diff --git a/.claude/agents/github-pr-reviewer.md b/.claude/agents/github-pr-reviewer.md deleted file mode 100644 index 8ae8da1af8f..00000000000 --- a/.claude/agents/github-pr-reviewer.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: github-pr-reviewer -description: Reviews GitHub pull requests and provides feedback comments. -disallowedTools: Write, Edit ---- - -# Review GitHub Pull Request - -## Follow these steps: -1. Use 'gh pr view' to get the PR details and description. -2. Use 'gh pr diff' to see all the changes in the PR. -3. Analyze the code changes for: - - Code quality and style consistency - - Potential bugs or issues - - Performance implications - - Security concerns - - Test coverage - - Documentation updates if needed -4. Ensure any existing review comments have been addressed. -5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF. - -## IMPORTANT: -- Just review. DO NOT make any changes -- Be constructive and specific in your comments -- Suggest improvements where appropriate -- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB. -- No need to run tests or linters, just review the code changes. -- No need to highlight things that are already good. - -## Output format: -- List specific comments for each file/line that needs attention -- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any. - - Example output: - ``` - Overall assessment: request changes. - - [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143 - - [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87 - - [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45 - ``` diff --git a/.claude/agents/raise-pull-request.md b/.claude/agents/raise-pull-request.md index 3d2d53406b1..e466df6fa7e 100644 --- a/.claude/agents/raise-pull-request.md +++ b/.claude/agents/raise-pull-request.md @@ -186,15 +186,11 @@ If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking ch ## Step 10: Push Branch and Create PR -```bash -# Get branch name and GitHub username -BRANCH=$(git branch --show-current) -PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1) -GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#') +Push the branch with upstream tracking, and create a PR against `home-assistant/core` with the generated title and body: +```bash # Create PR (gh pr create pushes the branch automatically) gh pr create --repo home-assistant/core --base dev \ - --head "$GITHUB_USER:$BRANCH" \ --draft \ --title "TITLE_HERE" \ --body "$(cat <<'EOF' diff --git a/.claude/skills/github-pr-reviewer/SKILL.md b/.claude/skills/github-pr-reviewer/SKILL.md new file mode 100644 index 00000000000..36441f5efd4 --- /dev/null +++ b/.claude/skills/github-pr-reviewer/SKILL.md @@ -0,0 +1,15 @@ +--- +name: github-pr-reviewer +description: Reviews GitHub pull requests and provides feedback comments. This is the top skill to use for reviewing Pull Requests from GitHub. +--- + +# Review GitHub Pull Request + +## Follow these steps: +1. Use 'gh pr view' to get the PR details and description. +2. Use 'gh pr diff' to see all the changes in the PR. +3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions. +4. Check if all existing review comments have been addressed. + +## IMPORTANT: +- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB. diff --git a/.claude/skills/integrations/SKILL.md b/.claude/skills/ha-integration-knowledge/SKILL.md similarity index 59% rename from .claude/skills/integrations/SKILL.md rename to .claude/skills/ha-integration-knowledge/SKILL.md index f2fb755ae8d..c7df10dacd5 100644 --- a/.claude/skills/integrations/SKILL.md +++ b/.claude/skills/ha-integration-knowledge/SKILL.md @@ -1,5 +1,5 @@ --- -name: Home Assistant Integration knowledge +name: ha-integration-knowledge description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference. --- @@ -12,11 +12,19 @@ description: Everything you need to know to build, test and review Home Assistan - When looking for examples, prefer integrations with the platinum or gold quality scale level first. - Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries. - Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names. +- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely. +- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast. +- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining. +- Integrations should not implement fixes or workarounds for limitations in libraries. Instead, the library should be updated to fix the issue. The following platforms have extra guidelines: - **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection - **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues +## Entity platforms + +- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`. +- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead. ## Integration Quality Scale diff --git a/.claude/skills/integrations/platform-diagnostics.md b/.claude/skills/ha-integration-knowledge/platform-diagnostics.md similarity index 100% rename from .claude/skills/integrations/platform-diagnostics.md rename to .claude/skills/ha-integration-knowledge/platform-diagnostics.md diff --git a/.claude/skills/integrations/platform-repairs.md b/.claude/skills/ha-integration-knowledge/platform-repairs.md similarity index 100% rename from .claude/skills/integrations/platform-repairs.md rename to .claude/skills/ha-integration-knowledge/platform-repairs.md diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md new file mode 100644 index 00000000000..ae92867fb34 --- /dev/null +++ b/.claude/skills/review/SKILL.md @@ -0,0 +1,38 @@ +--- +name: review +description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests. +--- + +# Review Code Changes + +## Analyze the code changes for: +- Code quality and style consistency +- Potential bugs or issues +- Performance implications +- Security concerns +- Test coverage +- Documentation updates if needed + +## Verification: +- After the review, run parallel subagents for each finding to double-check it. +- Spawn up to a maximum of 10 parallel subagents at a time. +- Gather the results from the subagents and summarize them in the final review comments. + +## IMPORTANT: +- Just review. DO NOT make any changes. +- Be constructive and specific in your comments. +- Suggest improvements where appropriate. +- No need to run tests or linters, just review the code changes. +- No need to highlight things that are already good. + +## Output format: +- List specific comments for each file/line that needs attention. +- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any. + - Example output: + ``` + Overall assessment: request changes. + - [CRITICAL] sensor.py:143 - Memory leak + - [PROBLEM] data_processing.py:87 - Inefficient algorithm + - [SUGGESTION] test_init.py:45 - Improve x variable name + ``` + - Make sure to include the file and line number when possible in the bullet points. diff --git a/.core_files.yaml b/.core_files.yaml index 62a787df0fd..ea08fd4a53c 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -36,6 +36,7 @@ base_platforms: &base_platforms - homeassistant/components/image_processing/** - homeassistant/components/infrared/** - homeassistant/components/lawn_mower/** + - homeassistant/components/radio_frequency/** - homeassistant/components/light/** - homeassistant/components/lock/** - homeassistant/components/media_player/** diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000000..cdd61126b13 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", + "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" + } + } +} diff --git a/.gitattributes b/.gitattributes index 606d6837628..d1fbf3ca074 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,12 +14,12 @@ Dockerfile.dev linguist-language=Dockerfile # Generated files CODEOWNERS linguist-generated=true -Dockerfile linguist-generated=true homeassistant/generated/*.py linguist-generated=true +pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true machine/* linguist-generated=true mypy.ini linguist-generated=true requirements.txt linguist-generated=true requirements_all.txt linguist-generated=true -requirements_test_all.txt linguist-generated=true requirements_test_pre_commit.txt linguist-generated=true script/hassfest/docker/Dockerfile linguist-generated=true +.github/workflows/*.lock.yml linguist-generated=true diff --git a/.github/actions/cache-apt-packages/action.yml b/.github/actions/cache-apt-packages/action.yml new file mode 100644 index 00000000000..c33526ef43e --- /dev/null +++ b/.github/actions/cache-apt-packages/action.yml @@ -0,0 +1,52 @@ +name: Cache and install APT packages +description: >- + Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI + needs. Removes the conflicting Microsoft apt source before any apt run, and + points the dynamic linker at the host's multiarch lib subdirectories so + shared libraries that rely on update-alternatives or postinst-managed paths + (eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream + action does not execute postinst scripts on cache restore. + +inputs: + packages: + description: Space-delimited list of apt packages to install. + required: true + version: + description: Cache version. Bump to invalidate the cache. + required: false + default: "1" + execute_install_scripts: + description: >- + Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not + actually cached by the upstream action, so this is largely a no-op today. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Remove conflicting Microsoft apt source + shell: bash + run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + - name: Install apt packages via cache + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + with: + packages: ${{ inputs.packages }} + version: ${{ inputs.version }} + execute_install_scripts: ${{ inputs.execute_install_scripts }} + - name: Refresh dynamic linker cache + shell: bash + run: | + # awalsh128/cache-apt-pkgs-action does not run postinst scripts on + # cache restore, so update-alternatives symlinks (eg the one libblas + # creates at /usr/lib//libblas.so.3) are never produced. + # Add every /usr/lib/ subdirectory that holds shared + # libraries to the ldconfig search path so the dynamic linker still + # finds them. Use dpkg-architecture to derive the host's multiarch + # tuple so this works on non-x86_64 runners too. + multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" + find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \ + -name '*.so.*' -printf '%h\n' \ + | sort -u \ + | sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null + sudo ldconfig diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bca27d97cd8..96cc8f2e4cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,8 @@ # Copilot code review instructions - Start review comments with a short, one-sentence summary of the suggested fix. -- Do not add comments about code style, formatting or linting issues. +- Do not comment on code style, formatting or linting issues. +- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. # GitHub Copilot & Claude Code Instructions @@ -15,24 +16,38 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review +## Pull Requests + +- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template. +- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected. + ## Development Commands -.vscode/tasks.json contains useful commands used for development. +- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. +- .vscode/tasks.json contains useful commands used for development. +- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues. ## Python Syntax Notes -- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. +- Home Assistant officially supports Python 3.14 as its minimum version. Do not flag syntax or features that require Python 3.14 as issues, and do not suggest workarounds for older Python versions. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue. +- Python 3.14 evaluates annotations lazily (PEP 649). Forward references in annotations do not need to be quoted — annotations can reference names defined later in the module without quoting them or using `from __future__ import annotations`. Do not flag unquoted forward references in annotations as issues. ## Testing -When writing or modifying tests, ensure all test function parameters have type annotations. -Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. +- Use `uv run pytest` to run tests +- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration `. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`. +- When writing or modifying tests, ensure all test function parameters have type annotations. +- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. +- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used. +- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching. +- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly. +- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself. +- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant. ## Good practices -Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration. - - -# Skills - -- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md +- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration. +- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form. +- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked. +- 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e04aba50e62..5a2b21d2dee 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,6 @@ updates: - github_actions cooldown: default-days: 7 + ignore: + # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. + - dependency-name: "github/gh-aw-actions/**" diff --git a/.github/instructions/integrations.instructions.md b/.github/instructions/integrations.instructions.md new file mode 100644 index 00000000000..420ca7d65b1 --- /dev/null +++ b/.github/instructions/integrations.instructions.md @@ -0,0 +1,51 @@ +--- +applyTo: "homeassistant/components/**, tests/components/**" +excludeAgent: "cloud-agent" +--- + + + + +## File Locations +- **Integration code**: `./homeassistant/components//` +- **Integration tests**: `./tests/components//` + +## General guidelines + +- When looking for examples, prefer integrations with the platinum or gold quality scale level first. +- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries. +- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names. +- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely. +- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast. +- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining. +- Integrations should not implement fixes or workarounds for limitations in libraries. Instead, the library should be updated to fix the issue. + +The following platforms have extra guidelines: +- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection +- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues + +## Entity platforms + +- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`. +- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead. + +## Integration Quality Scale + +- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules +- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices. + +Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml` + +### How Rules Apply +1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level +2. **Bronze Rules**: Always required for any integration with quality scale +3. **Higher Tier Rules**: Only apply if integration targets that tier or higher +4. **Rule Status**: Check `quality_scale.yaml` in integration folder for: + - `done`: Rule implemented + - `exempt`: Rule doesn't apply (with reason in comment) + - `todo`: Rule needs implementation + + +## Testing Requirements + +- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000000..08b017c21e2 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + + "enabledManagers": [ + "pep621", + "pip_requirements", + "pre-commit", + "dockerfile", + "custom.regex", + "homeassistant-manifest" + ], + + "pre-commit": { + "enabled": true + }, + + "pip_requirements": { + "managerFilePatterns": [ + "/(^|/)requirements[\\w_-]*\\.txt$/", + "/(^|/)homeassistant/package_constraints\\.txt$/" + ] + }, + + "dockerfile": { + "managerFilePatterns": ["/^Dockerfile$/"] + }, + + "homeassistant-manifest": { + "managerFilePatterns": [ + "/^homeassistant/components/[^/]+/manifest\\.json$/" + ] + }, + + "customManagers": [ + { + "customType": "regex", + "description": "Update ruff required-version in pyproject.toml", + "managerFilePatterns": ["/^pyproject\\.toml$/"], + "matchStrings": ["required-version = \">=(?[\\d.]+)\""], + "depNameTemplate": "ruff", + "datasourceTemplate": "pypi" + }, + { + "customType": "regex", + "description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin", + "managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"], + "matchStrings": ["RECOMMENDED_VERSION = \"(?[\\d.]+)\""], + "depNameTemplate": "ghcr.io/alexxit/go2rtc", + "datasourceTemplate": "docker" + } + ], + + "minimumReleaseAge": "7 days", + "prConcurrentLimit": 10, + "prHourlyLimit": 2, + "schedule": ["before 6am"], + + "semanticCommits": "disabled", + "commitMessageAction": "Update", + "commitMessageTopic": "{{depName}}", + "commitMessageExtra": "to {{newVersion}}", + + "automerge": false, + + "vulnerabilityAlerts": { + "enabled": false + }, + + "packageRules": [ + { + "description": "Deny all by default — allowlist below re-enables specific packages", + "matchPackageNames": ["*"], + "enabled": false + }, + { + "description": "Core runtime dependencies (allowlisted)", + "matchPackageNames": [ + "aiohttp", + "aiohttp-fast-zlib", + "aiohttp_cors", + "aiohttp-asyncmdnsresolver", + "yarl", + "httpx", + "requests", + "urllib3", + "certifi", + "orjson", + "PyYAML", + "Jinja2", + "cryptography", + "pyOpenSSL", + "PyJWT", + "SQLAlchemy", + "Pillow", + "attrs", + "uv", + "voluptuous", + "voluptuous-serialize", + "voluptuous-openapi", + "zeroconf" + ], + "enabled": true, + "labels": ["dependency", "core"] + }, + { + "description": "Common Python utilities (allowlisted)", + "matchPackageNames": [ + "astral", + "atomicwrites-homeassistant", + "audioop-lts", + "awesomeversion", + "bcrypt", + "ciso8601", + "cronsim", + "defusedxml", + "fnv-hash-fast", + "getmac", + "ical", + "ifaddr", + "lru-dict", + "mutagen", + "propcache", + "pyserial", + "python-slugify", + "PyTurboJPEG", + "securetar", + "standard-aifc", + "standard-telnetlib", + "ulid-transform", + "unidiff", + "url-normalize", + "xmltodict" + ], + "enabled": true, + "labels": ["dependency"] + }, + { + "description": "Home Assistant ecosystem packages (core-maintained, no cooldown)", + "matchPackageNames": [ + "hassil", + "home-assistant-bluetooth", + "home-assistant-frontend", + "home-assistant-intents", + "infrared-protocols", + "rf-protocols" + ], + "enabled": true, + "minimumReleaseAge": null, + "labels": ["dependency", "core"] + }, + { + "description": "Test dependencies (allowlisted)", + "matchPackageNames": [ + "pytest", + "pytest-asyncio", + "pytest-aiohttp", + "pytest-cov", + "pytest-freezer", + "pytest-github-actions-annotate-failures", + "pytest-socket", + "pytest-sugar", + "pytest-timeout", + "pytest-unordered", + "pytest-picked", + "pytest-xdist", + "pylint", + "pylint-per-file-ignores", + "astroid", + "coverage", + "freezegun", + "syrupy", + "respx", + "requests-mock", + "ruff", + "codespell", + "yamllint", + "zizmor" + ], + "enabled": true, + "labels": ["dependency"] + }, + { + "description": "For types-* stubs, only allow patch updates. Major/minor bumps track the upstream runtime package version and must be manually coordinated with the corresponding pin.", + "matchPackageNames": ["/^types-/"], + "matchUpdateTypes": ["patch"], + "enabled": true, + "labels": ["dependency"] + }, + { + "description": "Pre-commit hook repos (allowlisted, matched by owner/repo)", + "matchPackageNames": [ + "astral-sh/ruff-pre-commit", + "codespell-project/codespell", + "adrienverge/yamllint", + "zizmorcore/zizmor-pre-commit" + ], + "enabled": true, + "labels": ["dependency"] + }, + { + "description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)", + "matchPackageNames": ["ghcr.io/alexxit/go2rtc"], + "enabled": true, + "minimumReleaseAge": null, + "labels": ["dependency"] + }, + { + "description": "Group ruff pre-commit hook with its PyPI twin into one PR", + "matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"], + "groupName": "ruff", + "groupSlug": "ruff" + }, + { + "description": "Group codespell pre-commit hook with its PyPI twin into one PR", + "matchPackageNames": ["codespell-project/codespell", "codespell"], + "groupName": "codespell", + "groupSlug": "codespell" + }, + { + "description": "Group yamllint pre-commit hook with its PyPI twin into one PR", + "matchPackageNames": ["adrienverge/yamllint", "yamllint"], + "groupName": "yamllint", + "groupSlug": "yamllint" + }, + { + "description": "Group zizmor pre-commit hook with its PyPI twin into one PR", + "matchPackageNames": ["zizmorcore/zizmor-pre-commit", "zizmor"], + "groupName": "zizmor", + "groupSlug": "zizmor" + }, + { + "description": "Group pylint with astroid (their versions are linked and must move together)", + "matchPackageNames": ["pylint", "astroid"], + "groupName": "pylint", + "groupSlug": "pylint" + }, + { + "description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR", + "matchPackageNames": ["ghcr.io/alexxit/go2rtc"], + "groupName": "go2rtc", + "groupSlug": "go2rtc" + } + ] +} diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 926b478e10b..5b566952216 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -14,7 +14,7 @@ env: UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" # Base image version from https://github.com/home-assistant/docker - BASE_IMAGE_VERSION: "2026.01.0" + BASE_IMAGE_VERSION: "2026.05.0" ARCHITECTURES: '["amd64", "aarch64"]' permissions: {} @@ -47,10 +47,6 @@ jobs: with: python-version-file: ".python-version" - - name: Get information - id: info - uses: home-assistant/actions/helpers/info@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # zizmor: ignore[unpinned-uses] - - name: Get version id: version uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] @@ -80,7 +76,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: translations path: translations.tar.gz @@ -112,7 +108,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -123,7 +119,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package @@ -327,7 +323,7 @@ jobs: exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]' publish_container: - name: Publish meta container for ${{ matrix.registry }} + name: Publish to ${{ matrix.registry }} environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] @@ -342,19 +338,19 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Install Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v2.5.3" - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -384,7 +380,7 @@ jobs: # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev - name: Generate Docker metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ matrix.registry }}/home-assistant sep-tags: "," @@ -398,7 +394,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1 - name: Copy architecture images to DockerHub if: matrix.registry == 'docker.io/homeassistant' @@ -503,7 +499,7 @@ jobs: python -m build - name: Upload package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true @@ -527,14 +523,14 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -547,7 +543,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile diff --git a/.github/workflows/check-requirements-deterministic.yml b/.github/workflows/check-requirements-deterministic.yml new file mode 100644 index 00000000000..8313fafc669 --- /dev/null +++ b/.github/workflows/check-requirements-deterministic.yml @@ -0,0 +1,74 @@ +name: Check requirements (deterministic) + +# Stage 1 of the Check requirements pipeline. +# +# Runs the deterministic Python checks and uploads the structured +# results as an artifact. Stage 2 (the agentic workflow defined in +# `check-requirements.md`) consumes the artifact on completion. + +# yamllint disable-line rule:truthy +on: + # Auto-trigger on PRs that touch tracked requirement files is disabled + # for now while we iterate — testing the workflow_run handoff to the + # agentic stage is hard with an auto-trigger. Re-enable once the chain + # has been validated end-to-end. + # pull_request: + # types: [opened, synchronize, reopened] + # paths: + # - "**/requirements*.txt" + # - "homeassistant/package_constraints.txt" + workflow_dispatch: + inputs: + pull_request_number: + description: "Pull request number to (re-)check" + required: true + type: number + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ inputs.pull_request_number || github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deterministic: + name: Run deterministic requirement checks + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read # To fetch the PR diff via gh CLI + timeout-minutes: 10 + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: ".python-version" + check-latest: true + - name: Install script dependencies + run: pip install -r script/check_requirements/requirements.txt + - name: Collect PR diff + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run: | + mkdir -p deterministic + gh pr diff "${PR_NUMBER}" > deterministic/pr.diff + - name: Run deterministic checks + env: + PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run: | + python -m script.check_requirements \ + --pr-number "${PR_NUMBER}" \ + --diff deterministic/pr.diff \ + --output deterministic/results.json + - name: Upload deterministic-results artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: check-requirements-deterministic + path: deterministic/results.json + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml new file mode 100644 index 00000000000..7813634694b --- /dev/null +++ b/.github/workflows/check-requirements.lock.yml @@ -0,0 +1,1445 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"75b8b624ba0c144fb4b28cba143d16a47c30de8afae568fa3256c6febe01a68a","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.74.4). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed Python package requirements on PRs targeting the core repo, then posts the final review comment. Triggered by completion of the deterministic workflow. Reads the uploaded artifact from disk, replaces placeholders for any check whose status is `needs_agent`, and posts the merged comment using the PR number recorded inside the artifact itself. Each check kind has a dedicated instruction section below; if the artifact contains a check kind that does not have a section here, the agent fails hard rather than guess. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.25.46 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.46 +# - ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388 +# - ghcr.io/github/github-mcp-server:v1.0.4 +# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + +name: "Check requirements (AW)" +on: + workflow_run: + # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation + types: + - completed + workflows: + - Check requirements (deterministic) + +permissions: {} + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + +run-name: "Check requirements (AW)" + +jobs: + activation: + needs: + - extract_pr_number + - pre_activation + # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation + if: > + (needs.pre_activation.outputs.activated == 'true') && (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && + (!(github.event.workflow_run.repository.fork))) + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + comment_id: "" + comment_repo: "" + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_AGENT_VERSION: "1.0.48" + GH_AW_INFO_CLI_VERSION: "v0.74.4" + GH_AW_INFO_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["python"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.46" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + .claude + .codex + .crush + .gemini + .opencode + .pi + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "check-requirements.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.74.4" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + + GH_AW_PROMPT_198418d99edc7d5b_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + + Tools: add_comment, missing_tool, missing_data, noop + + GH_AW_PROMPT_198418d99edc7d5b_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_198418d99edc7d5b_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_198418d99edc7d5b_EOF' + + {{#runtime-import .github/workflows/check-requirements.md}} + GH_AW_PROMPT_198418d99edc7d5b_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools' + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: + - activation + - extract_pr_number + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: checkrequirements + outputs: + agentic_engine_timeout: ${{ steps.detect-copilot-errors.outputs.agentic_engine_timeout || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-copilot-errors.outputs.inference_access_error || 'false' }} + mcp_policy_error: ${{ steps.detect-copilot-errors.outputs.mcp_policy_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-copilot-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - if: github.event.workflow_run.conclusion == 'success' + name: Download deterministic-results artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + name: check-requirements-deterministic + path: /tmp/gh-aw/deterministic + run-id: ${{ github.event.workflow_run.id }} + + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46 + - name: Parse integrity filter lists + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh" + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_EXT: ".agent.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46 ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388 ghcr.io/github/github-mcp-server:v1.0.4 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF' + {"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_627e06df80c4e5ad_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "reply_to_id": { + "type": "string", + "maxLength": 256 + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + case "${DOCKER_HOST:-}" in + unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; + /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; + * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; + esac + DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.9' + + mkdir -p /home/runner/.copilot + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_175174907e5a28b4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.0.4", + "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions" + }, + "guard-policies": { + "allow-only": { + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, + "min-integrity": "unapproved", + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} + } + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_175174907e5a28b4_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["*.pythonhosted.org","anaconda.org","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","binstar.org","bootstrap.pypa.io","conda.anaconda.org","conda.binstar.org","files.pythonhosted.org","github.com","host.docker.internal","pip.pypa.io","pypi.org","pypi.python.org","raw.githubusercontent.com","registry.npmjs.org","repo.anaconda.com","repo.continuum.io","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"auto":["large"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.74.4 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect Copilot errors + id: detect-copilot-errors + if: always() + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_copilot_errors.cjs" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - if: always() && github.event.workflow_run.conclusion == 'success' + name: Verify agent produced an add_comment safe-output + run: |- + OUTPUT=/tmp/gh-aw/agent_output.json + if [ ! -f "${OUTPUT}" ]; then + echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion." + exit 1 + fi + if ! grep -q '"add_comment"' "${OUTPUT}"; then + echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR." + echo "Agent output:" + cat "${OUTPUT}" + exit 1 + fi + + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/proxy-logs/ + !/tmp/gh-aw/proxy-logs/proxy-tls/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - extract_pr_number + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-check-requirements" + cancel-in-progress: false + queue: max + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Log detection run + id: detection_runs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "check-requirements" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + GH_AW_MAX_EFFECTIVE_TOKENS: "25000000" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_reason: ${{ steps.detection_conclusion.outputs.reason }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Clean stale firewall files from agent artifact + run: | + rm -rf /tmp/gh-aw/sandbox/firewall/logs + rm -rf /tmp/gh-aw/sandbox/firewall/audit + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.46 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46 ghcr.io/github/gh-aw-firewall/squid:0.25.46 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP Config for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKFLOW_NAME: "Check requirements (AW)" + WORKFLOW_DESCRIPTION: "Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed Python package requirements on PRs targeting the core repo, then posts the final review comment. Triggered by completion of the deterministic workflow. Reads the uploaded artifact from disk, replaces placeholders for any check whose status is `needs_agent`, and posts the merged comment using the PR number recorded inside the artifact itself. Each check kind has a dedicated instruction section below; if the artifact contains a check kind that does not have a section here, the agent fails hard rather than guess." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.48 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.46 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.46/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.46"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.74.4 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + with: + script: | + try { + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + } catch (loadErr) { + const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; + const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; + const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); + core.error(msg); + core.setOutput('reason', 'parse_error'); + if (continueOnError && !detectionExecutionFailed) { + core.warning('\u26A0\uFE0F ' + msg); + core.setOutput('conclusion', 'warning'); + core.setOutput('success', 'false'); + } else { + core.setOutput('conclusion', 'failure'); + core.setOutput('success', 'false'); + core.setFailed(msg); + } + } + + extract_pr_number: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + + outputs: + pr_number: ${{ steps.extract.outputs.pr_number }} + steps: + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Download deterministic-results artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + name: check-requirements-deterministic + path: /tmp/deterministic + run-id: ${{ github.event.workflow_run.id }} + - name: Extract PR number from artifact + id: extract + run: | + PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) + echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + - detection + - extract_pr_number + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/check-requirements" + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.48" + GH_AW_WORKFLOW_ID: "check-requirements" + GH_AW_WORKFLOW_NAME: "Check requirements (AW)" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Check requirements (AW)" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/check-requirements.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.48" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + if-no-files-found: ignore + diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md new file mode 100644 index 00000000000..3174d7ecf6d --- /dev/null +++ b/.github/workflows/check-requirements.md @@ -0,0 +1,378 @@ +--- +on: + workflow_run: + workflows: ["Check requirements (deterministic)"] + types: [completed] +permissions: + contents: read + actions: read + issues: read + pull-requests: read +network: + allowed: + - python +tools: + web-fetch: {} + github: + toolsets: [default, actions] + min-integrity: unapproved +safe-outputs: + add-comment: + max: 1 + target: "${{ needs.extract_pr_number.outputs.pr_number }}" + needs: + - extract_pr_number +jobs: + extract_pr_number: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + outputs: + pr_number: ${{ steps.extract.outputs.pr_number }} + steps: + - name: Download deterministic-results artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: check-requirements-deterministic + path: /tmp/deterministic + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Extract PR number from artifact + id: extract + run: | + PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) + echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true +steps: + - name: Download deterministic-results artifact + if: github.event.workflow_run.conclusion == 'success' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: check-requirements-deterministic + path: /tmp/gh-aw/deterministic + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} +post-steps: + - name: Verify agent produced an add_comment safe-output + if: always() && github.event.workflow_run.conclusion == 'success' + run: | + OUTPUT=/tmp/gh-aw/agent_output.json + if [ ! -f "${OUTPUT}" ]; then + echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion." + exit 1 + fi + if ! grep -q '"add_comment"' "${OUTPUT}"; then + echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR." + echo "Agent output:" + cat "${OUTPUT}" + exit 1 + fi +description: > + Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed + Python package requirements on PRs targeting the core repo, then posts the + final review comment. Triggered by completion of the deterministic workflow. + Reads the uploaded artifact from disk, replaces placeholders for any check + whose status is `needs_agent`, and posts the merged comment using the PR + number recorded inside the artifact itself. Each check kind has a dedicated + instruction section below; if the artifact contains a check kind that does + not have a section here, the agent fails hard rather than guess. +--- + +# Check requirements (AW) + +You are a code review assistant for the Home Assistant project. The +deterministic stage has already evaluated every check it can on its own +and produced an artifact containing the PR number, per-package check +results, and a pre-rendered comment with placeholders. **Your only job is +to read that artifact, resolve any `needs_agent` checks, and post the +final comment.** + +## Step 1 — Read the deterministic-stage artifact + +The deterministic stage uploaded its results to the runner at +`/tmp/gh-aw/deterministic/results.json`. + +The JSON has this shape: + +- `pr_number` — the PR being checked. The `add_comment` safe-output is + already targeted at this PR (a pre-job extracts `pr_number` from the + artifact and the workflow wires it into the safe-output config via + `needs.extract_pr_number.outputs.pr_number`), so **you do not need to + set `item_number` yourself** — just emit `add_comment` with the + rendered body. +- `needs_agent` — `true` iff any package's check needs resolution. +- `packages[]` — one entry per changed package. Each entry has: + - `name`, `old_version` (`null` for a newly added package; otherwise the + previous pin), `new_version`, `repo_url`, `publisher_kind`. + - `checks` — a dict keyed by **check kind** (string). Each value has a + `status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`. +- `rendered_comment` — the final PR comment body, already rendered. For + every check whose status is `needs_agent` it contains two placeholders + you must replace: + - `{{CHECK_CELL::}}` — one cell of the summary + table. Replace with exactly one of `✅`, `⚠️`, `❌`. + - `{{CHECK_DETAIL::}}` — the body of one bullet + in the package's `
` block. Replace with + ` ` (the bullet's leading + `- **
\n\n" + + # Add custom themes information + try: + themes_info = self._get_themes_info(hass) + except Exception: # noqa: BLE001 + # Broad exception catch for robustness in support package generation + markdown += "## Custom Themes\n\n" + markdown += "Unable to collect themes information\n\n" + else: + markdown += "## Custom Themes\n\n" + markdown += f"Custom themes: {themes_info['count']}\n\n" + + if themes_info["themes"]: + markdown += "
Custom themes\n\n" + markdown += "Name\n" + markdown += "---\n" + for theme in themes_info["themes"]: + markdown += f"{theme}\n" markdown += "\n
\n\n" for domain, domain_info in domains_info.items(): @@ -615,6 +647,7 @@ class DownloadSupportPackageView(HomeAssistantView): return markdown + @require_admin async def get(self, request: web.Request) -> web.Response: """Download support package file.""" @@ -709,6 +742,7 @@ def _require_cloud_login( return with_cloud_auth +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) @websocket_api.async_response @@ -750,6 +784,7 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: return value +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { @@ -809,6 +844,7 @@ async def websocket_update_prefs( connection.send_message(websocket_api.result_message(msg["id"])) +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { @@ -829,6 +865,7 @@ async def websocket_hook_create( connection.send_message(websocket_api.result_message(msg["id"], hook)) +@websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( { @@ -964,7 +1001,7 @@ async def google_assistant_get( return entity = google_helpers.GoogleEntity(hass, gconf, state) - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + if not entity.is_supported(): connection.send_error( msg["id"], websocket_api.ERR_NOT_SUPPORTED, @@ -1066,9 +1103,7 @@ async def alexa_get( """Get data for a single alexa entity.""" entity_id: str = msg["entity_id"] - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa( - hass, entity_id - ): + if not entity_supported_by_alexa(hass, entity_id): connection.send_error( msg["id"], websocket_api.ERR_NOT_SUPPORTED, diff --git a/homeassistant/components/cloud/onboarding.py b/homeassistant/components/cloud/onboarding.py index ab0a0fbe310..b06106e4918 100644 --- a/homeassistant/components/cloud/onboarding.py +++ b/homeassistant/components/cloud/onboarding.py @@ -1,7 +1,5 @@ """Cloud onboarding views.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 412c0cf75a8..8b9debcba83 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,5 @@ """Preference management for cloud.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any import uuid @@ -11,7 +9,7 @@ from hass_nabucasa.voice import MAP_VOICE, Gender from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook -from homeassistant.components.google_assistant.http import ( # pylint: disable=hass-component-root-import +from homeassistant.components.google_assistant.http import ( # pylint: disable=home-assistant-component-root-import async_get_users as async_get_google_assistant_users, ) from homeassistant.core import HomeAssistant, callback @@ -286,7 +284,8 @@ class CloudPreferences: def alexa_default_expose(self) -> list[str] | None: """Return array of entity domains that are exposed by default to Alexa. - Can return None, in which case for backwards should be interpreted as allow all domains. + Can return None, in which case for backwards + should be interpreted as allow all domains. """ return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE) @@ -344,7 +343,8 @@ class CloudPreferences: def google_default_expose(self) -> list[str] | None: """Return array of entity domains that are exposed by default to Google. - Can return None, in which case for backwards should be interpreted as allow all domains. + Can return None, in which case for backwards + should be interpreted as allow all domains. """ return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE) diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index ed66cb8244f..5af9bc5d1ea 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -1,7 +1,5 @@ """Repairs implementation for the cloud integration.""" -from __future__ import annotations - import asyncio from hass_nabucasa.payments_api import SubscriptionInfo @@ -10,10 +8,10 @@ import voluptuous as vol from homeassistant.components.repairs import ( ConfirmRepairFlow, RepairsFlow, + RepairsFlowResult, repairs_flow_manager, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir from .const import DATA_CLOUD, DOMAIN @@ -52,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow): wait_task: asyncio.Task | None = None _data: SubscriptionInfo | None = None - async def async_step_init(self, _: None = None) -> FlowResult: + async def async_step_init(self, _: None = None) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm_change_plan() async def async_step_confirm_change_plan( self, user_input: dict[str, str] | None = None, - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: return await self.async_step_change_plan() @@ -68,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow): step_id="confirm_change_plan", data_schema=vol.Schema({}) ) - async def async_step_change_plan(self, _: None = None) -> FlowResult: + async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult: """Wait for the user to authorize the app installation.""" cloud = self.hass.data[DATA_CLOUD] @@ -109,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow): return self.async_external_step_done(next_step_id="complete") - async def async_step_complete(self, _: None = None) -> FlowResult: + async def async_step_complete(self, _: None = None) -> RepairsFlowResult: """Handle the final step of a fix flow.""" return self.async_create_entry(data={}) - async def async_step_timeout(self, _: None = None) -> FlowResult: + async def async_step_timeout(self, _: None = None) -> RepairsFlowResult: """Handle the final step of a fix flow.""" return self.async_abort(reason="operation_took_too_long") diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index df377c9a410..8ad95cc8afd 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,7 +1,5 @@ """Support for the cloud for speech to text service.""" -from __future__ import annotations - from collections.abc import AsyncIterable import logging diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 980823243bc..cfc2e40c1ff 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -1,7 +1,5 @@ """Subscription information.""" -from __future__ import annotations - import asyncio import logging @@ -27,7 +25,8 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo _LOGGER.error("Failed to fetch subscription information - %s", exception) except TimeoutError: _LOGGER.error( - "A timeout of %s was reached while trying to fetch subscription information", + "A timeout of %s was reached while trying to" + " fetch subscription information", REQUEST_TIMEOUT, ) return None diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 179f467922f..ddc3414e398 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,7 +1,5 @@ """Support for the cloud for text-to-speech service.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index e10ae22f404..3946a792832 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -1,7 +1,5 @@ """Update the IP addresses of your Cloudflare DNS records.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant, ServiceCall from .const import DOMAIN, SERVICE_UPDATE_RECORDS @@ -20,6 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) - """Set up service for manual trigger.""" await entry.runtime_data.async_request_refresh() + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) return True diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 1fad38c5afc..6ac7633d755 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Cloudflare integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/cloudflare/coordinator.py b/homeassistant/components/cloudflare/coordinator.py index fc01fa6ae68..7707a1b35a4 100644 --- a/homeassistant/components/cloudflare/coordinator.py +++ b/homeassistant/components/cloudflare/coordinator.py @@ -1,6 +1,4 @@ -"""Contains the Coordinator for updating the IP addresses of your Cloudflare DNS records.""" - -from __future__ import annotations +"""Coordinator for updating IP addresses of Cloudflare DNS records.""" import asyncio from datetime import timedelta diff --git a/homeassistant/components/cloudflare_r2/__init__.py b/homeassistant/components/cloudflare_r2/__init__.py index 0fd4089eae1..a392120b366 100644 --- a/homeassistant/components/cloudflare_r2/__init__.py +++ b/homeassistant/components/cloudflare_r2/__init__.py @@ -1,7 +1,5 @@ """The Cloudflare R2 integration.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/cloudflare_r2/backup.py b/homeassistant/components/cloudflare_r2/backup.py index 8f721186fa6..4e7b6933534 100644 --- a/homeassistant/components/cloudflare_r2/backup.py +++ b/homeassistant/components/cloudflare_r2/backup.py @@ -17,10 +17,11 @@ from homeassistant.components.backup import ( OnProgressCallback, suggested_filename, ) +from homeassistant.const import CONF_PREFIX from homeassistant.core import HomeAssistant, callback from . import R2ConfigEntry -from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) CACHE_TTL = 300 @@ -240,8 +241,10 @@ class R2BackupAgent(BackupAgent): finally: view.release() - # Compact the buffer if the consumed offset has grown large enough. This - # avoids unnecessary memory copies when compacting after every part upload. + # Compact the buffer if the consumed offset + # has grown large enough. This avoids + # unnecessary memory copies when compacting + # after every part upload. if offset and offset >= MULTIPART_MIN_PART_SIZE_BYTES: buffer = bytearray(buffer[offset:]) offset = 0 diff --git a/homeassistant/components/cloudflare_r2/config_flow.py b/homeassistant/components/cloudflare_r2/config_flow.py index 323b4ac3dec..d7ee64a2d26 100644 --- a/homeassistant/components/cloudflare_r2/config_flow.py +++ b/homeassistant/components/cloudflare_r2/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Cloudflare R2 integration.""" -from __future__ import annotations - from typing import Any from urllib.parse import urlparse @@ -15,6 +13,7 @@ from botocore.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PREFIX from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( TextSelector, @@ -27,7 +26,6 @@ from .const import ( CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_ENDPOINT_URL, - CONF_PREFIX, CONF_SECRET_ACCESS_KEY, DEFAULT_ENDPOINT_URL, DESCRIPTION_R2_AUTH_DOCS_URL, diff --git a/homeassistant/components/cloudflare_r2/const.py b/homeassistant/components/cloudflare_r2/const.py index 28b685e22e4..b5ddfbca591 100644 --- a/homeassistant/components/cloudflare_r2/const.py +++ b/homeassistant/components/cloudflare_r2/const.py @@ -11,7 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id" CONF_SECRET_ACCESS_KEY = "secret_access_key" CONF_ENDPOINT_URL = "endpoint_url" CONF_BUCKET = "bucket" -CONF_PREFIX = "prefix" # R2 is S3-compatible. Endpoint should be like: # https://.r2.cloudflarestorage.com diff --git a/homeassistant/components/cloudflare_r2/quality_scale.yaml b/homeassistant/components/cloudflare_r2/quality_scale.yaml index 9b9ed3e6619..36a8c0c8676 100644 --- a/homeassistant/components/cloudflare_r2/quality_scale.yaml +++ b/homeassistant/components/cloudflare_r2/quality_scale.yaml @@ -94,7 +94,7 @@ rules: entity-translations: status: exempt comment: This integration does not have entities. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: This integration does not use icons. diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index a1f303809d0..43bb4f19e23 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -1,7 +1,5 @@ """Support for interacting with and controlling the cmus music player.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 612610eff43..1973309113c 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,7 +1,5 @@ """The CO2 Signal integration.""" -from __future__ import annotations - from aioelectricitymaps import ElectricityMaps from homeassistant.const import CONF_API_KEY, Platform diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 2401121b76e..e5256dc667a 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Co2signal integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index f29f3c72f1f..275fa786ac9 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the co2signal integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index 840ba759a7b..13e6d9d2bcc 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for CO2Signal.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 207b412ec33..2466e7f6663 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -1,7 +1,5 @@ """Helper functions for the CO2 Signal integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 9cf5ae4c9a7..48288af3240 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,7 +1,5 @@ """Support for the CO2signal platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py index 5ec1f79c466..a70ab53067b 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -1,7 +1,5 @@ """Utils for CO2 signal.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index dca7f774331..b9e677098be 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -1,7 +1,5 @@ """The Coinbase integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -65,7 +63,8 @@ def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: raise ConfigEntryAuthFailed( "Your Coinbase API key appears to be for the deprecated v2 API. " "Please reconfigure with a new API key created for the v3 API. " - "Visit https://www.coinbase.com/developer-platform to create new credentials." + "Visit https://www.coinbase.com/developer-platform" + " to create new credentials." ) client = RESTClient( diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index a79bd2493e1..b0f4c486117 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Coinbase integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -302,4 +300,4 @@ class CurrencyUnavailable(HomeAssistantError): class ExchangeRateUnavailable(HomeAssistantError): - """Error to indicate the requested exchange rate resource is not provided by the API.""" + """Error to indicate the requested exchange rate is not provided by the API.""" diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 4dfc744b7fa..245cd3e1b18 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -1,7 +1,5 @@ """Support for Coinbase sensors.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass @@ -159,7 +157,9 @@ class AccountSensor(SensorEntity): def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the sensor.""" return { - ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", + ATTR_NATIVE_BALANCE: ( + f"{self._native_balance} {self._coinbase_data.exchange_base}" + ), } def update(self) -> None: diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py index aab56eb9537..0a922dbb7f3 100644 --- a/homeassistant/components/color_extractor/config_flow.py +++ b/homeassistant/components/color_extractor/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Color extractor integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/color_extractor/const.py b/homeassistant/components/color_extractor/const.py index 25c15ed9dc0..45b9b337213 100644 --- a/homeassistant/components/color_extractor/const.py +++ b/homeassistant/components/color_extractor/const.py @@ -6,4 +6,4 @@ ATTR_URL = "color_extract_url" DOMAIN = "color_extractor" DEFAULT_NAME = "Color extractor" -SERVICE_TURN_ON = "turn_on" +SERVICE_GET_COLOR = "get_color" diff --git a/homeassistant/components/color_extractor/icons.json b/homeassistant/components/color_extractor/icons.json index 9dab17a9f3b..a15fb9c120b 100644 --- a/homeassistant/components/color_extractor/icons.json +++ b/homeassistant/components/color_extractor/icons.json @@ -1,5 +1,8 @@ { "services": { + "get_color": { + "service": "mdi:select-color" + }, "turn_on": { "service": "mdi:lightbulb-on" } diff --git a/homeassistant/components/color_extractor/services.py b/homeassistant/components/color_extractor/services.py index d5d90bca308..273a54107f4 100644 --- a/homeassistant/components/color_extractor/services.py +++ b/homeassistant/components/color_extractor/services.py @@ -3,6 +3,7 @@ import asyncio import io import logging +from typing import Any import aiohttp from colorthief import ColorThief @@ -14,16 +15,17 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, LIGHT_TURN_ON_SCHEMA, ) -from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.const import SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON +from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_GET_COLOR _LOGGER = logging.getLogger(__name__) # Extend the existing light.turn_on service schema -SERVICE_SCHEMA = vol.All( +TURN_ON_SERVICE_SCHEMA = vol.All( cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), cv.make_entity_service_schema( { @@ -34,6 +36,14 @@ SERVICE_SCHEMA = vol.All( ), ) +GET_COLOR_SERVICE_SCHEMA = vol.All( + cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), + { + vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile, + vol.Exclusive(ATTR_URL, "color_extractor"): cv.url, + }, +) + def _get_file(file_path: str) -> str: """Get a PIL acceptable input file reference. @@ -127,6 +137,7 @@ async def async_handle_service(service_call: ServiceCall) -> None: _extract_color_from_path, service_call.hass, image_reference ) + # pylint: disable-next=home-assistant-action-swallowed-exception except UnidentifiedImageError as ex: _LOGGER.error( "Bad image from %s '%s' provided, are you sure it's an image? %s", @@ -140,10 +151,54 @@ async def async_handle_service(service_call: ServiceCall) -> None: service_data[ATTR_RGB_COLOR] = color await service_call.hass.services.async_call( - LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True ) +async def async_handle_get_color( + service_call: ServiceCall, +) -> dict[str, Any]: + """Handle get_color service call.""" + service_data = dict(service_call.data) + + try: + if ATTR_URL in service_data: + image_type = "URL" + image_reference = service_data.pop(ATTR_URL) + color = await _async_extract_color_from_url( + service_call.hass, image_reference + ) + + elif ATTR_PATH in service_data: + image_type = "file path" + image_reference = service_data.pop(ATTR_PATH) + color = await service_call.hass.async_add_executor_job( + _extract_color_from_path, service_call.hass, image_reference + ) + + except UnidentifiedImageError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_image", + translation_placeholders={ + "image_type": image_type, + "image_reference": image_reference, + }, + ) from ex + + if color is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_image", + translation_placeholders={ + "image_type": image_type, + "image_reference": image_reference, + }, + ) + + return {"color": color} + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register the services.""" @@ -152,5 +207,13 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_TURN_ON, async_handle_service, - schema=SERVICE_SCHEMA, + schema=TURN_ON_SERVICE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_COLOR, + async_handle_get_color, + schema=GET_COLOR_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/color_extractor/services.yaml b/homeassistant/components/color_extractor/services.yaml index 2fd0b0db815..261843eb007 100644 --- a/homeassistant/components/color_extractor/services.yaml +++ b/homeassistant/components/color_extractor/services.yaml @@ -11,3 +11,13 @@ turn_on: example: /opt/images/logo.png selector: text: +get_color: + fields: + color_extract_url: + example: https://www.example.com/images/logo.png + selector: + text: + color_extract_path: + example: /opt/images/logo.png + selector: + text: diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index cd628d5ab57..80dbd07b77b 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -6,7 +6,26 @@ } } }, + "exceptions": { + "invalid_image": { + "message": "Bad image {image_reference} from {image_type} provided, are you sure it's an image?" + } + }, "services": { + "get_color": { + "description": "Gets the predominant RGB color found in the image provided by URL or file path.", + "fields": { + "color_extract_path": { + "description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs.", + "name": "[%key:common::config_flow::data::path%]" + }, + "color_extract_url": { + "description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls.", + "name": "[%key:common::config_flow::data::url%]" + } + }, + "name": "Get predominant color" + }, "turn_on": { "description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.", "fields": { diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 4aef024d15e..fd744a36735 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -1,7 +1,5 @@ """Support for ComEd Hourly Pricing data.""" -from __future__ import annotations - import asyncio from datetime import timedelta import json diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index ba195fc43a4..9e7b4ee0991 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -3,9 +3,10 @@ from aiocomelit.const import BRIDGE from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import CONF_VEDO_PIN, DEFAULT_PORT +from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN from .coordinator import ( ComelitBaseCoordinator, ComelitConfigEntry, @@ -81,6 +82,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b return True +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ComelitConfigEntry +) -> bool: + """Migrate old entry.""" + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1 and config_entry.minor_version == 1: + device_registry = dr.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + if ( + entry.domain != Platform.SENSOR + or entry.device_id is None + or not (device_entry := device_registry.async_get(entry.device_id)) + or not any( + platform == DOMAIN + and identifier.startswith(f"{config_entry.entry_id}-zone-") + for platform, identifier in device_entry.identifiers + ) + ): + return None + + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + zone_index = entry.unique_id.removeprefix(f"{config_entry.entry_id}-") + return { + "new_unique_id": f"{config_entry.entry_id}-human_status-{zone_index}" + } + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + hass.config_entries.async_update_entry(config_entry, version=1, minor_version=2) + + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index de2186cf7f3..2edf6bd9a76 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Comelit VEDO system.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, cast @@ -112,7 +110,7 @@ class ComelitAlarmEntity( @property def available(self) -> bool: """Return True if alarm is available.""" - if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + if self._area.human_status is AlarmAreaState.UNKNOWN: return False return super().available @@ -126,7 +124,7 @@ class ComelitAlarmEntity( self._area.human_status, self._area.armed, ) - if self._area.human_status == AlarmAreaState.ARMED: + if self._area.human_status is AlarmAreaState.ARMED: if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]: return AlarmControlPanelState.ARMED_AWAY if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]: @@ -151,7 +149,7 @@ class ComelitAlarmEntity( if code != str(self.coordinator.api.device_pin): return await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[DISABLE] + self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly ) await self._async_update_state( AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] @@ -160,7 +158,7 @@ class ComelitAlarmEntity( async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[AWAY] + self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] @@ -169,7 +167,7 @@ class ComelitAlarmEntity( async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[HOME] + self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] @@ -178,7 +176,7 @@ class ComelitAlarmEntity( async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[NIGHT] + self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index d512ebc4f3d..3ddeb526d01 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -1,15 +1,16 @@ -"""Support for sensors.""" +"""Support for binary sensors.""" -from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, cast -from typing import TYPE_CHECKING, cast - -from aiocomelit.api import ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONE, AlarmZoneState +from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,12 +24,68 @@ from .utils import new_device_listener PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class ComelitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Comelit binary sensor entity description.""" + + object_type: str + is_on_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] + available_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] = ( + lambda obj: True + ) + + +BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = ( + ComelitBinarySensorEntityDescription( + key="anomaly", + translation_key="anomaly", + object_type=ALARM_AREA, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly, + available_fn=lambda obj: ( + cast(ComelitVedoAreaObject, obj).human_status is not AlarmAreaState.UNKNOWN + ), + ), + ComelitBinarySensorEntityDescription( + key="presence", + translation_key="motion", + object_type=ALARM_ZONE, + device_class=BinarySensorDeviceClass.MOTION, + is_on_fn=lambda obj: cast(ComelitVedoZoneObject, obj).status_api == "0001", + available_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status + not in { + AlarmZoneState.FAULTY, + AlarmZoneState.UNAVAILABLE, + AlarmZoneState.UNKNOWN, + } + ), + ), + ComelitBinarySensorEntityDescription( + key="faulty", + translation_key="faulty", + object_type=ALARM_ZONE, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status is AlarmZoneState.FAULTY + ), + available_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status + not in { + AlarmZoneState.UNAVAILABLE, + AlarmZoneState.UNKNOWN, + } + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Comelit VEDO presence sensors.""" + """Set up Comelit VEDO binary sensors.""" coordinator = config_entry.runtime_data is_bridge = isinstance(coordinator, ComelitSerialBridge) @@ -42,13 +99,23 @@ async def async_setup_entry( def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None: """Add entities for new monitors.""" entities = [ - ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) + ComelitVedoBinarySensorEntity( + coordinator, + device, + config_entry.entry_id, + description, + ) + for description in BINARY_SENSOR_TYPES for device in coordinator.data[dev_type].values() + if description.object_type == dev_type if device in new_devices ] if entities: async_add_entities(entities) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, ALARM_AREA) + ) config_entry.async_on_unload( new_device_listener(coordinator, _add_new_entities, ALARM_ZONE) ) @@ -59,42 +126,47 @@ class ComelitVedoBinarySensorEntity( ): """Sensor device.""" + entity_description: ComelitBinarySensorEntityDescription + _attr_has_entity_name = True - _attr_device_class = BinarySensorDeviceClass.MOTION def __init__( self, coordinator: ComelitVedoSystem | ComelitSerialBridge, - zone: ComelitVedoZoneObject, + object_data: ComelitVedoAreaObject | ComelitVedoZoneObject, config_entry_entry_id: str, + description: ComelitBinarySensorEntityDescription, ) -> None: """Init sensor entity.""" - self._zone_index = zone.index + self.entity_description = description + self._object_index = object_data.index + self._object_type = description.object_type super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}" - self._attr_device_info = coordinator.platform_device_info(zone, "zone") + self._attr_unique_id = ( + f"{config_entry_entry_id}-{description.key}-{self._object_index}" + ) + self._attr_device_info = coordinator.platform_device_info( + object_data, "area" if self._object_type == ALARM_AREA else "zone" + ) @property - def _zone(self) -> ComelitVedoZoneObject: - """Return zone object.""" + def _object(self) -> ComelitVedoAreaObject | ComelitVedoZoneObject: + """Return alarm object.""" return cast( - ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index] + ComelitVedoAreaObject | ComelitVedoZoneObject, + self.coordinator.data[self._object_type][self._object_index], ) @property def available(self) -> bool: - """Return True if alarm is available.""" - if self._zone.human_status in [ - AlarmZoneState.FAULTY, - AlarmZoneState.UNAVAILABLE, - AlarmZoneState.UNKNOWN, - ]: + """Return True if object is available.""" + if not self.entity_description.available_fn(self._object): return False return super().available @property def is_on(self) -> bool: - """Presence detected.""" - return self._zone.status_api == "0001" + """Return object binary sensor state.""" + return self.entity_description.is_on_fn(self._object) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 3f5a5268bb9..63c18f5b2cc 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -1,7 +1,5 @@ """Support for climates.""" -from __future__ import annotations - from enum import StrEnum from typing import Any, TypedDict, cast diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 0cb9f7e00d0..329ad727faa 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Comelit integration.""" -from __future__ import annotations - from asyncio.exceptions import TimeoutError from collections.abc import Mapping import re @@ -70,7 +68,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, raise InvalidAuth( translation_domain=DOMAIN, translation_key="cannot_authenticate", - translation_placeholders={"error": repr(err)}, ) from err finally: await api.logout() @@ -94,6 +91,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Comelit.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 300c6726bd1..0c44b45bcc7 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -11,7 +11,7 @@ from aiocomelit.const import BRIDGE, VEDO _LOGGER = logging.getLogger(__package__) -ObjectClassType = ( +type ObjectClassType = ( ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject ) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 009d864c0cb..a3baa7504c5 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -18,7 +18,12 @@ from aiocomelit.const import ( SCENARIO, VEDO, ) -from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from aiocomelit.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + DeviceStorageFailureError, +) from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry @@ -65,6 +70,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( + configuration_url=self.api.base_url, config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, model=device, @@ -111,6 +117,11 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): translation_domain=DOMAIN, translation_key="cannot_authenticate", ) from err + except DeviceStorageFailureError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_storage_failure", + ) from err @abstractmethod async def _async_update_system_data(self) -> T: diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 0d16962129d..4d74b6799bb 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -1,7 +1,5 @@ """Support for covers.""" -from __future__ import annotations - from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject diff --git a/homeassistant/components/comelit/diagnostics.py b/homeassistant/components/comelit/diagnostics.py index 547735f3879..c6df3a5a041 100644 --- a/homeassistant/components/comelit/diagnostics.py +++ b/homeassistant/components/comelit/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Comelit integration.""" -from __future__ import annotations - from typing import Any from aiocomelit import ( diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py index 409cd6a3f42..53394bf06db 100644 --- a/homeassistant/components/comelit/entity.py +++ b/homeassistant/components/comelit/entity.py @@ -1,7 +1,5 @@ """Base entity for Comelit.""" -from __future__ import annotations - from aiocomelit import ComelitSerialBridgeObject from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index b21682b6958..c17d2d6378e 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -1,7 +1,5 @@ """Support for humidifiers.""" -from __future__ import annotations - from enum import StrEnum from typing import Any, cast diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index ab34ad81b70..e56bfc437c2 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -1,7 +1,5 @@ """Support for lights.""" -from __future__ import annotations - from typing import Any, cast from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index f776cf6b3ee..ee4ac563a48 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==2.0.2"] + "requirements": ["aiocomelit==2.0.3"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index baf5d7eff7a..3d2b954b728 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -1,7 +1,5 @@ """Support for sensors.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Final, cast from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject @@ -153,7 +151,7 @@ class ComelitVedoSensorEntity( super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}" + self._attr_unique_id = f"{config_entry_entry_id}-{description.key}-{zone.index}" self._attr_device_info = coordinator.platform_device_info(zone, "zone") self.entity_description = description @@ -168,12 +166,12 @@ class ComelitVedoSensorEntity( @property def available(self) -> bool: """Sensor availability.""" - return self._zone_object.human_status != AlarmZoneState.UNAVAILABLE + return self._zone_object.human_status is not AlarmZoneState.UNAVAILABLE @property def native_value(self) -> StateType: """Sensor value.""" - if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: + if (status := self._zone_object.human_status) is AlarmZoneState.UNKNOWN: return None return cast(str, status.value) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index d8d2605b172..accc0ccdcdb 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -64,6 +64,17 @@ } }, "entity": { + "binary_sensor": { + "anomaly": { + "name": "Anomaly" + }, + "faulty": { + "name": "Faulty" + }, + "motion": { + "name": "Motion" + } + }, "climate": { "thermostat": { "state_attributes": { @@ -110,6 +121,9 @@ "cannot_retrieve_data": { "message": "Error retrieving data: {error}" }, + "device_storage_failure": { + "message": "Device SD card read failure. The card may be corrupted or failing; replacement is recommended." + }, "humidity_while_off": { "message": "Cannot change humidity while off" }, diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 29258ed915e..985f9566c69 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -1,7 +1,5 @@ """Support for switches.""" -from __future__ import annotations - from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index a2b05dda62e..30f5d691f41 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -5,7 +5,12 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate, Literal from aiocomelit.api import ComelitSerialBridgeObject -from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from aiocomelit.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + DeviceStorageFailureError, +) from aiohttp import ClientSession, CookieJar from homeassistant.config_entries import ConfigEntry @@ -110,6 +115,12 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P]( translation_key="cannot_retrieve_data", translation_placeholders={"error": repr(err)}, ) from err + except DeviceStorageFailureError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_storage_failure", + ) from err except CannotAuthenticate: self.coordinator.last_update_success = False self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 2295fdb4e8e..3f87d0d76ce 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -1,7 +1,5 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" -from __future__ import annotations - import logging import math from typing import Any @@ -96,12 +94,12 @@ class ComfoConnectFan(FanEntity): self._handle_mode_update, ) ) - await self.hass.async_add_executor_job( - self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE - ) - await self.hass.async_add_executor_job( - self._ccb.comfoconnect.register_sensor, SENSOR_OPERATING_MODE_BIS - ) + + def _register_sensors() -> None: + self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE) + self._ccb.comfoconnect.register_sensor(SENSOR_OPERATING_MODE_BIS) + + await self.hass.async_add_executor_job(_register_sensors) def _handle_speed_update(self, value: float) -> None: """Handle update callbacks.""" diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index fbe958e6d67..95e862dcd51 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -1,7 +1,5 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index b74c79fd842..55902a317ee 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1,7 +1,5 @@ """The command_line component.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine import logging diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 727bf5b86ca..dccc4df8569 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,11 +1,12 @@ """Support for custom shell commands to retrieve values.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorEntity, +) from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -27,6 +28,7 @@ from homeassistant.util import dt as dt_util from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS from .sensor import CommandSensorData +from .utils import create_platform_yaml_not_supported_issue DEFAULT_NAME = "Binary Command Sensor" DEFAULT_PAYLOAD_ON = "ON" @@ -43,6 +45,7 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, BINARY_SENSOR_DOMAIN) return binary_sensor_config = discovery_info @@ -122,7 +125,8 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): self._process_updates = asyncio.Lock() if self._process_updates.locked(): LOGGER.warning( - "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s", + "Updating Command Line Binary Sensor %s took longer" + " than the scheduled update interval %s", self.name, self._scan_interval, ) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 066f6ae0388..78d3dedbbbd 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,12 +1,10 @@ """Support for command line covers.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from homeassistant.components.cover import CoverEntity +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverEntity from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, @@ -28,7 +26,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS -from .utils import async_call_shell_with_timeout, async_check_output_or_log +from .utils import ( + async_call_shell_with_timeout, + async_check_output_or_log, + create_platform_yaml_not_supported_issue, +) SCAN_INTERVAL = timedelta(seconds=15) @@ -41,6 +43,7 @@ async def async_setup_platform( ) -> None: """Set up cover controlled by shell commands.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, COVER_DOMAIN) return covers = [] @@ -154,7 +157,8 @@ class CommandCover(ManualTriggerEntity, CoverEntity): self._process_updates = asyncio.Lock() if self._process_updates.locked(): LOGGER.warning( - "Updating Command Line Cover %s took longer than the scheduled update interval %s", + "Updating Command Line Cover %s took longer than" + " the scheduled update interval %s", self.name, self._scan_interval, ) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index b0031e4d5ee..b0e54127c30 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -1,30 +1,33 @@ """Support for command line notification services.""" -from __future__ import annotations - import logging import subprocess from typing import Any -from homeassistant.components.notify import BaseNotificationService +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + BaseNotificationService, +) from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, LOGGER -from .utils import render_template_args +from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER +from .utils import create_platform_yaml_not_supported_issue, render_template_args _LOGGER = logging.getLogger(__name__) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService | None: """Get the Command Line notification service.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, NOTIFY_DOMAIN) return None notify_config = discovery_info @@ -64,8 +67,18 @@ class CommandLineNotificationService(BaseNotificationService): proc.returncode, command, ) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) + except subprocess.TimeoutExpired as err: + _LOGGER.debug("Timeout for command: %s", command) kill_subprocess(proc) - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_error", + translation_placeholders={"command": command}, + ) from err + except subprocess.SubprocessError as err: + _LOGGER.debug("Error trying to exec command: %s", command) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"command": command, "error": str(err)}, + ) from err diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 234241fdeab..024f35312ca 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,7 +1,5 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from datetime import datetime, timedelta @@ -10,6 +8,7 @@ from typing import Any from jsonpath import jsonpath +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -34,7 +33,11 @@ from .const import ( LOGGER, TRIGGER_ENTITY_OPTIONS, ) -from .utils import async_check_output_or_log, render_template_args +from .utils import ( + async_check_output_or_log, + create_platform_yaml_not_supported_issue, + render_template_args, +) DEFAULT_NAME = "Command Sensor" @@ -49,6 +52,7 @@ async def async_setup_platform( ) -> None: """Set up the Command Sensor.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, SENSOR_DOMAIN) return sensor_config = discovery_info @@ -130,7 +134,8 @@ class CommandSensor(ManualTriggerSensorEntity): if self._process_updates.locked(): LOGGER.warning( - "Updating Command Line Sensor %s took longer than the scheduled update interval %s", + "Updating Command Line Sensor %s took longer than" + " the scheduled update interval %s", self.name, self._scan_interval, ) diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 6497fdcf98d..5eaa04f8550 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,4 +1,12 @@ { + "exceptions": { + "command_error": { + "message": "Error trying to execute command: {command}. Error: {error}" + }, + "timeout_error": { + "message": "Timeout trying to execute command: {command}" + } + }, "services": { "reload": { "description": "Reloads command line configuration from the YAML-configuration.", diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 9d6b84c105f..492d53ff93a 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,12 +1,14 @@ """Support for custom shell commands to turn a switch on/off.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + ENTITY_ID_FORMAT, + SwitchEntity, +) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, @@ -27,7 +29,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS -from .utils import async_call_shell_with_timeout, async_check_output_or_log +from .utils import ( + async_call_shell_with_timeout, + async_check_output_or_log, + create_platform_yaml_not_supported_issue, +) SCAN_INTERVAL = timedelta(seconds=30) @@ -40,6 +46,7 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, SWITCH_DOMAIN) return switches = [] @@ -156,7 +163,8 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): self._process_updates = asyncio.Lock() if self._process_updates.locked(): LOGGER.warning( - "Updating Command Line Switch %s took longer than the scheduled update interval %s", + "Updating Command Line Switch %s took longer than" + " the scheduled update interval %s", self.name, self._scan_interval, ) diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 607340c4853..1c9de6b55c4 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -1,14 +1,15 @@ """The command_line component utils.""" -from __future__ import annotations - import asyncio from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers.entity_platform import ( + async_create_platform_config_not_supported_issue, +) from homeassistant.helpers.template import Template -from .const import LOGGER +from .const import DOMAIN, LOGGER _EXEC_FAILED_CODE = 127 @@ -93,3 +94,17 @@ def render_template_args(hass: HomeAssistant, command: str) -> str | None: LOGGER.debug("Running command: %s", command) return command + + +def create_platform_yaml_not_supported_issue( + hass: HomeAssistant, platform_domain: str +) -> None: + """Create an issue when platform yaml is used.""" + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + platform_domain, + yaml_config_under_integration_supported=True, + learn_more_url="https://www.home-assistant.io/integrations/command_line/", + logger=LOGGER, + ) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index 96e1cdac3d7..5d7a99cf408 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -91,6 +91,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Compensation sensor.""" hass.data[DATA_COMPENSATION] = {} + # Exit early if no compensations are configured using the compensation: key in configuration.yaml. + # This allows us to create an issue if platform: compensation is present in the sensor: section. + if DOMAIN not in config: + return True + for compensation, conf in config[DOMAIN].items(): _LOGGER.debug("Setup %s.%s", DOMAIN, compensation) diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 36421e8ea07..be625149d92 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -1,7 +1,5 @@ """Support for compensation sensor.""" -from __future__ import annotations - import logging from typing import Any @@ -10,6 +8,7 @@ import numpy as np from homeassistant.components.sensor import ( ATTR_STATE_CLASS, CONF_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, SensorEntity, ) from homeassistant.const import ( @@ -33,7 +32,10 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_create_platform_config_not_supported_issue, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,6 +45,7 @@ from .const import ( CONF_PRECISION, DATA_COMPENSATION, DEFAULT_NAME, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -60,6 +63,14 @@ async def async_setup_platform( ) -> None: """Set up the Compensation sensor.""" if discovery_info is None: + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + SENSOR_DOMAIN, + yaml_config_under_integration_supported=True, + learn_more_url="https://www.home-assistant.io/integrations/compensation/", + logger=_LOGGER, + ) return compensation: str = discovery_info[CONF_COMPENSATION] diff --git a/homeassistant/components/compit/__init__.py b/homeassistant/components/compit/__init__.py index 0a0e7e6eabf..5ea4413a41e 100644 --- a/homeassistant/components/compit/__init__.py +++ b/homeassistant/components/compit/__init__.py @@ -16,6 +16,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/compit/config_flow.py b/homeassistant/components/compit/config_flow.py index fc2cac432b1..fa7bc0c373e 100644 --- a/homeassistant/components/compit/config_flow.py +++ b/homeassistant/components/compit/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Compit integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/compit/icons.json b/homeassistant/components/compit/icons.json index 7a98b01ef7e..90075efef44 100644 --- a/homeassistant/components/compit/icons.json +++ b/homeassistant/components/compit/icons.json @@ -279,6 +279,20 @@ "no_alarm": "mdi:check-circle" } } + }, + "switch": { + "device_on_off": { + "default": "mdi:power", + "state": { + "off": "mdi:power-off" + } + }, + "force_dhw": { + "default": "mdi:water-boiler", + "state": { + "off": "mdi:water-boiler-off" + } + } } } } diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json index 56624669e0d..c555485de5a 100644 --- a/homeassistant/components/compit/strings.json +++ b/homeassistant/components/compit/strings.json @@ -421,6 +421,14 @@ "weather_curve": { "name": "Weather curve" } + }, + "switch": { + "device_on_off": { + "name": "Device on/off" + }, + "force_dhw": { + "name": "Force domestic hot water" + } } } } diff --git a/homeassistant/components/compit/switch.py b/homeassistant/components/compit/switch.py new file mode 100644 index 00000000000..c885adc60b9 --- /dev/null +++ b/homeassistant/components/compit/switch.py @@ -0,0 +1,132 @@ +"""Switch platform for Compit integration.""" + +from dataclasses import dataclass +from typing import Any + +from compit_inext_api.consts import CompitParameter + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class CompitDeviceDescription: + """Class to describe a Compit device.""" + + name: str + """Name of the device.""" + + parameters: list[SwitchEntityDescription] + """Parameters of the device.""" + + +DESCRIPTIONS: dict[CompitParameter, SwitchEntityDescription] = { + CompitParameter.DEVICE_ON_OFF: SwitchEntityDescription( + key=CompitParameter.DEVICE_ON_OFF.value, + translation_key="device_on_off", + ), + CompitParameter.FORCE_DHW: SwitchEntityDescription( + key=CompitParameter.FORCE_DHW.value, + translation_key="force_dhw", + ), +} + +DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = { + 210: CompitDeviceDescription( + name="EL750", + parameters=[DESCRIPTIONS[CompitParameter.DEVICE_ON_OFF]], + ), + 224: CompitDeviceDescription( + name="R 900", + parameters=[ + DESCRIPTIONS[CompitParameter.FORCE_DHW], + ], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Compit switch entities from a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + CompitSwitch( + coordinator, + device_id, + device_definition.name, + entity_description, + ) + for device_id, device in coordinator.connector.all_devices.items() + if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code)) + for entity_description in device_definition.parameters + ) + + +class CompitSwitch(CoordinatorEntity[CompitDataUpdateCoordinator], SwitchEntity): + """Representation of a Compit switch entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + device_name: str, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator) + self.device_id = device_id + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=device_name, + manufacturer=MANUFACTURER_NAME, + model=device_name, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.connector.get_device(self.device_id) is not None + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + value = self.coordinator.connector.get_current_option( + self.device_id, CompitParameter(self.entity_description.key) + ) + + return True if value == STATE_ON else False if value == STATE_OFF else None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.connector.select_device_option( + self.device_id, CompitParameter(self.entity_description.key), STATE_ON + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.connector.select_device_option( + self.device_id, CompitParameter(self.entity_description.key), STATE_OFF + ) + self.async_write_ha_state() diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index f4498c43ab6..6f1265491dd 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Concord232 alarm control panels.""" -from __future__ import annotations - import datetime import logging diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index cc4d3bb92bd..94f66e62382 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -1,7 +1,5 @@ """Support for exposing Concord232 elements as sensors.""" -from __future__ import annotations - import datetime import logging from typing import Any diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index ca4ddda2242..ef7a1147273 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,7 +1,5 @@ """Component to configure Home Assistant via an API.""" -from __future__ import annotations - from homeassistant.components import frontend from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 3e0a9c1df5f..0d4383534f6 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -1,7 +1,5 @@ """HTTP views to interact with the area registry.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 1b3fa71d7ea..2479fe652c3 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -1,7 +1,5 @@ """Offer API to configure Home Assistant auth.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -10,32 +8,19 @@ from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -WS_TYPE_LIST = "config/auth/list" -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_LIST} -) - -WS_TYPE_DELETE = "config/auth/delete" -SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str} -) - @callback def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" - websocket_api.async_register_command( - hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST - ) - websocket_api.async_register_command( - hass, WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE - ) + websocket_api.async_register_command(hass, websocket_list) + websocket_api.async_register_command(hass, websocket_delete) websocket_api.async_register_command(hass, websocket_create) websocket_api.async_register_command(hass, websocket_update) return True @websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "config/auth/list"}) @websocket_api.async_response async def websocket_list( hass: HomeAssistant, @@ -49,6 +34,9 @@ async def websocket_list( @websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "config/auth/delete", vol.Required("user_id"): str} +) @websocket_api.async_response async def websocket_delete( hass: HomeAssistant, diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 8513c53bd07..ab7b2a84e9e 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -1,7 +1,5 @@ """Offer API to configure the Home Assistant auth provider.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 50148bc88ae..0533d699fa1 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,12 +1,10 @@ """Provide configuration end points for Automations.""" -from __future__ import annotations - from typing import Any import uuid from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.automation.config import ( # pylint: disable=hass-component-root-import +from homeassistant.components.automation.config import ( # pylint: disable=home-assistant-component-root-import async_validate_config_item, ) from homeassistant.config import AUTOMATION_CONFIG_PATH diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index fe031e8466f..15d793ee1b6 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,7 +1,5 @@ """Http views to control the config manager.""" -from __future__ import annotations - from collections.abc import Callable from http import HTTPStatus import logging @@ -150,7 +148,7 @@ def _prepare_config_flow_result_json( prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]], ) -> dict[str, Any]: """Convert result to JSON.""" - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) data = {key: val for key, val in result.items() if key not in ("data", "context")} @@ -177,7 +175,6 @@ class ConfigManagerFlowIndexView( vol.Schema( { vol.Required("handler"): vol.Any(str, list), - vol.Optional("show_advanced_options", default=False): cv.boolean, vol.Optional("entry_id"): cv.string, }, extra=vol.ALLOW_EXTRA, @@ -304,7 +301,6 @@ class SubentryManagerFlowIndexView( vol.Schema( { vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)), - vol.Optional("show_advanced_options", default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index b40f533d1f8..43d398fc299 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,7 +1,5 @@ """Component to interact with Hassbian tools.""" -from __future__ import annotations - from typing import Any from aiohttp import web @@ -62,7 +60,9 @@ class CheckConfigView(HomeAssistantView): vol.Optional("location_name"): str, vol.Optional("longitude"): cv.longitude, vol.Optional("radius"): cv.positive_int, - vol.Optional("time_zone"): cv.time_zone, + # Validated by async_set_time_zone in the executor to avoid + # blocking I/O loading zoneinfo data on the event loop. + vol.Optional("time_zone"): str, vol.Optional("update_units"): bool, vol.Optional("unit_system"): unit_system.validate_unit_system, } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 8b114041672..befbbb74850 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -1,7 +1,5 @@ """HTTP views to interact with the device registry.""" -from __future__ import annotations - from typing import Any, cast import voluptuous as vol diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 86c5a8dd3ed..aa0e0df35bf 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -1,7 +1,5 @@ """HTTP views to interact with the entity registry.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index 2f0fc180c0b..d88da6adbab 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -1,7 +1,5 @@ """Provide configuration end points for Scenes.""" -from __future__ import annotations - from typing import Any import uuid diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index 7e18e926c7f..5ce339da41b 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,11 +1,9 @@ """Provide configuration end points for scripts.""" -from __future__ import annotations - from typing import Any from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.components.script.config import ( # pylint: disable=hass-component-root-import +from homeassistant.components.script.config import ( # pylint: disable=home-assistant-component-root-import async_validate_config_item, ) from homeassistant.config import SCRIPT_CONFIG_PATH diff --git a/homeassistant/components/config/view.py b/homeassistant/components/config/view.py index 14d89356c92..75cbd1c4255 100644 --- a/homeassistant/components/config/view.py +++ b/homeassistant/components/config/view.py @@ -1,7 +1,5 @@ """Component to configure Home Assistant via an API.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from http import HTTPStatus diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index d1ddcb6cd4b..c8b99ed1d51 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -6,14 +6,14 @@ A callback has to be provided to `request_config` which will be called when the user has submitted configuration information. """ -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from datetime import datetime import functools as ft from typing import Any +import voluptuous as vol + from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import ( HassJob, @@ -24,8 +24,8 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe _KEY_INSTANCE = "configurator" @@ -54,7 +54,6 @@ type ConfiguratorCallback = Callable[[list[dict[str, str]]], None] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -@bind_hass @async_callback def async_request_config( hass: HomeAssistant, @@ -93,7 +92,6 @@ def async_request_config( return request_id -@bind_hass def request_config(hass: HomeAssistant, *args: Any, **kwargs: Any) -> str: """Create a new request for configuration. @@ -104,7 +102,6 @@ def request_config(hass: HomeAssistant, *args: Any, **kwargs: Any) -> str: ).result() -@bind_hass @async_callback def async_notify_errors(hass: HomeAssistant, request_id: str, error: str) -> None: """Add errors to a config request.""" @@ -112,7 +109,6 @@ def async_notify_errors(hass: HomeAssistant, request_id: str, error: str) -> Non _get_requests(hass)[request_id].async_notify_errors(request_id, error) -@bind_hass def notify_errors(hass: HomeAssistant, request_id: str, error: str) -> None: """Add errors to a config request.""" return run_callback_threadsafe( @@ -120,7 +116,6 @@ def notify_errors(hass: HomeAssistant, request_id: str, error: str) -> None: ).result() -@bind_hass @async_callback def async_request_done(hass: HomeAssistant, request_id: str) -> None: """Mark a configuration request as done.""" @@ -128,7 +123,6 @@ def async_request_done(hass: HomeAssistant, request_id: str) -> None: _get_requests(hass).pop(request_id).async_request_done(request_id) -@bind_hass def request_done(hass: HomeAssistant, request_id: str) -> None: """Mark a configuration request as done.""" return run_callback_threadsafe( @@ -156,8 +150,12 @@ class Configurator: self._requests: dict[ str, tuple[str, list[dict[str, str]], ConfiguratorCallback | None] ] = {} - hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call + async_register_admin_service( + hass, + DOMAIN, + SERVICE_CONFIGURE, + self.async_handle_service_call, + schema=vol.Schema({}, extra=vol.ALLOW_EXTRA), ) @async_callback diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 5e77421e690..5cb8eb0cf61 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -1,7 +1,5 @@ """The Control4 integration.""" -from __future__ import annotations - from dataclasses import dataclass import json import logging @@ -34,7 +32,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER] @dataclass @@ -157,7 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> config[CONF_HOST], ) raise ConfigEntryNotReady( - f"Timeout getting UI configuration from Control4 controller at {config[CONF_HOST]}" + "Timeout getting UI configuration from" + f" Control4 controller at {config[CONF_HOST]}" ) from err ui_configuration = json.loads(ui_config_raw) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index ba0005cbf3a..afc45d33aba 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -1,7 +1,5 @@ """Platform for Control4 Climate/Thermostat.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -204,7 +202,8 @@ class Control4Climate(Control4Entity, ClimateEntity): def _create_api_object(self) -> C4Climate: """Create a pyControl4 device object. - This exists so the director token used is always the latest one, without needing to re-init the entire entity. + This exists so the director token used is always the + latest one, without needing to re-init the entire entity. """ return C4Climate(self.runtime_data.director, self._idx) @@ -273,7 +272,10 @@ class Control4Climate(Control4Entity, ClimateEntity): if data is None: return None humidity = data.get(CONTROL4_HUMIDITY) - return int(humidity) if humidity is not None else None + try: + return int(humidity) if humidity is not None else None + except ValueError, TypeError: + return None @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 39360459cbd..46512b7ffa3 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Control4 integration.""" -from __future__ import annotations - import logging from typing import Any @@ -169,6 +167,8 @@ class OptionsFlowHandler(OptionsFlowWithReload): data_schema = vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/control4/cover.py b/homeassistant/components/control4/cover.py new file mode 100644 index 00000000000..df2c4f875a0 --- /dev/null +++ b/homeassistant/components/control4/cover.py @@ -0,0 +1,220 @@ +"""Platform for Control4 Covers (blinds and shades).""" + +from datetime import timedelta +import logging +from typing import Any + +from pyControl4.blind import C4Blind +from pyControl4.error_handling import C4Exception + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import Control4ConfigEntry, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE +from .director_utils import update_variables_for_config_entry +from .entity import Control4Entity + +_LOGGER = logging.getLogger(__name__) + +CONTROL4_CATEGORY = "blinds_shades" + +CONTROL4_LEVEL = "Level" +CONTROL4_FULLY_CLOSED = "Fully Closed" +CONTROL4_FULLY_OPEN = "Fully Open" +CONTROL4_OPENING = "Opening" +CONTROL4_CLOSING = "Closing" + +VARIABLES_OF_INTEREST = { + CONTROL4_LEVEL, + CONTROL4_FULLY_CLOSED, + CONTROL4_FULLY_OPEN, + CONTROL4_OPENING, + CONTROL4_CLOSING, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Control4 covers from a config entry.""" + runtime_data = entry.runtime_data + + async def async_update_data() -> dict[int, dict[str, Any]]: + """Fetch data from Control4 director for blinds.""" + try: + return await update_variables_for_config_entry( + hass, entry, VARIABLES_OF_INTEREST + ) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]]( + hass, + _LOGGER, + name="cover", + update_method=async_update_data, + update_interval=timedelta(seconds=runtime_data.scan_interval), + config_entry=entry, + ) + + await coordinator.async_refresh() + + items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY) + entity_list = [] + for item in items_of_category: + try: + if item["type"] != CONTROL4_ENTITY_TYPE: + continue + item_name = item["name"] + item_id = item["id"] + item_parent_id = item["parentId"] + item_manufacturer = None + item_device_name = None + item_model = None + + for parent_item in items_of_category: + if parent_item["id"] == item_parent_id: + item_manufacturer = parent_item.get("manufacturer") + item_device_name = parent_item.get("roomName") + item_model = parent_item.get("model") + except KeyError: + _LOGGER.exception( + "Unknown device properties received from Control4: %s", + item, + ) + continue + + if item_id not in coordinator.data: + _LOGGER.warning( + "Couldn't get cover state data for %s (ID: %s), skipping setup", + item_name, + item_id, + ) + continue + + entity_list.append( + Control4Cover( + runtime_data, + coordinator, + item_name, + item_id, + item_device_name, + item_manufacturer, + item_model, + item_parent_id, + ) + ) + + async_add_entities(entity_list) + + +class Control4Cover(Control4Entity, CoverEntity): + """Control4 cover entity.""" + + _attr_has_entity_name = True + _attr_translation_key = "blind" + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._cover_data is not None + + def _create_api_object(self) -> C4Blind: + """Create a pyControl4 device object. + + This exists so the director token used is always the latest one, + without needing to re-init the entire entity. + """ + return C4Blind(self.runtime_data.director, self._idx) + + @property + def _cover_data(self) -> dict[str, Any] | None: + """Return the cover data from the coordinator.""" + return self.coordinator.data.get(self._idx) + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover (0 closed, 100 open).""" + data = self._cover_data + if data is None: + return None + level = data.get(CONTROL4_LEVEL) + if level is None: + return None + return int(level) + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + data = self._cover_data + if data is None: + return None + if (fully_closed := data.get(CONTROL4_FULLY_CLOSED)) is not None: + return bool(fully_closed) + position = self.current_cover_position + if position is None: + return None + return position == 0 + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening.""" + data = self._cover_data + if data is None: + return None + opening = data.get(CONTROL4_OPENING) + if opening is None: + return None + return bool(opening) + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing.""" + data = self._cover_data + if data is None: + return None + closing = data.get(CONTROL4_CLOSING) + if closing is None: + return None + return bool(closing) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + c4_blind = self._create_api_object() + await c4_blind.open() + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + c4_blind = self._create_api_object() + await c4_blind.close() + await self.coordinator.async_request_refresh() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + c4_blind = self._create_api_object() + await c4_blind.stop() + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + c4_blind = self._create_api_object() + await c4_blind.setLevelTarget(kwargs[ATTR_POSITION]) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py index f7ca0e1fabc..b18909adbc4 100644 --- a/homeassistant/components/control4/entity.py +++ b/homeassistant/components/control4/entity.py @@ -1,7 +1,5 @@ """The Control4 integration.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2e9528063d1..0a137ad5c7d 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -1,7 +1,5 @@ """Platform for Control4 Lights.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -184,7 +182,8 @@ class Control4Light(Control4Entity, LightEntity): def _create_api_object(self): """Create a pyControl4 device object. - This exists so the director token used is always the latest one, without needing to re-init the entire entity. + This exists so the director token used is always the + latest one, without needing to re-init the entire entity. """ return C4Light(self.runtime_data.director, self._idx) diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index be891c3d153..727ff17012c 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -1,7 +1,5 @@ """Platform for Control4 Rooms Media Players.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import enum @@ -224,7 +222,8 @@ class Control4Room(Control4Entity, MediaPlayerEntity): def _create_api_object(self) -> C4Room: """Create a pyControl4 device object. - This exists so the director token used is always the latest one, without needing to re-init the entire entity. + This exists so the director token used is always the + latest one, without needing to re-init the entire entity. """ return C4Room(self.runtime_data.director, self._idx) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index b386121543c..e3c3efa18d5 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -1,7 +1,5 @@ """Support for functionality to have conversations with Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, Literal @@ -23,7 +21,6 @@ from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from .agent_manager import ( AgentInfo, @@ -127,7 +124,6 @@ CONFIG_SCHEMA = vol.Schema( @callback -@bind_hass def async_set_agent( hass: HomeAssistant, config_entry: ConfigEntry, @@ -138,7 +134,6 @@ def async_set_agent( @callback -@bind_hass def async_unset_agent( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 8aff2c5fba6..02dd833160f 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -1,7 +1,5 @@ """Agent foundation for conversation integration.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses import logging diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 4ee8a8cc310..f5d9879db6a 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -1,7 +1,5 @@ """Conversation chat log.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator from contextlib import contextmanager @@ -423,9 +421,11 @@ class ChatLog: ) -> AsyncGenerator[ToolResultContent]: """Add assistant content and execute tool calls. - tool_call_tasks can contains tasks for tool calls that are already in progress. + tool_call_tasks can contain tasks for tool calls + that are already in progress. - This method is an async generator and will yield the tool results as they come in. + This method is an async generator and will yield + the tool results as they come in. """ LOGGER.debug("Adding assistant content: %s", content) self.content.append(content) @@ -489,14 +489,17 @@ class ChatLog: ) -> AsyncGenerator[AssistantContent | ToolResultContent]: """Stream content into the chat log. - Returns a generator with all content that was added to the chat log. + Returns a generator with all content that was added + to the chat log. - stream iterates over dictionaries with optional keys role, content and tool_calls. + stream iterates over dictionaries with optional keys + role, content and tool_calls. - When a delta contains a role key, the current message is considered complete and - a new message is started. + When a delta contains a role key, the current message + is considered complete and a new message is started. - The keys content and tool_calls will be concatenated if they appear multiple times. + The keys content and tool_calls will be concatenated + if they appear multiple times. """ current_content = "" current_thinking_content = "" @@ -732,7 +735,8 @@ class ChatLog: if llm_api: prompt_parts.append(llm_api.api_prompt) - # Append current date and time to the prompt if the corresponding tool is not provided + # Append current date and time to the prompt if the + # corresponding tool is not provided llm_tools: list[llm.Tool] = llm_api.tools if llm_api else [] if not any(tool.name.endswith("GetDateTime") for tool in llm_tools): prompt_parts.append( diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index c291a87b53d..cd832f88280 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,7 +1,5 @@ """Const for conversation integration.""" -from __future__ import annotations - from enum import IntFlag, StrEnum from typing import TYPE_CHECKING @@ -21,6 +19,7 @@ ATTR_AGENT_ID = "agent_id" ATTR_CONVERSATION_ID = "conversation_id" SERVICE_PROCESS = "process" +# pylint: disable-next=home-assistant-duplicate-const SERVICE_RELOAD = "reload" DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index b279d9b9943..7f6877b829a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1,7 +1,5 @@ """Standard conversation implementation for Home Assistant.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from collections.abc import Callable, Iterable @@ -13,18 +11,13 @@ import time from typing import IO, Any, cast from hassil.expression import Expression, Group, ListReference, TextChunk -from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo from hassil.intents import ( - Intent, - IntentData, Intents, SlotList, TextSlotList, TextSlotValue, WildcardSlotList, ) -from hassil.models import MatchEntity -from hassil.ngram import Sqlite3NgramModel from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, @@ -36,11 +29,7 @@ from hassil.trie import Trie from hassil.util import merge_dict, remove_punctuation from home_assistant_intents import ( ErrorKey, - FuzzyConfig, - FuzzyLanguageResponses, LanguageScores, - get_fuzzy_config, - get_fuzzy_language, get_intents, get_language_scores, get_languages, @@ -97,7 +86,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} -METADATA_FUZZY_MATCH = "hass_fuzzy_match" ERROR_SENTINEL = object() @@ -116,8 +104,6 @@ class LanguageIntents: intent_responses: dict[str, Any] error_responses: dict[str, Any] language_variant: str | None - fuzzy_matcher: FuzzyNgramMatcher | None = None - fuzzy_responses: FuzzyLanguageResponses | None = None @dataclass(slots=True) @@ -135,9 +121,6 @@ class IntentMatchingStage(Enum): EXPOSED_ENTITIES_ONLY = auto() """Match against exposed entities only.""" - FUZZY = auto() - """Use fuzzy matching to guess intent.""" - UNEXPOSED_ENTITIES = auto() """Match against unexposed entities in Home Assistant.""" @@ -188,7 +171,7 @@ class IntentCache: return self.cache[key] def put(self, key: IntentCacheKey, value: IntentCacheValue) -> None: - """Put a value in the cache, evicting the least recently used item if necessary.""" + """Put a value in the cache, evicting the LRU item if necessary.""" if key in self.cache: # Update value and mark as recently used self.cache.move_to_end(key) @@ -261,10 +244,6 @@ class DefaultAgent(ConversationEntity): # LRU cache to avoid unnecessary intent matching self._intent_cache = IntentCache(capacity=128) - # Shared configuration for fuzzy matching - self.fuzzy_matching = True - self._fuzzy_config: FuzzyConfig | None = None - async def async_added_to_hass(self) -> None: """Subscribe to intents updates when added to hass.""" self._unsub_intents = get_agent_manager(self.hass).subscribe_intents( @@ -424,8 +403,6 @@ class DefaultAgent(ConversationEntity): "sentence_template": "", # When match is incomplete, this will contain the best slot guesses "unmatched_slots": _get_unmatched_slots(intent_result), - # True if match was not exact - "fuzzy_match": False, } if successful_match: @@ -449,10 +426,6 @@ class DefaultAgent(ConversationEntity): else: result_dict["source"] = "builtin" - result_dict["fuzzy_match"] = intent_result.intent_metadata.get( - METADATA_FUZZY_MATCH, False - ) - return result_dict async def _async_handle_message( @@ -673,7 +646,7 @@ class DefaultAgent(ConversationEntity): cache_value = self._intent_cache.get(cache_key) if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY + cache_value.stage is IntentMatchingStage.EXPOSED_ENTITIES_ONLY ): _LOGGER.debug("Got cached result for exposed entities") return cache_value.result @@ -706,44 +679,14 @@ class DefaultAgent(ConversationEntity): return strict_result if strict_intents_only: - # Don't try matching against all entities or doing a fuzzy match + # Don't try matching against all entities return None - # Use fuzzy matching - skip_fuzzy_match = False - if cache_value is not None: - if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.FUZZY - ): - _LOGGER.debug("Got cached result for fuzzy match") - return cache_value.result - - # Continue with matching, but we know we won't succeed for fuzzy - # match. - skip_fuzzy_match = True - - if (not skip_fuzzy_match) and self.fuzzy_matching: - start_time = time.monotonic() - fuzzy_result = self._recognize_fuzzy(lang_intents, user_input) - - # Update cache - self._intent_cache.put( - cache_key, - IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY), - ) - - _LOGGER.debug( - "Did fuzzy match in %s second(s)", time.monotonic() - start_time - ) - - if fuzzy_result is not None: - return fuzzy_result - # Try again with all entities (including unexposed) skip_unexposed_entities_match = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES + cache_value.stage is IntentMatchingStage.UNEXPOSED_ENTITIES ): _LOGGER.debug("Got cached result for all entities") return cache_value.result @@ -788,7 +731,7 @@ class DefaultAgent(ConversationEntity): skip_unknown_names = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES + cache_value.stage is IntentMatchingStage.UNKNOWN_NAMES ): _LOGGER.debug("Got cached result for unknown names") return cache_value.result @@ -816,56 +759,6 @@ class DefaultAgent(ConversationEntity): return maybe_result - def _recognize_fuzzy( - self, lang_intents: LanguageIntents, user_input: ConversationInput - ) -> RecognizeResult | None: - """Return fuzzy recognition from hassil.""" - if lang_intents.fuzzy_matcher is None: - return None - - context_area: str | None = None - satellite_area, _ = self._get_satellite_area_and_device( - user_input.satellite_id, user_input.device_id - ) - if satellite_area: - context_area = satellite_area.name - - fuzzy_result = lang_intents.fuzzy_matcher.match( - user_input.text, context_area=context_area - ) - if fuzzy_result is None: - return None - - response = "default" - if lang_intents.fuzzy_responses: - domain = "" # no domain - if "name" in fuzzy_result.slots: - domain = fuzzy_result.name_domain - elif "domain" in fuzzy_result.slots: - domain = fuzzy_result.slots["domain"].value - - slot_combo = tuple(sorted(fuzzy_result.slots)) - if ( - intent_responses := lang_intents.fuzzy_responses.get( - fuzzy_result.intent_name - ) - ) and (combo_responses := intent_responses.get(slot_combo)): - response = combo_responses.get(domain, response) - - entities = [ - MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text) - for slot_name, slot_value in fuzzy_result.slots.items() - ] - - return RecognizeResult( - intent=Intent(name=fuzzy_result.intent_name), - intent_data=IntentData(sentence_texts=[]), - intent_metadata={METADATA_FUZZY_MATCH: True}, - entities={entity.name: entity for entity in entities}, - entities_list=entities, - response=response, - ) - def _recognize_unknown_names( self, lang_intents: LanguageIntents, @@ -1179,7 +1072,8 @@ class DefaultAgent(ConversationEntity): dict, ): _LOGGER.warning( - "Custom sentences file does not match expected format path=%s", + "Custom sentences file does not match" + " expected format path=%s", custom_sentences_file.name, ) continue @@ -1222,88 +1116,12 @@ class DefaultAgent(ConversationEntity): intent_responses = responses_dict.get("intents", {}) error_responses = responses_dict.get("errors", {}) - if not self.fuzzy_matching: - _LOGGER.debug("Fuzzy matching is disabled") - return LanguageIntents( - intents, - intents_dict, - intent_responses, - error_responses, - language_variant, - ) - - # Load fuzzy - fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load) - if fuzzy_info is None: - _LOGGER.debug( - "Fuzzy matching not available for language: %s", language_variant - ) - return LanguageIntents( - intents, - intents_dict, - intent_responses, - error_responses, - language_variant, - ) - - if self._fuzzy_config is None: - # Load shared config - self._fuzzy_config = get_fuzzy_config(json_load=json_load) - _LOGGER.debug("Loaded shared fuzzy matching config") - - assert self._fuzzy_config is not None - - fuzzy_matcher: FuzzyNgramMatcher | None = None - fuzzy_responses: FuzzyLanguageResponses | None = None - - start_time = time.monotonic() - fuzzy_responses = fuzzy_info.responses - fuzzy_matcher = FuzzyNgramMatcher( - intents=intents, - intent_models={ - intent_name: Sqlite3NgramModel( - order=fuzzy_model.order, - words={ - word: str(word_id) - for word, word_id in fuzzy_model.words.items() - }, - database_path=fuzzy_model.database_path, - ) - for intent_name, fuzzy_model in fuzzy_info.ngram_models.items() - }, - intent_slot_list_names=self._fuzzy_config.slot_list_names, - slot_combinations={ - intent_name: { - combo_key: SlotCombinationInfo( - context_area=combo_info.context_area, - name_domains=( - set(combo_info.name_domains) - if combo_info.name_domains - else None - ), - ) - for combo_key, combo_info in intent_combos.items() - } - for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items() - }, - domain_keywords=fuzzy_info.domain_keywords, - stop_words=fuzzy_info.stop_words, - ) - _LOGGER.debug( - "Loaded fuzzy matcher in %s second(s): language=%s, intents=%s", - time.monotonic() - start_time, - language_variant, - sorted(fuzzy_matcher.intent_models.keys()), - ) - return LanguageIntents( intents, intents_dict, intent_responses, error_responses, language_variant, - fuzzy_matcher=fuzzy_matcher, - fuzzy_responses=fuzzy_responses, ) @callback @@ -1384,10 +1202,6 @@ class DefaultAgent(ConversationEntity): "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } - # Reload fuzzy matchers with new slot lists - if self.fuzzy_matching: - await self.hass.async_add_executor_job(self._load_fuzzy_matchers) - self._listen_clear_slot_list() _LOGGER.debug( @@ -1397,25 +1211,6 @@ class DefaultAgent(ConversationEntity): return self._slot_lists - def _load_fuzzy_matchers(self) -> None: - """Reload fuzzy matchers for all loaded languages.""" - for lang_intents in self._lang_intents.values(): - if (not isinstance(lang_intents, LanguageIntents)) or ( - lang_intents.fuzzy_matcher is None - ): - continue - - lang_matcher = lang_intents.fuzzy_matcher - lang_intents.fuzzy_matcher = FuzzyNgramMatcher( - intents=lang_matcher.intents, - intent_models=lang_matcher.intent_models, - intent_slot_list_names=lang_matcher.intent_slot_list_names, - slot_combinations=lang_matcher.slot_combinations, - domain_keywords=lang_matcher.domain_keywords, - stop_words=lang_matcher.stop_words, - slot_lists=self._slot_lists, - ) - def _make_intent_context( self, user_input: ConversationInput ) -> dict[str, Any] | None: @@ -1652,7 +1447,7 @@ class DefaultAgent(ConversationEntity): response = await self._async_process_intent_result(result, user_input, chat_log) if ( - response.response_type == intent.IntentResponseType.ERROR + response.response_type is intent.IntentResponseType.ERROR and response.error_code not in ( intent.IntentResponseErrorCode.FAILED_TO_HANDLE, @@ -1680,7 +1475,7 @@ def _make_error_result( def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: - """Get key and template arguments for error when there are unmatched intent entities/slots.""" + """Get key and template args for unmatched intent entities/slots error.""" # Filter out non-text and missing context entities unmatched_text: dict[str, str] = { @@ -1751,7 +1546,7 @@ def _get_match_error_response( # device_class only return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class} - if (reason == intent.MatchFailedReason.DOMAIN) and constraints.domains: + if (reason is intent.MatchFailedReason.DOMAIN) and constraints.domains: domain = next(iter(constraints.domains)) # first domain if constraints.area_name: # domain in area @@ -1770,7 +1565,7 @@ def _get_match_error_response( # domain only return ErrorKey.NO_DOMAIN, {"domain": domain} - if reason == intent.MatchFailedReason.DUPLICATE_NAME: + if reason is intent.MatchFailedReason.DUPLICATE_NAME: if constraints.floor_name: # duplicate on floor return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, { @@ -1787,26 +1582,26 @@ def _get_match_error_response( return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name} - if reason == intent.MatchFailedReason.INVALID_AREA: + if reason is intent.MatchFailedReason.INVALID_AREA: # Invalid area name return ErrorKey.NO_AREA, {"area": result.no_match_name} - if reason == intent.MatchFailedReason.INVALID_FLOOR: + if reason is intent.MatchFailedReason.INVALID_FLOOR: # Invalid floor name return ErrorKey.NO_FLOOR, {"floor": result.no_match_name} - if reason == intent.MatchFailedReason.FEATURE: + if reason is intent.MatchFailedReason.FEATURE: # Feature not supported by entity return ErrorKey.FEATURE_NOT_SUPPORTED, {} - if reason == intent.MatchFailedReason.STATE: + if reason is intent.MatchFailedReason.STATE: # Entity is not in correct state assert constraints.states state = next(iter(constraints.states)) return ErrorKey.ENTITY_WRONG_STATE, {"state": state} - if reason == intent.MatchFailedReason.ASSISTANT: + if reason is intent.MatchFailedReason.ASSISTANT: # Not exposed if constraints.name: if constraints.area_name: diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 86e18f3aff0..3ebfcc711c6 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -1,7 +1,5 @@ """HTTP endpoints for conversation integration.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any @@ -184,7 +182,7 @@ async def websocket_list_sentences( async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Return intents that would be matched by the default agent for a list of sentences.""" + """Return intents matched by the default agent for a list of sentences.""" agent = get_agent_manager(hass).default_agent assert agent is not None diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7317aea8285..0945e52b2c1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"] + "requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 96c245d4b27..bcbb291d976 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -1,7 +1,5 @@ """Agent foundation for conversation integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Literal @@ -102,8 +100,8 @@ class AbstractConversationAgent(ABC): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" - async def async_reload(self, language: str | None = None) -> None: + async def async_reload(self, language: str | None = None) -> None: # noqa: B027 """Clear cached intents for a language.""" - async def async_prepare(self, language: str | None = None) -> None: + async def async_prepare(self, language: str | None = None) -> None: # noqa: B027 """Load intents for a language.""" diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index d852b1b826a..6394b1d43dd 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -1,7 +1,5 @@ """Offer sentence based automation rules.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import Any diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index 04a5a420279..d7c87807fd0 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -1,7 +1,5 @@ """Utility functions for conversation integration.""" -from __future__ import annotations - import logging from homeassistant.core import callback @@ -33,7 +31,8 @@ def async_get_result_from_chat_log( if not isinstance((last_content := chat_log.content[-1]), AssistantContent): _LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + "Last content in chat log is not an AssistantContent: %s." + " This could be due to the model not returning a valid response", last_content, ) raise HomeAssistantError("Unable to get response") diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 2129d1d8ed5..53d0d9014e6 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -1,7 +1,5 @@ """The Cookidoo integration.""" -from __future__ import annotations - import logging from cookidoo_api import CookidooAuthException, CookidooRequestException @@ -44,6 +42,35 @@ async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +def _migrate_identifiers( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + old_prefix: str, + new_unique_id: str, +) -> None: + """Migrate device identifiers and entity unique_ids from old to new prefix.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + new_identifiers = { + (DOMAIN, new_unique_id) if domain == DOMAIN else (domain, identifier) + for domain, identifier in dev.identifiers + } + device_registry.async_update_device(dev.id, new_identifiers=new_identifiers) + for ent in entity_entries: + if ent.unique_id and ent.unique_id.startswith(f"{old_prefix}_"): + entity_registry.async_update_entity( + ent.entity_id, + new_unique_id=f"{new_unique_id}{ent.unique_id[len(old_prefix) :]}", + ) + + async def async_migrate_entry( hass: HomeAssistant, config_entry: CookidooConfigEntry ) -> bool: @@ -51,41 +78,37 @@ async def async_migrate_entry( _LOGGER.debug("Migrating from version %s", config_entry.version) if config_entry.version == 1 and config_entry.minor_version == 1: - # Add the unique uuid + # Add the unique uuid (first migration, entities used config_entry_id as prefix) cookidoo = await cookidoo_from_config_entry(hass, config_entry) try: - auth_data = await cookidoo.login() + await cookidoo.login() + user_info = await cookidoo.get_user_info() except (CookidooRequestException, CookidooAuthException) as e: - _LOGGER.error( - "Could not migrate config config_entry: %s", - str(e), - ) + _LOGGER.error("Could not migrate config entry: %s", e) return False - unique_id = auth_data.sub - - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry_id=config_entry.entry_id - ) - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry_id=config_entry.entry_id - ) - for dev in device_entries: - device_registry.async_update_device( - dev.id, new_identifiers={(DOMAIN, unique_id)} - ) - for ent in entity_entries: - assert ent.config_entry_id - entity_registry.async_update_entity( - ent.entity_id, - new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id), - ) - + _migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id) hass.config_entries.async_update_entry( - config_entry, unique_id=auth_data.sub, minor_version=2 + config_entry, unique_id=user_info.id, minor_version=3 + ) + + if config_entry.version == 1 and config_entry.minor_version == 2: + # Migrate unique_id from old CIAM sub to community profile id + cookidoo = await cookidoo_from_config_entry(hass, config_entry) + + try: + await cookidoo.login() + user_info = await cookidoo.get_user_info() + except (CookidooRequestException, CookidooAuthException) as e: + _LOGGER.error("Could not migrate config entry: %s", e) + return False + + old_unique_id = config_entry.unique_id + if old_unique_id: + _migrate_identifiers(hass, config_entry, old_unique_id, user_info.id) + hass.config_entries.async_update_entry( + config_entry, unique_id=user_info.id, minor_version=3 ) _LOGGER.debug( diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index 0035e225e8f..91a18a0d78a 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -1,17 +1,20 @@ """Calendar platform for the Cookidoo integration.""" -from __future__ import annotations - from datetime import date, datetime, timedelta import logging -from cookidoo_api import CookidooAuthException, CookidooException +from cookidoo_api import ( + CookidooAuthException, + CookidooException, + CookidooRequestException, +) from cookidoo_api.types import CookidooCalendarDayRecipe from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator @@ -60,7 +63,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): if not self.coordinator.data.week_plan: return None - today = date.today() + today = dt_util.now().date() for day_data in self.coordinator.data.week_plan: day_date = date.fromisoformat(day_data.id) if day_date >= today and day_data.recipes: @@ -75,7 +78,13 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): week_day ) except CookidooAuthException: - await self.coordinator.cookidoo.refresh_token() + try: + await self.coordinator.cookidoo.login() + except (CookidooAuthException, CookidooRequestException) as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="calendar_fetch_failed", + ) from exc return await self.coordinator.cookidoo.get_recipes_in_calendar_week( week_day ) diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 71ad3015730..e770bd0ec4b 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Cookidoo integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -56,7 +54,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Cookidoo.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 COUNTRY_DATA_SCHEMA: dict LANGUAGE_DATA_SCHEMA: dict @@ -225,8 +223,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): cookidoo = await cookidoo_from_config_data(self.hass, data_input) try: - auth_data = await cookidoo.login() - self.user_uuid = auth_data.sub + await cookidoo.login() + user_info = await cookidoo.get_user_info() + self.user_uuid = user_info.id if language_input: await cookidoo.get_additional_items() except CookidooRequestException: diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 940c6e36f71..51c2fe09a22 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -1,9 +1,7 @@ """DataUpdateCoordinator for the Cookidoo integration.""" -from __future__ import annotations - from dataclasses import dataclass -from datetime import date, timedelta +from datetime import timedelta import logging from cookidoo_api import ( @@ -23,6 +21,7 @@ from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -83,10 +82,12 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() subscription = await self.cookidoo.get_active_subscription() - week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) + week_plan = await self.cookidoo.get_recipes_in_calendar_week( + dt_util.now().date() + ) except CookidooAuthException: try: - await self.cookidoo.refresh_token() + await self.cookidoo.login() except CookidooAuthException as exc: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -95,8 +96,14 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): CONF_EMAIL: self.config_entry.data[CONF_EMAIL] }, ) from exc + except CookidooRequestException as exc: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from exc _LOGGER.debug( - "Authentication failed but re-authentication was successful, trying again later" + "Authentication failed but re-authentication" + " was successful, trying again later" ) return self.data except CookidooException as e: diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py index 97ebb384ecb..5e5e0142160 100644 --- a/homeassistant/components/cookidoo/entity.py +++ b/homeassistant/components/cookidoo/entity.py @@ -1,7 +1,5 @@ """Base entity for the Cookidoo integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py index 199abb2e05d..da11bf0784a 100644 --- a/homeassistant/components/cookidoo/helpers.py +++ b/homeassistant/components/cookidoo/helpers.py @@ -2,11 +2,12 @@ from typing import Any +from aiohttp import CookieJar from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import CookidooConfigEntry @@ -21,7 +22,7 @@ async def cookidoo_from_config_data( ) return Cookidoo( - async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)), CookidooConfig( email=data[CONF_EMAIL], password=data[CONF_PASSWORD], diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index b4cf653f810..015b01c834e 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.14.0"] + "requirements": ["cookidoo-api==0.17.2"] } diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py index 2e9cbcc05b8..5f64b321c8f 100644 --- a/homeassistant/components/cookidoo/sensor.py +++ b/homeassistant/components/cookidoo/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Cookidoo integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py index c577b845657..652d7a7163d 100644 --- a/homeassistant/components/cookidoo/todo.py +++ b/homeassistant/components/cookidoo/todo.py @@ -1,7 +1,5 @@ """Todo platform for the Cookidoo integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from cookidoo_api import ( @@ -75,7 +73,10 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): async def async_update_todo_item(self, item: TodoItem) -> None: """Update an ingredient to the To-do list. - Cookidoo ingredients can be changed in state, but not in summary or description. This is currently not possible to distinguish in home assistant and just fails silently. + Cookidoo ingredients can be changed in state, but not + in summary or description. This is currently not + possible to distinguish in Home Assistant and just + fails silently. """ try: if TYPE_CHECKING: @@ -101,7 +102,7 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): - """A To-do List representation of the additional items in the Cookidoo Shopping List.""" + """A To-do List representation of additional Cookidoo Shopping List items.""" _attr_translation_key = "additional_item_list" _attr_supported_features = ( diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index 5c1f19fd14c..c86d9e4669b 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for CoolMasterNet integration.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index 7cc8fc56c80..0346bf06da5 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -1,7 +1,5 @@ """Button platform for CoolMasterNet integration.""" -from __future__ import annotations - from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index f6017c95b43..c1e2eb2e441 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -1,7 +1,5 @@ """CoolMasterNet platform to control of CoolMasterNet Climate Devices.""" -from __future__ import annotations - import logging from typing import Any @@ -70,7 +68,8 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): _attr_name = None - # TODO(2026.7.0): When support for unknown fan speeds is removed, delete this variable. + # TODO(2026.7.0): When support for unknown fan speeds is + # removed, delete this variable. # Holds unknown fan speeds we have already warned about. warned_unknown_fan_speeds: set[str] = set() diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index d9c16dcb7cf..91fbd5eeaa3 100644 --- a/homeassistant/components/coolmaster/config_flow.py +++ b/homeassistant/components/coolmaster/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Coolmaster.""" -from __future__ import annotations - from typing import Any from pycoolmasternet_async import CoolMasterNet @@ -11,8 +9,10 @@ from homeassistant.components.climate import HVACMode from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import SectionConfig, section from .const import ( + CONF_MORE_OPTIONS, CONF_SEND_WAKEUP_PROMPT, CONF_SUPPORTED_MODES, CONF_SWING_SUPPORT, @@ -31,11 +31,21 @@ AVAILABLE_MODES = [ MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES} -DATA_SCHEMA = { - vol.Required(CONF_HOST): str, - **MODES_SCHEMA, - vol.Required(CONF_SWING_SUPPORT, default=False): bool, -} +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + **MODES_SCHEMA, + vol.Required(CONF_SWING_SUPPORT, default=False): bool, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False): bool, + } + ), + SectionConfig(collapsed=True), + ), + } +) async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool: @@ -49,16 +59,9 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def _get_data_schema(self) -> vol.Schema: - schema_dict = DATA_SCHEMA.copy() - - if self.show_advanced_options: - schema_dict[vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False)] = bool - - return vol.Schema(schema_dict) - @callback def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + more_options = data.get(CONF_MORE_OPTIONS, {}) supported_modes = [ key for (key, value) in data.items() if key in AVAILABLE_MODES and value ] @@ -69,7 +72,9 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PORT: DEFAULT_PORT, CONF_SUPPORTED_MODES: supported_modes, CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT], - CONF_SEND_WAKEUP_PROMPT: data.get(CONF_SEND_WAKEUP_PROMPT, False), + CONF_SEND_WAKEUP_PROMPT: more_options.get( + CONF_SEND_WAKEUP_PROMPT, False + ), }, ) @@ -77,18 +82,17 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - data_schema = self._get_data_schema() - if user_input is None: - return self.async_show_form(step_id="user", data_schema=data_schema) + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) errors = {} host = user_input[CONF_HOST] + more_options = user_input.get(CONF_MORE_OPTIONS, {}) try: result = await _validate_connection( - host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False) + host, more_options.get(CONF_SEND_WAKEUP_PROMPT, False) ) if not result: errors["base"] = "no_units" @@ -97,7 +101,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", data_schema=DATA_SCHEMA, errors=errors ) return self._async_get_entry(user_input) diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py index ce6fe45adc4..66ba74b508c 100644 --- a/homeassistant/components/coolmaster/const.py +++ b/homeassistant/components/coolmaster/const.py @@ -6,6 +6,7 @@ DEFAULT_PORT = 10102 CONF_SUPPORTED_MODES = "supported_modes" CONF_SWING_SUPPORT = "swing_support" +CONF_MORE_OPTIONS = "more_options" CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt" MAX_RETRIES = 3 BACKOFF_BASE_DELAY = 2 diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py index b7fe0c28134..595e097fb2d 100644 --- a/homeassistant/components/coolmaster/coordinator.py +++ b/homeassistant/components/coolmaster/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for coolmaster integration.""" -from __future__ import annotations - import asyncio import logging @@ -56,7 +54,9 @@ class CoolmasterDataUpdateCoordinator( except OSError as error: if retries_left == 0: raise UpdateFailed( - f"Error communicating with Coolmaster (aborting after {MAX_RETRIES} retries): {error}" + "Error communicating with Coolmaster" + f" (aborting after {MAX_RETRIES}" + f" retries): {error}" ) from error _LOGGER.debug( "Error communicating with coolmaster (%d retries left): %s", @@ -68,7 +68,8 @@ class CoolmasterDataUpdateCoordinator( return status _LOGGER.debug( - "Error communicating with coolmaster: empty status received (%d retries left)", + "Error communicating with coolmaster:" + " empty status received (%d retries left)", retries_left, ) @@ -76,5 +77,7 @@ class CoolmasterDataUpdateCoordinator( await asyncio.sleep(backoff) raise UpdateFailed( - f"Error communicating with Coolmaster (aborting after {MAX_RETRIES} retries): empty status received" + "Error communicating with Coolmaster" + f" (aborting after {MAX_RETRIES} retries):" + " empty status received" ) diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 32dceb83c5f..acd5dfcb276 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for CoolMasterNet integration.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 3697f50efb9..abebf548f46 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -14,14 +14,23 @@ "heat_cool": "Support automatic heat/cool mode", "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", - "send_wakeup_prompt": "Send wakeup prompt", "swing_support": "Control swing mode" }, "data_description": { - "host": "The hostname or IP address of your CoolMasterNet device.", - "send_wakeup_prompt": "Send the coolmaster unit an empty commaand before issuing any actual command. This is required for serial models." + "host": "The hostname or IP address of your CoolMasterNet device." }, - "description": "Set up your CoolMasterNet connection details." + "description": "Set up your CoolMasterNet connection details.", + "sections": { + "more_options": { + "data": { + "send_wakeup_prompt": "Send wakeup prompt" + }, + "data_description": { + "send_wakeup_prompt": "Send the coolmaster unit an empty command before issuing any actual command. This is required for serial models." + }, + "name": "More options" + } + } } } }, diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index e84a92328b2..05a45f9f811 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -1,7 +1,5 @@ """Component to count within automations.""" -from __future__ import annotations - import logging from typing import Any, Self @@ -256,17 +254,20 @@ class Counter(collection.CollectionEntity, RestoreEntity): """Set counter to value.""" if (maximum := self._config.get(CONF_MAXIMUM)) is not None and value > maximum: raise ValueError( - f"Value {value} for {self.entity_id} exceeding the maximum value of {maximum}" + f"Value {value} for {self.entity_id}" + f" exceeding the maximum value of {maximum}" ) if (minimum := self._config.get(CONF_MINIMUM)) is not None and value < minimum: raise ValueError( - f"Value {value} for {self.entity_id} exceeding the minimum value of {minimum}" + f"Value {value} for {self.entity_id}" + f" exceeding the minimum value of {minimum}" ) if (step := self._config.get(CONF_STEP)) is not None and value % step != 0: raise ValueError( - f"Value {value} for {self.entity_id} is not a multiple of the step size {step}" + f"Value {value} for {self.entity_id}" + f" is not a multiple of the step size {step}" ) self._state = value diff --git a/homeassistant/components/counter/conditions.yaml b/homeassistant/components/counter/conditions.yaml index 6a00235d287..50081533ec3 100644 --- a/homeassistant/components/counter/conditions.yaml +++ b/homeassistant/components/counter/conditions.yaml @@ -7,11 +7,13 @@ is_value: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: threshold: required: true selector: diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 42c68d1f344..30d390e5588 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Counter state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 4e728b0bc44..1f08ba33ae9 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -1,6 +1,8 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_value": { @@ -9,6 +11,9 @@ "behavior": { "name": "Condition passes if" }, + "for": { + "name": "[%key:component::counter::common::condition_for_name%]" + }, "threshold": { "name": "Threshold type" } @@ -42,21 +47,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "decrement": { "description": "Decrements a counter by its step size.", @@ -96,6 +86,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached maximum" @@ -105,6 +98,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached minimum" @@ -114,6 +110,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reset" diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py index bcb1a23be84..f84191e1873 100644 --- a/homeassistant/components/counter/trigger.py +++ b/homeassistant/components/counter/trigger.py @@ -1,11 +1,6 @@ """Provides triggers for counters.""" -from homeassistant.const import ( - CONF_MAXIMUM, - CONF_MINIMUM, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -41,9 +36,7 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger): """Trigger for when a counter is decremented.""" def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the counter value decreased.""" return int(from_state.state) > int(to_state.state) @@ -51,9 +44,7 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger): """Trigger for when a counter is incremented.""" def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the counter value increased.""" return int(from_state.state) < int(to_state.state) @@ -62,12 +53,6 @@ class CounterValueBaseTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec()} - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - class CounterMaxReachedTrigger(CounterValueBaseTrigger): """Trigger for when a counter reaches its maximum value.""" diff --git a/homeassistant/components/counter/triggers.yaml b/homeassistant/components/counter/triggers.yaml index b424d1769d7..9ad489e3935 100644 --- a/homeassistant/components/counter/triggers.yaml +++ b/homeassistant/components/counter/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: incremented: target: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 7dc9bd26d03..0e37281ceeb 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,7 +1,5 @@ """Support for Cover devices.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import functools as ft @@ -29,7 +27,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from .condition import make_cover_is_closed_condition, make_cover_is_open_condition @@ -87,7 +84,6 @@ __all__ = [ ] -@bind_hass def is_closed(hass: HomeAssistant, entity_id: str) -> bool: """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(entity_id, CoverState.CLOSED) @@ -432,9 +428,13 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # * fully open but do not report `current_cover_position` # * stopped partially open # * either opening or closing, but do not report them - # If we previously reported opening/closing, we should move in the opposite direction. - # Otherwise, we must assume we are (partially) open and should always close. - # Note: _cover_is_last_toggle_direction_open will always remain True if we never report opening/closing. + # If we previously reported opening/closing, we should + # move in the opposite direction. + # Otherwise, we must assume we are (partially) open + # and should always close. + # Note: _cover_is_last_toggle_direction_open will + # always remain True if we never report + # opening/closing. return ( fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"] ) diff --git a/homeassistant/components/cover/conditions.yaml b/homeassistant/components/cover/conditions.yaml index 075f3a926bc..6db398fd069 100644 --- a/homeassistant/components/cover/conditions.yaml +++ b/homeassistant/components/cover/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: awning_is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index a982e99776b..a7f7213ef91 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Cover.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index f1d89a0e1eb..b2198701d84 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -1,7 +1,5 @@ """Provides device automations for Cover.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 0f65ef80a7f..25b95ae6ef3 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Cover.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 927e725460c..ea7f3ef1f22 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Cover state.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine, Iterable from functools import partial diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py index 32f62057b93..c1a860afd19 100644 --- a/homeassistant/components/cover/significant_change.py +++ b/homeassistant/components/cover/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Cover state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 3be0ed28d79..502168bcc79 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "awning_is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is closed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is open" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is closed" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is open" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is closed" @@ -54,6 +71,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is open" @@ -63,6 +83,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is closed" @@ -72,6 +95,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is open" @@ -81,6 +107,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is closed" @@ -90,6 +119,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is open" @@ -178,21 +210,6 @@ "name": "Window" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "close_cover": { "description": "Closes a cover.", @@ -254,6 +271,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning closed" @@ -263,6 +283,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning opened" @@ -272,6 +295,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind closed" @@ -281,6 +307,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind opened" @@ -290,6 +319,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain closed" @@ -299,6 +331,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain opened" @@ -308,6 +343,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade closed" @@ -317,6 +355,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade opened" @@ -326,6 +367,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shutter closed" @@ -335,6 +379,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shutter opened" diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 1d3ceba1177..c9f81c9d6d1 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -2,7 +2,7 @@ from collections.abc import Mapping -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.trigger import EntityTriggerBase, Trigger @@ -28,9 +28,7 @@ class CoverTriggerBase(EntityTriggerBase): return self._get_value(state) == domain_spec.target_value def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the transition is valid for a cover state change.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the relevant cover value changed.""" if (from_value := self._get_value(from_state)) is None: return False return from_value != self._get_value(to_state) diff --git a/homeassistant/components/cover/triggers.yaml b/homeassistant/components/cover/triggers.yaml index 4b9d0a054dc..69ed2379fd8 100644 --- a/homeassistant/components/cover/triggers.yaml +++ b/homeassistant/components/cover/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: awning_closed: fields: *trigger_common_fields diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index 3b2682d4e32..ceae6b75622 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -1,7 +1,5 @@ """Support for ClearPass Policy Manager.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/cpuspeed/config_flow.py b/homeassistant/components/cpuspeed/config_flow.py index 21dc577b5bf..6defa844fb7 100644 --- a/homeassistant/components/cpuspeed/config_flow.py +++ b/homeassistant/components/cpuspeed/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the CPU Speed integration.""" -from __future__ import annotations - from typing import Any from cpuinfo import cpuinfo diff --git a/homeassistant/components/cpuspeed/diagnostics.py b/homeassistant/components/cpuspeed/diagnostics.py index 64fe7f86fa2..26b5d47697e 100644 --- a/homeassistant/components/cpuspeed/diagnostics.py +++ b/homeassistant/components/cpuspeed/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for CPU Speed.""" -from __future__ import annotations - from typing import Any from cpuinfo import cpuinfo diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 11f683b1434..46ba2eba7f8 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -1,7 +1,5 @@ """Support for displaying the current CPU speed.""" -from __future__ import annotations - from cpuinfo import cpuinfo from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py index 8f5739f9172..8c294d46e0a 100644 --- a/homeassistant/components/crownstone/__init__.py +++ b/homeassistant/components/crownstone/__init__.py @@ -1,7 +1,5 @@ """Integration for Crownstone.""" -from __future__ import annotations - from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 5f5af4f51a4..08610f52f89 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -1,7 +1,5 @@ """Flow handler for Crownstone.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any @@ -10,8 +8,6 @@ from crownstone_cloud.exceptions import ( CrownstoneAuthenticationError, CrownstoneUnknownError, ) -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -61,9 +57,11 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Set up a Crownstone USB dongle.""" - list_of_ports = await self.hass.async_add_executor_job( - serial.tools.list_ports.comports - ) + list_of_ports = [ + p + for p in await usb.async_scan_serial_ports(self.hass) + if isinstance(p, usb.USBDevice) + ] if self.flow_type == CONFIG_FLOW: ports_as_string = list_ports_as_str(list_of_ports) else: @@ -82,10 +80,8 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow): else: index = ports_as_string.index(selection) - 1 - selected_port: ListPortInfo = list_of_ports[index] - self.usb_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, selected_port.device - ) + selected_port = list_of_ports[index] + self.usb_path = selected_port.device return await self.async_step_usb_sphere_config() return self.async_show_form( diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py index 5325a476266..455f2a8be6c 100644 --- a/homeassistant/components/crownstone/const.py +++ b/homeassistant/components/crownstone/const.py @@ -1,7 +1,5 @@ """Constants for the crownstone integration.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform diff --git a/homeassistant/components/crownstone/entity.py b/homeassistant/components/crownstone/entity.py index cb06a5fb00d..b086e8be06e 100644 --- a/homeassistant/components/crownstone/entity.py +++ b/homeassistant/components/crownstone/entity.py @@ -1,7 +1,5 @@ """Base classes for Crownstone devices.""" -from __future__ import annotations - from crownstone_cloud.cloud_models.crownstones import Crownstone from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py index e414e3c7055..3ac68e08bb6 100644 --- a/homeassistant/components/crownstone/entry_manager.py +++ b/homeassistant/components/crownstone/entry_manager.py @@ -1,7 +1,5 @@ """Manager to set up IO with Crownstone devices for a config entry.""" -from __future__ import annotations - import logging from typing import Any @@ -81,7 +79,8 @@ class CrownstoneEntryManager: _LOGGER.error("Unknown error during login") raise ConfigEntryNotReady from unknown_err - # A new clientsession is created because the default one does not cleanup on unload + # A new clientsession is created because the default + # one does not cleanup on unload self.sse = CrownstoneSSEAsync( email=email, password=password, @@ -100,7 +99,8 @@ class CrownstoneEntryManager: await self.async_setup_usb() # Save the sphere where the USB is located - # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple + # Makes HA aware of the Crownstone environment HA is + # placed in, a user can have multiple self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] return True diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py index 4da8bc8dbe7..417654db575 100644 --- a/homeassistant/components/crownstone/helpers.py +++ b/homeassistant/components/crownstone/helpers.py @@ -1,19 +1,16 @@ """Helper functions for the Crownstone integration.""" -from __future__ import annotations - from collections.abc import Sequence import os -from serial.tools.list_ports_common import ListPortInfo - from homeassistant.components import usb +from homeassistant.components.usb import USBDevice from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST def list_ports_as_str( - serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True + serial_ports: Sequence[USBDevice], no_usb_option: bool = True ) -> list[str]: """Represent currently available serial ports as string. @@ -31,8 +28,8 @@ def list_ports_as_str( port.serial_number, port.manufacturer, port.description, - f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None, - f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None, + port.vid, + port.pid, ) for port in serial_ports ) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index 4b5b12f4cb3..6b245ee32fa 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -1,7 +1,5 @@ """Support for Crownstone devices.""" -from __future__ import annotations - from functools import partial from typing import Any diff --git a/homeassistant/components/crownstone/listeners.py b/homeassistant/components/crownstone/listeners.py index 2642e1501ef..5fc046b7c61 100644 --- a/homeassistant/components/crownstone/listeners.py +++ b/homeassistant/components/crownstone/listeners.py @@ -1,11 +1,11 @@ """Listeners for updating data in the Crownstone integration. -For data updates, Cloud Push is used in form of an SSE server that sends out events. -For fast device switching Local Push is used in form of a USB dongle that hooks into a BLE mesh. +For data updates, Cloud Push is used in form of an SSE server +that sends out events. +For fast device switching Local Push is used in form of a USB +dongle that hooks into a BLE mesh. """ -from __future__ import annotations - from functools import partial from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 6168d483ab5..7eb3dbd31ba 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -1,9 +1,9 @@ { "domain": "crownstone", "name": "Crownstone", - "after_dependencies": ["usb"], "codeowners": ["@Crownstone", "@RicArch97"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/crownstone", "iot_class": "cloud_push", "loggers": [ @@ -15,7 +15,6 @@ "requirements": [ "crownstone-cloud==1.4.11", "crownstone-sse==2.0.5", - "crownstone-uart==2.1.0", - "pyserial==3.5" + "crownstone-uart==2.1.0" ] } diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 832a856f51a..c88e586d55e 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -1,7 +1,5 @@ """Support for currencylayer.com exchange rates service.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -63,7 +61,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" - _attr_icon = "mdi:currency" + _attr_icon = "mdi:currency-usd" def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py index ba340f90fd7..246eaaa6287 100644 --- a/homeassistant/components/cync/__init__.py +++ b/homeassistant/components/cync/__init__.py @@ -1,7 +1,5 @@ """The Cync integration.""" -from __future__ import annotations - from pycync import Auth, Cync, User from pycync.exceptions import AuthFailedError, CyncError diff --git a/homeassistant/components/cync/config_flow.py b/homeassistant/components/cync/config_flow.py index 23359697ff6..f88aab510ae 100644 --- a/homeassistant/components/cync/config_flow.py +++ b/homeassistant/components/cync/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Cync integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -90,7 +88,7 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required and prompts for their Cync credentials.""" + """Inform the user that reauth is required and prompt for Cync credentials.""" errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py index 84bfa6d0fee..0503d7bed2b 100644 --- a/homeassistant/components/cync/coordinator.py +++ b/homeassistant/components/cync/coordinator.py @@ -1,7 +1,5 @@ """Coordinator to handle keeping device states up to date.""" -from __future__ import annotations - from datetime import timedelta import logging import time @@ -53,7 +51,7 @@ class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]): await self._update_config_cync_credentials(logged_in_user) async def _async_update_data(self) -> dict[int, CyncDevice]: - """First, refresh the user's auth token if it is set to expire in less than one hour. + """Refresh the user's auth token if it expires within one hour. Then, fetch all current device states. """ diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index a96918747a2..ed05b3bbe90 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -1,7 +1,5 @@ """Platform for the Daikin AC.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index d9917c3cfe6..6589da59fce 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -1,7 +1,5 @@ """Support for the Daikin HVAC.""" -from __future__ import annotations - from collections.abc import Sequence import logging from typing import Any @@ -175,7 +173,10 @@ async def async_setup_entry( def format_target_temperature(target_temperature: float) -> str: - """Format target temperature to be sent to the Daikin unit, rounding to nearest half degree.""" + """Format target temperature to be sent to the Daikin unit. + + Rounds to nearest half degree. + """ return str(round(float(target_temperature) * 2, 0) / 2).rstrip("0").rstrip(".") diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 52d03c97995..04771d13a86 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Daikin platform.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py index 9bd8d17bf48..d7ee2730929 100644 --- a/homeassistant/components/daikin/coordinator.py +++ b/homeassistant/components/daikin/coordinator.py @@ -4,10 +4,11 @@ from datetime import timedelta import logging from pydaikin.daikin_base import Appliance +from pydaikin.exceptions import DaikinException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, TIMEOUT_SEC @@ -33,4 +34,11 @@ class DaikinCoordinator(DataUpdateCoordinator[None]): self.device = device async def _async_update_data(self) -> None: - await self.device.update_status() + try: + await self.device.update_status() + except DaikinException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_communicating", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index c1aa28fbe67..eae3fa5aeb1 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,7 +1,5 @@ """Support for Daikin AC sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index b3326454d37..b7efa24a31d 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -59,6 +59,9 @@ } }, "exceptions": { + "error_communicating": { + "message": "Error communicating with Daikin device: {error}" + }, "zone_hvac_mode_unsupported": { "message": "Zone temperature can only be changed when the main climate mode is heat or cool." }, diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 20d27e7d3ea..5e9a8d41862 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -1,7 +1,5 @@ """Support for Daikin AirBase zones.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py index 736604d7ea1..78fada815f1 100644 --- a/homeassistant/components/danfoss_air/binary_sensor.py +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the for Danfoss Air HRV binary sensors.""" -from __future__ import annotations - from pydanfossair.commands import ReadCommand from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 569ba21b234..b7f2b0eaf9a 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -1,7 +1,5 @@ """Support for the for Danfoss Air HRV sensors.""" -from __future__ import annotations - import logging from pydanfossair.commands import ReadCommand diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index c30dc3fac83..57e254ed4b7 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -1,7 +1,5 @@ """Support for the for Danfoss Air HRV sswitches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/data_grand_lyon/__init__.py b/homeassistant/components/data_grand_lyon/__init__.py new file mode 100644 index 00000000000..2f33bfe1d4f --- /dev/null +++ b/homeassistant/components/data_grand_lyon/__init__.py @@ -0,0 +1,62 @@ +"""The Data Grand Lyon integration.""" + +import asyncio + +from data_grand_lyon_ha import DataGrandLyonClient + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .coordinator import ( + DataGrandLyonConfigEntry, + DataGrandLyonData, + DataGrandLyonTclCoordinator, + DataGrandLyonVelovCoordinator, +) + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: DataGrandLyonConfigEntry +) -> bool: + """Set up Data Grand Lyon from a config entry.""" + session = async_get_clientsession(hass) + client = DataGrandLyonClient( + session=session, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + + tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client) + velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client) + + coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator] + await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) + + entry.runtime_data = DataGrandLyonData( + tcl_coordinator=tcl_coordinator, + velov_coordinator=velov_coordinator, + ) + + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_entry( + hass: HomeAssistant, entry: DataGrandLyonConfigEntry +) -> None: + """Handle config entry update (e.g., subentry changes).""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, entry: DataGrandLyonConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/data_grand_lyon/binary_sensor.py b/homeassistant/components/data_grand_lyon/binary_sensor.py new file mode 100644 index 00000000000..be598660cb4 --- /dev/null +++ b/homeassistant/components/data_grand_lyon/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for the Data Grand Lyon integration.""" + +from data_grand_lyon_ha import VelovStationStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SUBENTRY_TYPE_VELOV_STATION +from .coordinator import DataGrandLyonConfigEntry +from .entity import DataGrandLyonVelovEntity + +PARALLEL_UPDATES = 0 + +VELOV_BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="station_open", + translation_key="station_open", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Data Grand Lyon binary sensor entities.""" + velov_coordinator = entry.runtime_data.velov_coordinator + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION): + async_add_entities( + ( + DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description) + for description in VELOV_BINARY_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry.subentry_id, + ) + + +class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntity): + """Binary sensor for Data Grand Lyon Vélo'v station.""" + + @property + def is_on(self) -> bool: + """Return true if the station is open.""" + return ( + self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN + ) diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py new file mode 100644 index 00000000000..ad234b066d8 --- /dev/null +++ b/homeassistant/components/data_grand_lyon/config_flow.py @@ -0,0 +1,236 @@ +"""Config flow for the Data Grand Lyon integration.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from aiohttp import ClientError, ClientResponseError +from data_grand_lyon_ha import DataGrandLyonClient +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_LINE, + CONF_STATION_ID, + CONF_STOP_ID, + DOMAIN, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV_STATION, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_STOP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LINE): str, + vol.Required(CONF_STOP_ID): vol.Coerce(int), + } +) + +STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATION_ID): vol.Coerce(int), + } +) + + +class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Data Grand Lyon.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentry types supported by this integration.""" + return { + SUBENTRY_TYPE_STOP: StopSubentryFlowHandler, + SUBENTRY_TYPE_VELOV_STATION: VelovStationSubentryFlowHandler, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + + if error := await self._test_connection(user_input): + errors["base"] = error + else: + return self.async_create_entry(title="Data Grand Lyon", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with new credentials.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + if error := await self._test_connection(user_input): + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of credentials.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + creds = { + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + if error := await self._test_connection(creds): + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_RECONFIGURE_SCHEMA, + user_input or reconfigure_entry.data, + ), + errors=errors, + ) + + async def _test_connection(self, user_input: dict[str, Any]) -> str | None: + """Test connectivity by making a dummy API call. + + Returns None on success, or an error key for the errors dict. + """ + session = async_get_clientsession(self.hass) + client = DataGrandLyonClient( + session=session, + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + try: + await client.get_tcl_passages() + except ClientResponseError as err: + if err.status in (401, 403): + return "invalid_auth" + return "cannot_connect" + except ClientError, TimeoutError: + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error testing Data Grand Lyon connection") + return "unknown" + return None + + +class StopSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding a Data Grand Lyon stop.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the user step to add a new stop.""" + entry = self._get_entry() + + if user_input is not None: + line = user_input[CONF_LINE] + stop_id = user_input[CONF_STOP_ID] + unique_id = f"{line}_{stop_id}" + + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + name = f"{line} - Stop {stop_id}" + return self.async_create_entry( + title=name, + data={CONF_LINE: line, CONF_STOP_ID: stop_id}, + unique_id=unique_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_STOP_DATA_SCHEMA, + ) + + +class VelovStationSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding a Vélo'v station.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the user step to add a new Vélo'v station.""" + entry = self._get_entry() + + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + unique_id = f"velov_{station_id}" + + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=f"Vélo'v {station_id}", + data={CONF_STATION_ID: station_id}, + unique_id=unique_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_VELOV_STATION_DATA_SCHEMA, + ) diff --git a/homeassistant/components/data_grand_lyon/const.py b/homeassistant/components/data_grand_lyon/const.py new file mode 100644 index 00000000000..49c66613a8e --- /dev/null +++ b/homeassistant/components/data_grand_lyon/const.py @@ -0,0 +1,13 @@ +"""Constants for the Data Grand Lyon integration.""" + +import logging + +DOMAIN = "data_grand_lyon" +LOGGER = logging.getLogger(__package__) + +SUBENTRY_TYPE_STOP = "stop" +SUBENTRY_TYPE_VELOV_STATION = "velov_station" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STATION_ID = "station_id" diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py new file mode 100644 index 00000000000..70764fdfe1a --- /dev/null +++ b/homeassistant/components/data_grand_lyon/coordinator.py @@ -0,0 +1,168 @@ +"""DataUpdateCoordinator for the Data Grand Lyon integration.""" + +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientError, ClientResponseError +from data_grand_lyon_ha import ( + DataGrandLyonClient, + TclPassage, + VelovStation, + filter_tcl_passages_by_lines_stops, + find_velov_stations_by_ids, + sort_tcl_passages_by_time, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_LINE, + CONF_STATION_ID, + CONF_STOP_ID, + DOMAIN, + LOGGER, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV_STATION, +) + + +@dataclass +class DataGrandLyonData: + """Runtime data for the Data Grand Lyon integration.""" + + tcl_coordinator: DataGrandLyonTclCoordinator + velov_coordinator: DataGrandLyonVelovCoordinator + + +type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData] + + +class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]): + """Coordinator for TCL transit passages.""" + + config_entry: DataGrandLyonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + client: DataGrandLyonClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_tcl", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> dict[str, list[TclPassage]]: + """Fetch data for all monitored stops.""" + stop_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP) + ) + if not stop_subentries: + return {} + + try: + all_passages = await self.client.get_tcl_passages() + except ClientResponseError as err: + if err.status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_tcl", + ) from err + except (ClientError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_tcl", + ) from err + + lines_stops = [ + (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID]) + for subentry in stop_subentries + ] + grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops) + stops: dict[str, list[TclPassage]] = {} + for subentry in stop_subentries: + key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID]) + sorted_passages = sort_tcl_passages_by_time(grouped[key]) + if sorted_passages: + stops[subentry.subentry_id] = sorted_passages + else: + LOGGER.warning( + "No TCL passages found for subentry %s", + subentry.subentry_id, + ) + return stops + + +class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation]]): + """Coordinator for Vélo'v stations.""" + + config_entry: DataGrandLyonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + client: DataGrandLyonClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_velov", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> dict[str, VelovStation]: + """Fetch data for all monitored Vélo'v stations.""" + velov_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION) + ) + if not velov_subentries: + return {} + + try: + all_stations = await self.client.get_velov_stations() + except ClientResponseError as err: + if err.status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_velov", + ) from err + except (ClientError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_velov", + ) from err + + station_ids = [subentry.data[CONF_STATION_ID] for subentry in velov_subentries] + found = find_velov_stations_by_ids(all_stations, station_ids) + velov_stations: dict[str, VelovStation] = {} + for subentry in velov_subentries: + station = found[subentry.data[CONF_STATION_ID]] + if station is not None: + velov_stations[subentry.subentry_id] = station + else: + LOGGER.warning( + "Vélo'v station not found for subentry %s", + subentry.subentry_id, + ) + return velov_stations diff --git a/homeassistant/components/data_grand_lyon/diagnostics.py b/homeassistant/components/data_grand_lyon/diagnostics.py new file mode 100644 index 00000000000..8e7788e4025 --- /dev/null +++ b/homeassistant/components/data_grand_lyon/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for the Data Grand Lyon integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .coordinator import DataGrandLyonConfigEntry + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: DataGrandLyonConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": { + "stops": { + subentry_id: [asdict(passage) for passage in passages] + for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items() + }, + "velov_stations": { + subentry_id: asdict(station) + for subentry_id, station in entry.runtime_data.velov_coordinator.data.items() + }, + }, + } diff --git a/homeassistant/components/data_grand_lyon/entity.py b/homeassistant/components/data_grand_lyon/entity.py new file mode 100644 index 00000000000..fb31bc91aec --- /dev/null +++ b/homeassistant/components/data_grand_lyon/entity.py @@ -0,0 +1,74 @@ +"""Base entity for the Data Grand Lyon integration.""" + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator + + +class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator]( + CoordinatorEntity[_CoordinatorT] +): + """Base entity for Data Grand Lyon.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: _CoordinatorT, + subentry: ConfigSubentry, + description: EntityDescription, + manufacturer: str, + model: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._subentry_id = subentry.subentry_id + assert subentry.unique_id is not None + + self._attr_unique_id = f"{subentry.unique_id}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.unique_id)}, + name=subentry.title, + manufacturer=manufacturer, + model=model, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def available(self) -> bool: + """Return True if subentry data is available.""" + return super().available and self._subentry_id in self.coordinator.data + + +class DataGrandLyonTclEntity(DataGrandLyonEntity[DataGrandLyonTclCoordinator]): + """Base entity for Data Grand Lyon TCL stops.""" + + def __init__( + self, + coordinator: DataGrandLyonTclCoordinator, + subentry: ConfigSubentry, + description: EntityDescription, + ) -> None: + """Initialize the TCL entity.""" + super().__init__(coordinator, subentry, description, "TCL", "Stop") + + +class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator]): + """Base entity for Data Grand Lyon Vélo'v stations.""" + + def __init__( + self, + coordinator: DataGrandLyonVelovCoordinator, + subentry: ConfigSubentry, + description: EntityDescription, + ) -> None: + """Initialize the Vélo'v entity.""" + super().__init__(coordinator, subentry, description, "JCDecaux", "Station") diff --git a/homeassistant/components/data_grand_lyon/icons.json b/homeassistant/components/data_grand_lyon/icons.json new file mode 100644 index 00000000000..893b337880d --- /dev/null +++ b/homeassistant/components/data_grand_lyon/icons.json @@ -0,0 +1,71 @@ +{ + "entity": { + "binary_sensor": { + "station_open": { + "default": "mdi:close-circle", + "state": { + "on": "mdi:check-circle" + } + } + }, + "sensor": { + "available_bikes": { + "default": "mdi:bike" + }, + "available_electrical_bikes": { + "default": "mdi:bicycle-electric" + }, + "available_mechanical_bikes": { + "default": "mdi:bike" + }, + "available_stands": { + "default": "mdi:parking" + }, + "capacity": { + "default": "mdi:counter" + }, + "electrical_internal_battery_bikes": { + "default": "mdi:bicycle-electric" + }, + "electrical_removable_battery_bikes": { + "default": "mdi:bicycle-electric" + }, + "next_departure_1": { + "default": "mdi:bus-clock" + }, + "next_departure_1_direction": { + "default": "mdi:directions" + }, + "next_departure_1_type": { + "default": "mdi:clock-outline", + "state": { + "estimated": "mdi:clock-check-outline" + } + }, + "next_departure_2": { + "default": "mdi:bus-clock" + }, + "next_departure_2_direction": { + "default": "mdi:directions" + }, + "next_departure_2_type": { + "default": "mdi:clock-outline", + "state": { + "estimated": "mdi:clock-check-outline" + } + }, + "next_departure_3": { + "default": "mdi:bus-clock" + }, + "next_departure_3_direction": { + "default": "mdi:directions" + }, + "next_departure_3_type": { + "default": "mdi:clock-outline", + "state": { + "estimated": "mdi:clock-check-outline" + } + } + } + } +} diff --git a/homeassistant/components/data_grand_lyon/manifest.json b/homeassistant/components/data_grand_lyon/manifest.json new file mode 100644 index 00000000000..01b62c8d35b --- /dev/null +++ b/homeassistant/components/data_grand_lyon/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "data_grand_lyon", + "name": "Data Grand Lyon", + "codeowners": ["@Crocmagnon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/data_grand_lyon", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "platinum", + "requirements": ["data-grand-lyon-ha==0.8.0"] +} diff --git a/homeassistant/components/data_grand_lyon/quality_scale.yaml b/homeassistant/components/data_grand_lyon/quality_scale.yaml new file mode 100644 index 00000000000..f00af503f89 --- /dev/null +++ b/homeassistant/components/data_grand_lyon/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities use the coordinator pattern and do not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This is a service integration; there are no discoverable devices. + discovery: + status: exempt + comment: This is a service integration; there are no discoverable devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This is a service integration; devices are added and removed manually by the user. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: This is a service integration; devices are added and removed manually by the user. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/data_grand_lyon/sensor.py b/homeassistant/components/data_grand_lyon/sensor.py new file mode 100644 index 00000000000..ff96ce5aaa7 --- /dev/null +++ b/homeassistant/components/data_grand_lyon/sensor.py @@ -0,0 +1,225 @@ +"""Sensor platform for the Data Grand Lyon integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from zoneinfo import ZoneInfo + +from data_grand_lyon_ha import TclPassage, TclPassageType, VelovStation + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION +from .coordinator import DataGrandLyonConfigEntry +from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity + +PARALLEL_UPDATES = 0 + +_TZ_PARIS = ZoneInfo("Europe/Paris") + +_DEPARTURE_TYPE_OPTIONS = [t.name.lower() for t in TclPassageType] + + +def _departure_time(departure: TclPassage) -> datetime: + """Return the departure time, localized to Europe/Paris if naive.""" + dt = departure.heure_passage + if dt.tzinfo is None: + return dt.replace(tzinfo=_TZ_PARIS) + return dt + + +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonStopSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon stop departure sensor entity.""" + + departure_index: int + value_fn: Callable[[TclPassage], StateType | datetime] + + +STOP_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonStopSensorEntityDescription, ...] = ( + DataGrandLyonStopSensorEntityDescription( + key="next_departure_1", + translation_key="next_departure_1", + device_class=SensorDeviceClass.TIMESTAMP, + departure_index=0, + value_fn=_departure_time, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_1_direction", + translation_key="next_departure_1_direction", + departure_index=0, + value_fn=lambda p: p.direction, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_1_type", + translation_key="next_departure_1_type", + device_class=SensorDeviceClass.ENUM, + options=_DEPARTURE_TYPE_OPTIONS, + departure_index=0, + value_fn=lambda p: p.type.name.lower(), + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_2", + translation_key="next_departure_2", + device_class=SensorDeviceClass.TIMESTAMP, + departure_index=1, + value_fn=_departure_time, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_2_direction", + translation_key="next_departure_2_direction", + departure_index=1, + value_fn=lambda p: p.direction, + entity_registry_enabled_default=False, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_2_type", + translation_key="next_departure_2_type", + device_class=SensorDeviceClass.ENUM, + options=_DEPARTURE_TYPE_OPTIONS, + departure_index=1, + value_fn=lambda p: p.type.name.lower(), + entity_registry_enabled_default=False, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_3", + translation_key="next_departure_3", + device_class=SensorDeviceClass.TIMESTAMP, + departure_index=2, + value_fn=_departure_time, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_3_direction", + translation_key="next_departure_3_direction", + departure_index=2, + value_fn=lambda p: p.direction, + entity_registry_enabled_default=False, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_departure_3_type", + translation_key="next_departure_3_type", + device_class=SensorDeviceClass.ENUM, + options=_DEPARTURE_TYPE_OPTIONS, + departure_index=2, + value_fn=lambda p: p.type.name.lower(), + entity_registry_enabled_default=False, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonVelovSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon Vélo'v station sensor entity.""" + + value_fn: Callable[[VelovStation], StateType | datetime] + + +VELOV_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonVelovSensorEntityDescription, ...] = ( + DataGrandLyonVelovSensorEntityDescription( + key="available_bikes", + translation_key="available_bikes", + value_fn=lambda s: s.total_stands.bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_mechanical_bikes", + translation_key="available_mechanical_bikes", + value_fn=lambda s: s.total_stands.mechanical_bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_electrical_bikes", + translation_key="available_electrical_bikes", + value_fn=lambda s: s.total_stands.electrical_bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_stands", + translation_key="available_stands", + value_fn=lambda s: s.total_stands.stands, + ), + DataGrandLyonVelovSensorEntityDescription( + key="capacity", + translation_key="capacity", + value_fn=lambda s: s.total_stands.capacity, + entity_registry_enabled_default=False, + ), + DataGrandLyonVelovSensorEntityDescription( + key="electrical_internal_battery_bikes", + translation_key="electrical_internal_battery_bikes", + value_fn=lambda s: s.total_stands.electrical_internal_battery_bikes, + entity_registry_enabled_default=False, + ), + DataGrandLyonVelovSensorEntityDescription( + key="electrical_removable_battery_bikes", + translation_key="electrical_removable_battery_bikes", + value_fn=lambda s: s.total_stands.electrical_removable_battery_bikes, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Data Grand Lyon sensor entities.""" + tcl_coordinator = entry.runtime_data.tcl_coordinator + velov_coordinator = entry.runtime_data.velov_coordinator + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP): + async_add_entities( + ( + DataGrandLyonStopSensor(tcl_coordinator, subentry, description) + for description in STOP_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry.subentry_id, + ) + + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION): + async_add_entities( + ( + DataGrandLyonVelovSensor(velov_coordinator, subentry, description) + for description in VELOV_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry.subentry_id, + ) + + +class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity): + """Sensor for Data Grand Lyon stop departures.""" + + entity_description: DataGrandLyonStopSensorEntityDescription + + @property + def available(self) -> bool: + """Return True if the departure index exists.""" + return super().available and self.entity_description.departure_index < len( + self.coordinator.data[self._subentry_id] + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor value.""" + departure = self.coordinator.data[self._subentry_id][ + self.entity_description.departure_index + ] + return self.entity_description.value_fn(departure) + + +class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity): + """Sensor for Data Grand Lyon Vélo'v station.""" + + entity_description: DataGrandLyonVelovSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor value.""" + return self.entity_description.value_fn( + self.coordinator.data[self._subentry_id] + ) diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json new file mode 100644 index 00000000000..697fda1dd0f --- /dev/null +++ b/homeassistant/components/data_grand_lyon/strings.json @@ -0,0 +1,168 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]", + "username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]" + } + }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]", + "username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]" + } + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your password on data.grandlyon.com.", + "username": "Your username on data.grandlyon.com." + } + } + } + }, + "config_subentries": { + "stop": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "entry_type": "Transit stop", + "initiate_flow": { + "user": "Add transit stop" + }, + "step": { + "user": { + "data": { + "line": "Line", + "stop_id": "Stop ID" + } + } + } + }, + "velov_station": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "entry_type": "Vélo'v station", + "initiate_flow": { + "user": "Add Vélo'v station" + }, + "step": { + "user": { + "data": { + "station_id": "Station ID" + } + } + } + } + }, + "entity": { + "binary_sensor": { + "station_open": { + "name": "Station open" + } + }, + "sensor": { + "available_bikes": { + "name": "Available bikes", + "unit_of_measurement": "bikes" + }, + "available_electrical_bikes": { + "name": "Available electrical bikes", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" + }, + "available_mechanical_bikes": { + "name": "Available mechanical bikes", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" + }, + "available_stands": { + "name": "Available stands", + "unit_of_measurement": "stands" + }, + "capacity": { + "name": "Capacity", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_stands::unit_of_measurement%]" + }, + "electrical_internal_battery_bikes": { + "name": "Electrical internal battery bikes", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" + }, + "electrical_removable_battery_bikes": { + "name": "Electrical removable battery bikes", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" + }, + "next_departure_1": { + "name": "Next departure 1" + }, + "next_departure_1_direction": { + "name": "Next departure 1 direction" + }, + "next_departure_1_type": { + "name": "Next departure 1 type", + "state": { + "estimated": "Estimated", + "theoretical": "Theoretical" + } + }, + "next_departure_2": { + "name": "Next departure 2" + }, + "next_departure_2_direction": { + "name": "Next departure 2 direction" + }, + "next_departure_2_type": { + "name": "Next departure 2 type", + "state": { + "estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]", + "theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]" + } + }, + "next_departure_3": { + "name": "Next departure 3" + }, + "next_departure_3_direction": { + "name": "Next departure 3 direction" + }, + "next_departure_3_type": { + "name": "Next departure 3 type", + "state": { + "estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]", + "theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]" + } + } + } + }, + "exceptions": { + "auth_failed": { + "message": "Authentication failed for Data Grand Lyon." + }, + "update_failed_tcl": { + "message": "Error fetching TCL departures from Data Grand Lyon." + }, + "update_failed_velov": { + "message": "Error fetching Vélo'v stations from Data Grand Lyon." + } + } +} diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 43ce6a9b4c1..97abbb417ff 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -1,7 +1,5 @@ """Component to allow setting date as platforms.""" -from __future__ import annotations - from datetime import date, timedelta import logging from typing import final diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index 53f85992abc..3595be108f2 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -1,7 +1,5 @@ """Component to allow setting date/time as platforms.""" -from __future__ import annotations - from datetime import UTC, datetime, timedelta import logging from typing import final diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index e93b7e14e05..aa995d866b8 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -1,7 +1,5 @@ """Support for DD-WRT routers.""" -from __future__ import annotations - from http import HTTPStatus import logging import re diff --git a/homeassistant/components/deako/__init__.py b/homeassistant/components/deako/__init__.py index 7a169defe01..9e88c36a020 100644 --- a/homeassistant/components/deako/__init__.py +++ b/homeassistant/components/deako/__init__.py @@ -1,7 +1,5 @@ """The deako integration.""" -from __future__ import annotations - import logging from pydeako import Deako, DeakoDiscoverer, FindDevicesError @@ -34,7 +32,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> boo await connection.disconnect() raise ConfigEntryNotReady(exc) from exc - # If deako devices are advertising on mdns, we should be able to get at least one device + # If deako devices are advertising on mdns, we should be + # able to get at least one device devices = connection.get_devices() if len(devices) == 0: await connection.disconnect() diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py index 12f42c36f29..ecc997e2fef 100644 --- a/homeassistant/components/deako/light.py +++ b/homeassistant/components/deako/light.py @@ -93,4 +93,4 @@ class DeakoLightEntity(LightEntity): self._attr_supported_color_modes is not None and ColorMode.BRIGHTNESS in self._attr_supported_color_modes ): - self._attr_brightness = int(round(state.get("dim", 0) * 2.55)) + self._attr_brightness = round(state.get("dim", 0) * 2.55) diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py index cef98211d9e..4ed32cac834 100644 --- a/homeassistant/components/debugpy/__init__.py +++ b/homeassistant/components/debugpy/__init__.py @@ -1,7 +1,5 @@ """The Remote Python Debugger integration.""" -from __future__ import annotations - from asyncio import Event, get_running_loop import logging from threading import Thread diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 7de091c1292..40b42641453 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,7 +1,5 @@ """Support for deCONZ devices.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 85ca32d76e6..f0750644d22 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for deCONZ alarm control panel devices.""" -from __future__ import annotations - from pydeconz.models.alarm_system import AlarmSystemArmAction from pydeconz.models.event import EventType from pydeconz.models.sensor.ancillary_control import ( diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index fcbb61a4e4f..395b18f5e4f 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,7 +1,5 @@ """Support for deCONZ binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 1d96f9867a7..0679b4c57dd 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -1,7 +1,5 @@ """Support for deCONZ buttons.""" -from __future__ import annotations - from dataclasses import dataclass from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index af10bf7e3c3..730bab3dfa1 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -1,7 +1,5 @@ """Support for deCONZ climate devices.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType @@ -28,12 +26,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry -from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE +from .const import ATTR_OFFSET, ATTR_VALVE from .entity import DeconzDevice from .hub import DeconzHub diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index c979b7059b2..9936dc1e5de 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure deCONZ component.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 6edc9beaf38..b8a74fea18f 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -43,7 +43,6 @@ PLATFORMS = [ ] ATTR_DARK = "dark" -ATTR_LOCKED = "locked" ATTR_OFFSET = "offset" ATTR_ON = "on" ATTR_VALVE = "valve" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index d68e0fec09c..059502cb54a 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,7 +1,5 @@ """Support for deCONZ covers.""" -from __future__ import annotations - from typing import Any, cast from pydeconz.interfaces.lights import CoverAction diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index d6d2ddf1373..ad1ff8fe09e 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -1,7 +1,5 @@ """Representation of a deCONZ remote or keypad.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 4bc723abfce..f86483c217f 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for deconz events.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/deconz/diagnostics.py b/homeassistant/components/deconz/diagnostics.py index 284b538d1dd..9b21ae83f22 100644 --- a/homeassistant/components/deconz/diagnostics.py +++ b/homeassistant/components/deconz/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for deCONZ.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/deconz/entity.py b/homeassistant/components/deconz/entity.py index d1ac18c8a52..f0b7adc0cf7 100644 --- a/homeassistant/components/deconz/entity.py +++ b/homeassistant/components/deconz/entity.py @@ -1,7 +1,5 @@ """Base class for deCONZ devices.""" -from __future__ import annotations - from pydeconz.models.deconz_device import DeconzDevice as PydeconzDevice from pydeconz.models.group import Group as PydeconzGroup from pydeconz.models.light import LightBase as PydeconzLightBase diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 324ada807e0..fc261f5559b 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,7 +1,5 @@ """Support for deCONZ fans.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/hub/api.py b/homeassistant/components/deconz/hub/api.py index c00a2178eb0..ff479cdc26c 100644 --- a/homeassistant/components/deconz/hub/api.py +++ b/homeassistant/components/deconz/hub/api.py @@ -1,7 +1,5 @@ """deCONZ API representation.""" -from __future__ import annotations - import asyncio from typing import TYPE_CHECKING diff --git a/homeassistant/components/deconz/hub/config.py b/homeassistant/components/deconz/hub/config.py index 5acbe816833..b04e850333e 100644 --- a/homeassistant/components/deconz/hub/config.py +++ b/homeassistant/components/deconz/hub/config.py @@ -1,7 +1,5 @@ """deCONZ config entry abstraction.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, Self diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py index 3fb864e7019..c304060f604 100644 --- a/homeassistant/components/deconz/hub/hub.py +++ b/homeassistant/components/deconz/hub/hub.py @@ -1,7 +1,5 @@ """Representation of a deCONZ gateway.""" -from __future__ import annotations - from collections.abc import Callable from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 077fabc6d83..209276f8e62 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -1,7 +1,5 @@ """Support for deCONZ lights.""" -from __future__ import annotations - from typing import Any, TypedDict, cast from pydeconz.interfaces.groups import GroupHandler diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 77b9ea435c7..ed316d40c01 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -1,7 +1,5 @@ """Support for deCONZ locks.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index b62e4957c4c..2ce0f45af98 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -1,7 +1,5 @@ """Describe deCONZ logbook events.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index d5ba8cc28d5..3eb44b31c61 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -1,7 +1,5 @@ """Support for configuring different deCONZ numbers.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 0aff2b3ca8c..34f296b3229 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -1,7 +1,5 @@ """Support for deCONZ scenes.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index 4d92b465cdc..5c6b040e3b5 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -1,7 +1,5 @@ """Support for deCONZ select entities.""" -from __future__ import annotations - from pydeconz.models.event import EventType from pydeconz.models.sensor.air_purifier import AirPurifier, AirPurifierFanMode from pydeconz.models.sensor.presence import ( diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 955ea3df853..277e8d478ae 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,7 +1,5 @@ """Support for deCONZ sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -99,7 +97,7 @@ T = TypeVar( @dataclass(frozen=True, kw_only=True) -class DeconzSensorDescription(SensorEntityDescription, Generic[T]): +class DeconzSensorDescription(SensorEntityDescription, Generic[T]): # noqa: UP046 """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index b3c900c07c4..bff21a0c69c 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -11,6 +11,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.read_only_dict import ReadOnlyDict from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER @@ -84,6 +85,7 @@ def async_setup_services(hass: HomeAssistant) -> None: else: try: hub = get_master_hub(hass) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: LOGGER.error("No master gateway available") return @@ -98,7 +100,8 @@ def async_setup_services(hass: HomeAssistant) -> None: await async_remove_orphaned_entries_service(hub) for service in SUPPORTED_SERVICES: - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, service, async_call_deconz_service, diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 4c15cf8ccfe..7d67316ce50 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -1,7 +1,5 @@ """Support for deCONZ siren.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 49904642804..b10fdcd67b8 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,7 +1,5 @@ """Support for deCONZ switches.""" -from __future__ import annotations - from typing import Any from pydeconz.models.event import EventType diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py index c4dc9df08ce..692fa62a0d6 100644 --- a/homeassistant/components/deconz/util.py +++ b/homeassistant/components/deconz/util.py @@ -1,7 +1,5 @@ """Utilities for deCONZ integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/decora_wifi/__init__.py b/homeassistant/components/decora_wifi/__init__.py index e6f9a1e2b0d..cf16bd5daec 100644 --- a/homeassistant/components/decora_wifi/__init__.py +++ b/homeassistant/components/decora_wifi/__init__.py @@ -1,7 +1,5 @@ """The Leviton Decora Wi-Fi integration.""" -from __future__ import annotations - from contextlib import suppress from dataclasses import dataclass @@ -19,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady PLATFORMS = [Platform.LIGHT] @@ -40,7 +38,7 @@ def _login_and_get_switches(email: str, password: str) -> DecoraWifiData: success = session.login(email, password) if success is None: - raise ConfigEntryAuthFailed("Invalid credentials for myLeviton account") + raise ConfigEntryError("Invalid credentials for myLeviton account") perms = session.user.get_residential_permissions() all_switches: list[IotSwitch] = [] diff --git a/homeassistant/components/decora_wifi/config_flow.py b/homeassistant/components/decora_wifi/config_flow.py index f4e55c75fbc..3e69880157b 100644 --- a/homeassistant/components/decora_wifi/config_flow.py +++ b/homeassistant/components/decora_wifi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Leviton Decora Wi-Fi integration.""" -from __future__ import annotations - import contextlib from typing import Any diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 01c926a4922..0934dc10a95 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -1,7 +1,5 @@ """Interfaces with the myLeviton API for Decora Smart WiFi products.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -163,6 +161,7 @@ class DecoraWifiLight(LightEntity): try: self._switch.update_attributes(attribs) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: _LOGGER.error("Failed to turn on myLeviton switch") @@ -171,6 +170,7 @@ class DecoraWifiLight(LightEntity): attribs = {"power": "OFF"} try: self._switch.update_attributes(attribs) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: _LOGGER.error("Failed to turn off myLeviton switch") diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 7f94f272c0d..bf7692f57d9 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -1,7 +1,5 @@ """Support for De Lijn (Flemish public transport) information.""" -from __future__ import annotations - from datetime import datetime import logging diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index f9972570df3..26d9b9ea6ca 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -1,7 +1,5 @@ """The Deluge integration.""" -from __future__ import annotations - import logging from ssl import SSLError diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 0fcd7edfb0d..0c5c3f7dab3 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Deluge integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from ssl import SSLError diff --git a/homeassistant/components/deluge/const.py b/homeassistant/components/deluge/const.py index 909fa2e98c3..78660ddcee2 100644 --- a/homeassistant/components/deluge/const.py +++ b/homeassistant/components/deluge/const.py @@ -13,14 +13,19 @@ LOGGER = logging.getLogger(__package__) class DelugeGetSessionStatusKeys(enum.Enum): - """Enum representing the keys that get passed into the Deluge RPC `core.get_session_status` xml rpc method. + """Keys passed into the Deluge RPC `core.get_session_status`. - You can call `core.get_session_status` with no keys (so an empty list in deluge-client.DelugeRPCClient.call) - to get the full list of possible keys, but it seems to basically be a all of the session statistics - listed on this page: https://www.rasterbar.com/products/libtorrent/manual-ref.html#session-statistics + You can call `core.get_session_status` with no keys + (so an empty list in + deluge-client.DelugeRPCClient.call) + to get the full list of possible keys, but it seems to + basically be all of the session statistics listed on + this page: + https://www.rasterbar.com/products/libtorrent/manual-ref.html#session-statistics and a few others - there is also a list of deprecated keys that deluge will translate for you and issue a warning in the log: + there is also a list of deprecated keys that deluge + will translate for you and issue a warning in the log: https://github.com/deluge-torrent/deluge/blob/7f3f7f69ee78610e95bea07d99f699e9310c4e08/deluge/core/core.py#L58 """ @@ -32,10 +37,11 @@ class DelugeGetSessionStatusKeys(enum.Enum): class DelugeSensorType(enum.StrEnum): - """Enum that distinguishes the different sensor types that the Deluge integration has. + """Sensor types for the Deluge integration. - This is mainly used to avoid passing strings around and to distinguish between similarly - named strings in `DelugeGetSessionStatusKeys`. + This is mainly used to avoid passing strings around + and to distinguish between similarly named strings + in `DelugeGetSessionStatusKeys`. """ CURRENT_STATUS_SENSOR = "current_status" diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index f86f92767ee..1bdfae02538 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Deluge integration.""" -from __future__ import annotations - from collections import Counter from datetime import timedelta from ssl import SSLError diff --git a/homeassistant/components/deluge/entity.py b/homeassistant/components/deluge/entity.py index 5873abb3199..ac9641acd10 100644 --- a/homeassistant/components/deluge/entity.py +++ b/homeassistant/components/deluge/entity.py @@ -1,7 +1,5 @@ """The Deluge integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index eb6ac9b27b9..6d83cd789aa 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the Deluge BitTorrent client API.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -29,7 +27,8 @@ def get_state(data: dict[str, float], key: str) -> str | float: protocol_upload = data[DelugeGetSessionStatusKeys.DHT_UPLOAD_RATE.value] protocol_download = data[DelugeGetSessionStatusKeys.DHT_DOWNLOAD_RATE.value] - # if key is CURRENT_STATUS, we just return whether we are uploading / downloading / idle + # if key is CURRENT_STATUS, we just return whether + # we are uploading / downloading / idle if key == DelugeSensorType.CURRENT_STATUS_SENSOR: if upload > 0 and download > 0: return "seeding_and_downloading" diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 342442ee727..371c353f21f 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -1,7 +1,5 @@ """Support for setting the Deluge BitTorrent client in Pause.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index dbc65119bfa..64ac2bb6afe 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -1,7 +1,5 @@ """Set up the demo environment that mimics interaction with devices.""" -from __future__ import annotations - import asyncio from homeassistant import config_entries, core as ha, setup diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index 4e247812efe..453a23994b7 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -1,7 +1,5 @@ """Demo platform that offers fake air quality data.""" -from __future__ import annotations - from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 9716eccc2c1..b58bd69b46f 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -1,11 +1,9 @@ """Demo platform that has two fake alarm control panels.""" -from __future__ import annotations - import datetime from homeassistant.components.alarm_control_panel import AlarmControlPanelState -from homeassistant.components.manual.alarm_control_panel import ( # pylint: disable=hass-component-root-import +from homeassistant.components.manual.alarm_control_panel import ( # pylint: disable=home-assistant-component-root-import ManualAlarm, ) from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index b210e726205..19e091a4087 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -1,7 +1,5 @@ """Demo platform that has two fake binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 25212f38989..6404f2be440 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake button entity.""" -from __future__ import annotations - from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index b0e82acfa61..d3d93ab34bc 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -1,7 +1,5 @@ """Demo platform that has two fake calendars.""" -from __future__ import annotations - import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 69ba7efda01..1d68ef8de55 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,7 +1,5 @@ """Demo camera platform that has a fake camera.""" -from __future__ import annotations - from pathlib import Path from homeassistant.components.camera import Camera, CameraEntityFeature diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index b1876f3f6ce..73561822e26 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake climate device.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 6f8ee26f511..df0ee6a2693 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure demo component.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index af7b4934975..f112460f06c 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,7 +1,5 @@ """Demo platform for the cover component.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index 875075a381d..4e92afd23c6 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake Date entity.""" -from __future__ import annotations - from datetime import date from homeassistant.components.date import DateEntity diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index 353ed8311bb..3aef9b3cc71 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake date/time entity.""" -from __future__ import annotations - from datetime import UTC, datetime from homeassistant.components.datetime import DateTimeEntity diff --git a/homeassistant/components/demo/device_tracker.py b/homeassistant/components/demo/device_tracker.py index 2097f29ea28..25e24f03a7b 100644 --- a/homeassistant/components/demo/device_tracker.py +++ b/homeassistant/components/demo/device_tracker.py @@ -1,7 +1,5 @@ """Demo platform for the Device tracker component.""" -from __future__ import annotations - import random from homeassistant.components.device_tracker import SeeCallback diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py index f593a833123..c52073131b2 100644 --- a/homeassistant/components/demo/event.py +++ b/homeassistant/components/demo/event.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake event entity.""" -from __future__ import annotations - from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 9f48628688e..f20749e93ad 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,7 +1,5 @@ """Demo fan platform that has a fake fan.""" -from __future__ import annotations - from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index ac72a3097b0..b0a77fca029 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -1,7 +1,5 @@ """Demo platform for the geolocation component.""" -from __future__ import annotations - from datetime import timedelta import logging from math import cos, pi, radians, sin diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 7f34c23751b..9dd9757f6b8 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake humidifier device.""" -from __future__ import annotations - from typing import Any from homeassistant.components.humidifier import ( diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py index d109f55f5a2..549ea9ed098 100644 --- a/homeassistant/components/demo/image_processing.py +++ b/homeassistant/components/demo/image_processing.py @@ -1,7 +1,5 @@ """Support for the demo image processing.""" -from __future__ import annotations - from homeassistant.components.image_processing import ( FaceInformation, ImageProcessingFaceEntity, @@ -45,7 +43,7 @@ class DemoImageProcessingFace(ImageProcessingFaceEntity): """Return minimum confidence for send events.""" return 80 - def process_image(self, image: bytes) -> None: + async def async_process_image(self, image: bytes) -> None: """Process image.""" demo_data = [ FaceInformation( @@ -58,4 +56,4 @@ class DemoImageProcessingFace(ImageProcessingFaceEntity): FaceInformation(confidence=62.53, name="Luna"), ] - self.process_faces(demo_data, 4) + self.async_process_faces(demo_data, 4) diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index a70d3fe481a..cd1feba84a3 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -1,7 +1,5 @@ """Demo light platform that implements lights.""" -from __future__ import annotations - import random from typing import Any diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 081e1cf1d53..1a7b2de655c 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,7 +1,5 @@ """Demo lock platform that implements locks.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index c65cdd12bec..f55563b3eca 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -1,7 +1,5 @@ """Demo implementation of the media player.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py index d26e13cc541..10449bd6325 100644 --- a/homeassistant/components/demo/notify.py +++ b/homeassistant/components/demo/notify.py @@ -1,7 +1,5 @@ """Demo notification entity.""" -from __future__ import annotations - from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, NotifyEntity, diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index c7b62bdc3e0..bcbf43b2f4e 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake Number entity.""" -from __future__ import annotations - from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index b8354edaaea..35617ddf305 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -1,7 +1,5 @@ """Demo platform that has two fake remotes.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index fce90bc9b4f..8c0fad85413 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake select entity.""" -from __future__ import annotations - from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index ae9ff26eca9..5d8489d5121 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -1,7 +1,5 @@ """Demo platform that has a couple of fake sensors.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import cast @@ -14,9 +12,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfTemperature, @@ -39,49 +37,71 @@ async def async_setup_entry( async_add_entities( [ DemoSensor( + "sensor_1", "sensor_1", "Outside Temperature", 15.6, SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, UnitOfTemperature.CELSIUS, - 12, ), DemoSensor( + "battery_1", + "sensor_1", + "Outside Temperature", + 12, + SensorDeviceClass.BATTERY, + SensorStateClass.MEASUREMENT, + PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_name="Battery", + ), + DemoSensor( + "sensor_2", "sensor_2", "Outside Humidity", 54, SensorDeviceClass.HUMIDITY, SensorStateClass.MEASUREMENT, PERCENTAGE, - None, ), DemoSensor( + "sensor_3", "sensor_3", "Carbon monoxide", 54, SensorDeviceClass.CO, SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, - None, ), DemoSensor( + "sensor_4", "sensor_4", "Carbon dioxide", 54, SensorDeviceClass.CO2, SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, - 14, ), DemoSensor( + "battery_4", + "sensor_4", + "Carbon dioxide", + 99, + SensorDeviceClass.BATTERY, + SensorStateClass.MEASUREMENT, + PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_name="Battery", + ), + DemoSensor( + "sensor_5", "sensor_5", "Power consumption", 100, SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, UnitOfPower.WATT, - None, ), DemoSumSensor( "sensor_6", @@ -90,7 +110,6 @@ async def async_setup_entry( SensorDeviceClass.ENERGY, SensorStateClass.TOTAL, UnitOfEnergy.KILO_WATT_HOUR, - None, "total_energy_kwh", ), DemoSumSensor( @@ -100,7 +119,6 @@ async def async_setup_entry( SensorDeviceClass.ENERGY, SensorStateClass.TOTAL, UnitOfEnergy.MEGA_WATT_HOUR, - None, "total_energy_mwh", ), DemoSumSensor( @@ -110,7 +128,6 @@ async def async_setup_entry( SensorDeviceClass.GAS, SensorStateClass.TOTAL, UnitOfVolume.CUBIC_METERS, - None, "total_gas_m3", ), DemoSumSensor( @@ -120,17 +137,16 @@ async def async_setup_entry( SensorDeviceClass.GAS, SensorStateClass.TOTAL, UnitOfVolume.CUBIC_FEET, - None, "total_gas_ft3", ), DemoSensor( unique_id="sensor_10", + device_id="sensor_10", device_name="Thermostat", state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, unit_of_measurement=None, - battery=None, options=["away", "comfort", "eco", "sleep"], translation_key="thermostat_mode", ), @@ -142,20 +158,21 @@ class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" _attr_has_entity_name = True - _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, + device_id: str, device_name: str | None, state: float | str | None, device_class: SensorDeviceClass, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: int | None, options: list[str] | None = None, translation_key: str | None = None, + entity_category: EntityCategory | None = None, + entity_name: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class @@ -165,15 +182,14 @@ class DemoSensor(SensorEntity): self._attr_unique_id = unique_id self._attr_options = options self._attr_translation_key = translation_key + self._attr_entity_category = entity_category + self._attr_name = entity_name self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, + identifiers={(DOMAIN, device_id)}, name=device_name, ) - if battery: - self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery} - class DemoSumSensor(RestoreSensor): """Representation of a Demo sensor.""" @@ -189,7 +205,6 @@ class DemoSumSensor(RestoreSensor): device_class: SensorDeviceClass, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: int | None, suggested_entity_id: str, ) -> None: """Initialize the sensor.""" @@ -206,9 +221,6 @@ class DemoSumSensor(RestoreSensor): name=device_name, ) - if battery: - self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery} - @callback def _async_bump_sum(self, now: datetime) -> None: """Bump the sum.""" diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py index ddaa5101e0f..a10fea387d2 100644 --- a/homeassistant/components/demo/siren.py +++ b/homeassistant/components/demo/siren.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake siren device.""" -from __future__ import annotations - from typing import Any from homeassistant.components.siren import SirenEntity, SirenEntityFeature diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 1757e4a8b88..225046151ac 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,7 +1,5 @@ """Support for the demo for speech-to-text service.""" -from __future__ import annotations - from collections.abc import AsyncIterable from homeassistant.components.stt import ( diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 214f64e8a49..946170630b7 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -1,7 +1,5 @@ """Demo platform that has two fake switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index 3219821ef98..feaa19cc198 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake text entity.""" -from __future__ import annotations - from homeassistant.components.text import TextEntity, TextMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index 296155e9bec..4d3e8fb4c18 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake time entity.""" -from __future__ import annotations - from datetime import time from homeassistant.components.time import TimeEntity diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py index 1d28d1358e1..f51a8e544c7 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -1,7 +1,5 @@ """Support for the demo for text-to-speech service.""" -from __future__ import annotations - import os from typing import Any diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 916646416e9..492e450879e 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -1,7 +1,5 @@ """Demo platform that offers fake update entities.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 28bfea66be2..7ab2c64019a 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -1,7 +1,5 @@ """Demo platform for the vacuum component.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py index 4e90b10ada5..fd118205868 100644 --- a/homeassistant/components/demo/valve.py +++ b/homeassistant/components/demo/valve.py @@ -1,7 +1,5 @@ """Demo valve platform that implements valves.""" -from __future__ import annotations - import asyncio from datetime import datetime from typing import Any diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 6432ce22ddf..a15b9832f27 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake water heater device.""" -from __future__ import annotations - from typing import Any from homeassistant.components.water_heater import ( diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index d1f829fee1b..10265f1e768 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -1,7 +1,5 @@ """Demo platform that offers fake meteorological data.""" -from __future__ import annotations - from datetime import datetime, timedelta from homeassistant.components.weather import ( diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index a33db94f41c..c30e4e24e9d 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -1,7 +1,5 @@ """Support for Denon Network Receivers.""" -from __future__ import annotations - import logging import telnetlib # pylint: disable=deprecated-module diff --git a/homeassistant/components/denon_rs232/__init__.py b/homeassistant/components/denon_rs232/__init__.py new file mode 100644 index 00000000000..6f1199fbcd1 --- /dev/null +++ b/homeassistant/components/denon_rs232/__init__.py @@ -0,0 +1,55 @@ +"""The Denon RS-232 integration.""" + +from denon_rs232 import DenonReceiver, ReceiverState +from denon_rs232.models import MODELS + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import LOGGER, DenonRS232ConfigEntry + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool: + """Set up Denon RS-232 from a config entry.""" + port = entry.data[CONF_DEVICE] + model = MODELS[entry.data[CONF_MODEL]] + receiver = DenonReceiver(port, model=model) + + try: + await receiver.connect() + await receiver.query_state() + except (ConnectionError, OSError, TimeoutError) as err: + LOGGER.error("Error connecting to Denon receiver at %s: %s", port, err) + if receiver.connected: + await receiver.disconnect() + raise ConfigEntryNotReady from err + + entry.runtime_data = receiver + + @callback + def _on_disconnect(state: ReceiverState | None) -> None: + # Only reload if the entry is still loaded. During entry removal, + # disconnect() fires this callback but the entry is already gone. + if state is None and entry.state is ConfigEntryState.LOADED: + LOGGER.warning("Denon receiver disconnected, reloading config entry") + hass.config_entries.async_schedule_reload(entry.entry_id) + + entry.async_on_unload(receiver.subscribe(_on_disconnect)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + await entry.runtime_data.disconnect() + + return unload_ok diff --git a/homeassistant/components/denon_rs232/config_flow.py b/homeassistant/components/denon_rs232/config_flow.py new file mode 100644 index 00000000000..15913dddca3 --- /dev/null +++ b/homeassistant/components/denon_rs232/config_flow.py @@ -0,0 +1,117 @@ +"""Config flow for the Denon RS-232 integration.""" + +from typing import Any + +from denon_rs232 import DenonReceiver +from denon_rs232.models import MODELS +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + SerialPortSelector, +) + +from .const import DOMAIN, LOGGER + +CONF_MODEL_NAME = "model_name" + +# Build a flat list of (model_key, individual_name) pairs by splitting +# grouped names like "AVR-3803 / AVC-3570 / AVR-2803" into separate entries. +# Sorted alphabetically with "Other" at the bottom. +MODEL_OPTIONS: list[tuple[str, str]] = sorted( + ( + (_key, _name) + for _key, _model in MODELS.items() + if _key != "other" + for _name in _model.name.split(" / ") + ), + key=lambda x: x[1], +) +MODEL_OPTIONS.append(("other", "Other")) + + +async def _async_attempt_connect(port: str, model_key: str) -> str | None: + """Attempt to connect to the receiver at the given port. + + Returns None on success, error on failure. + """ + model = MODELS[model_key] + receiver = DenonReceiver(port, model=model) + + try: + await receiver.connect() + except ( + # When the port contains invalid connection data + ValueError, + # If it is a remote port, and we cannot connect + ConnectionError, + OSError, + TimeoutError, + ): + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + else: + await receiver.disconnect() + return None + + +class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Denon RS-232.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + model_key, _, model_name = user_input[CONF_MODEL].partition(":") + resolved_name = model_name if model_key != "other" else None + + self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]}) + error = await _async_attempt_connect(user_input[CONF_DEVICE], model_key) + if not error: + return self.async_create_entry( + title=resolved_name or "Denon Receiver", + data={ + CONF_DEVICE: user_input[CONF_DEVICE], + CONF_MODEL: model_key, + CONF_MODEL_NAME: resolved_name, + }, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=f"{key}:{name}", + label=name, + ) + for key, name in MODEL_OPTIONS + ], + mode=SelectSelectorMode.DROPDOWN, + translation_key="model", + ) + ), + vol.Required(CONF_DEVICE): SerialPortSelector(), + } + ), + user_input or {}, + ), + errors=errors, + ) diff --git a/homeassistant/components/denon_rs232/const.py b/homeassistant/components/denon_rs232/const.py new file mode 100644 index 00000000000..864998635d1 --- /dev/null +++ b/homeassistant/components/denon_rs232/const.py @@ -0,0 +1,12 @@ +"""Constants for the Denon RS-232 integration.""" + +import logging + +from denon_rs232 import DenonReceiver + +from homeassistant.config_entries import ConfigEntry + +LOGGER = logging.getLogger(__package__) +DOMAIN = "denon_rs232" + +type DenonRS232ConfigEntry = ConfigEntry[DenonReceiver] diff --git a/homeassistant/components/denon_rs232/manifest.json b/homeassistant/components/denon_rs232/manifest.json new file mode 100644 index 00000000000..e50677a5f4f --- /dev/null +++ b/homeassistant/components/denon_rs232/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "denon_rs232", + "name": "Denon RS-232", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/denon_rs232", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["denon_rs232"], + "quality_scale": "bronze", + "requirements": ["denon-rs232==4.1.0"] +} diff --git a/homeassistant/components/denon_rs232/media_player.py b/homeassistant/components/denon_rs232/media_player.py new file mode 100644 index 00000000000..4f422df00ec --- /dev/null +++ b/homeassistant/components/denon_rs232/media_player.py @@ -0,0 +1,233 @@ +"""Media player platform for the Denon RS-232 integration.""" + +from typing import Literal, cast + +from denon_rs232 import ( + MIN_VOLUME_DB, + VOLUME_DB_RANGE, + DenonReceiver, + InputSource, + MainPlayer, + ReceiverState, + ZonePlayer, +) + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .config_flow import CONF_MODEL_NAME +from .const import DOMAIN, DenonRS232ConfigEntry + +INPUT_SOURCE_DENON_TO_HA: dict[InputSource, str] = { + InputSource.PHONO: "phono", + InputSource.CD: "cd", + InputSource.TUNER: "tuner", + InputSource.DVD: "dvd", + InputSource.VDP: "vdp", + InputSource.TV: "tv", + InputSource.DBS_SAT: "dbs_sat", + InputSource.VCR_1: "vcr_1", + InputSource.VCR_2: "vcr_2", + InputSource.VCR_3: "vcr_3", + InputSource.V_AUX: "v_aux", + InputSource.CDR_TAPE1: "cdr_tape1", + InputSource.MD_TAPE2: "md_tape2", + InputSource.HDP: "hdp", + InputSource.DVR: "dvr", + InputSource.TV_CBL: "tv_cbl", + InputSource.SAT: "sat", + InputSource.NET_USB: "net_usb", + InputSource.DOCK: "dock", + InputSource.IPOD: "ipod", + InputSource.BD: "bd", + InputSource.SAT_CBL: "sat_cbl", + InputSource.MPLAY: "mplay", + InputSource.GAME: "game", + InputSource.AUX1: "aux1", + InputSource.AUX2: "aux2", + InputSource.NET: "net", + InputSource.BT: "bt", + InputSource.USB_IPOD: "usb_ipod", + InputSource.EIGHT_K: "eight_k", + InputSource.PANDORA: "pandora", + InputSource.SIRIUSXM: "siriusxm", + InputSource.SPOTIFY: "spotify", + InputSource.FLICKR: "flickr", + InputSource.IRADIO: "iradio", + InputSource.SERVER: "server", + InputSource.FAVORITES: "favorites", + InputSource.LASTFM: "lastfm", + InputSource.XM: "xm", + InputSource.SIRIUS: "sirius", + InputSource.HDRADIO: "hdradio", + InputSource.DAB: "dab", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: DenonRS232ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Denon RS-232 media player.""" + receiver = config_entry.runtime_data + entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")] + + if receiver.zone_2.power is not None: + entities.append( + DenonRS232MediaPlayer(receiver, receiver.zone_2, config_entry, "zone_2") + ) + if receiver.zone_3.power is not None: + entities.append( + DenonRS232MediaPlayer(receiver, receiver.zone_3, config_entry, "zone_3") + ) + + async_add_entities(entities) + + +class DenonRS232MediaPlayer(MediaPlayerEntity): + """Representation of a Denon receiver controlled over RS-232.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_has_entity_name = True + _attr_translation_key = "receiver" + _attr_should_poll = False + + _volume_min = MIN_VOLUME_DB + _volume_range = VOLUME_DB_RANGE + + def __init__( + self, + receiver: DenonReceiver, + player: MainPlayer | ZonePlayer, + config_entry: DenonRS232ConfigEntry, + zone: Literal["main", "zone_2", "zone_3"], + ) -> None: + """Initialize the media player.""" + self._receiver = receiver + self._player = player + self._is_main = zone == "main" + + model = receiver.model + assert model is not None # We always set this + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Denon", + model_id=config_entry.data.get(CONF_MODEL_NAME), + ) + self._attr_unique_id = f"{config_entry.entry_id}_{zone}" + + self._attr_source_list = sorted( + INPUT_SOURCE_DENON_TO_HA[source] for source in model.input_sources + ) + self._attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + + if zone == "main": + self._attr_name = None + self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + else: + self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3" + + self._async_update_from_player() + + async def async_added_to_hass(self) -> None: + """Subscribe to receiver state updates.""" + self.async_on_remove(self._receiver.subscribe(self._async_on_state_update)) + + @callback + def _async_on_state_update(self, state: ReceiverState | None) -> None: + """Handle a state update from the receiver.""" + if state is None: + self._attr_available = False + else: + self._attr_available = True + self._async_update_from_player() + self.async_write_ha_state() + + @callback + def _async_update_from_player(self) -> None: + """Update entity attributes from the shared player object.""" + if self._player.power is None: + self._attr_state = None + else: + self._attr_state = ( + MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF + ) + + source = self._player.input_source + self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None + + volume_min = self._player.volume_min + volume_max = self._player.volume_max + if volume_min is not None: + self._volume_min = volume_min + + if volume_max is not None and volume_max > volume_min: + self._volume_range = volume_max - volume_min + + volume = self._player.volume + if volume is not None: + self._attr_volume_level = (volume - self._volume_min) / self._volume_range + else: + self._attr_volume_level = None + + if self._is_main: + self._attr_is_volume_muted = cast(MainPlayer, self._player).mute + + async def async_turn_on(self) -> None: + """Turn the receiver on.""" + await self._player.power_on() + + async def async_turn_off(self) -> None: + """Turn the receiver off.""" + await self._player.power_standby() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + db = volume * self._volume_range + self._volume_min + await self._player.set_volume(db) + + async def async_volume_up(self) -> None: + """Volume up.""" + await self._player.volume_up() + + async def async_volume_down(self) -> None: + """Volume down.""" + await self._player.volume_down() + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute.""" + player = cast(MainPlayer, self._player) + if mute: + await player.mute_on() + else: + await player.mute_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + input_source = next( + ( + input_source + for input_source, ha_source in INPUT_SOURCE_DENON_TO_HA.items() + if ha_source == source + ), + None, + ) + if input_source is None: + raise HomeAssistantError("Invalid source") + + await self._player.select_input_source(input_source) diff --git a/homeassistant/components/denon_rs232/quality_scale.yaml b/homeassistant/components/denon_rs232/quality_scale.yaml new file mode 100644 index 00000000000..e7b4993cd67 --- /dev/null +++ b/homeassistant/components/denon_rs232/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: "The integration does not create dynamic devices." + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: "The integration does not create devices that can become stale." + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/denon_rs232/strings.json b/homeassistant/components/denon_rs232/strings.json new file mode 100644 index 00000000000..2ed91a0fb29 --- /dev/null +++ b/homeassistant/components/denon_rs232/strings.json @@ -0,0 +1,84 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "device": "[%key:common::config_flow::data::port%]", + "model": "Receiver model" + }, + "data_description": { + "device": "Serial port path to connect to", + "model": "Determines available features" + } + } + } + }, + "entity": { + "media_player": { + "receiver": { + "state_attributes": { + "source": { + "state": { + "aux1": "Aux 1", + "aux2": "Aux 2", + "bd": "BD Player", + "bt": "Bluetooth", + "cd": "CD", + "cdr_tape1": "CDR/Tape 1", + "dab": "DAB", + "dbs_sat": "DBS/Sat", + "dock": "Dock", + "dvd": "DVD", + "dvr": "DVR", + "eight_k": "8K", + "favorites": "Favorites", + "flickr": "Flickr", + "game": "Game", + "hdp": "HDP", + "hdradio": "HD Radio", + "ipod": "iPod", + "iradio": "Internet Radio", + "lastfm": "Last.fm", + "md_tape2": "MD/Tape 2", + "mplay": "Media Player", + "net": "HEOS Music", + "net_usb": "Network/USB", + "pandora": "Pandora", + "phono": "Phono", + "sat": "Sat", + "sat_cbl": "Satellite/Cable", + "server": "Server", + "sirius": "Sirius", + "siriusxm": "SiriusXM", + "spotify": "Spotify", + "tuner": "Tuner", + "tv": "TV Audio", + "tv_cbl": "TV/Cable", + "usb_ipod": "USB/iPod", + "v_aux": "V. Aux", + "vcr_1": "VCR 1", + "vcr_2": "VCR 2", + "vcr_3": "VCR 3", + "vdp": "VDP", + "xm": "XM" + } + } + } + } + } + }, + "selector": { + "model": { + "options": { + "other": "Other" + } + } + } +} diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 196c894e8c0..b6e5b2b2376 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Denon AVR receivers using their HTTP interface.""" -from __future__ import annotations - import logging from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 0df9872a669..cf697b993ce 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -1,7 +1,5 @@ """Support for Denon AVR receivers using their HTTP interface.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta from functools import wraps @@ -22,6 +20,8 @@ from denonavr.const import ( from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, + AvrIncompleteResponseError, + AvrInvalidResponseError, AvrNetworkError, AvrProcessingError, AvrTimoutError, @@ -193,6 +193,17 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R]( self._receiver.host, ) self._attr_available = False + except AvrInvalidResponseError, AvrIncompleteResponseError: + available = False + if self.available: + _LOGGER.warning( + ( + "Denon AVR receiver at host %s returned malformed response. " + "Device is unavailable" + ), + self._receiver.host, + ) + self._attr_available = False except AvrCommandError as err: available = False _LOGGER.error( @@ -254,13 +265,15 @@ class DenonDevice(MediaPlayerEntity): def _telnet_callback(self, zone: str, event: str, parameter: str) -> None: """Process a telnet command callback.""" - # There are multiple checks implemented which reduce unnecessary updates of the ha state machine + # There are multiple checks implemented which reduce + # unnecessary updates of the ha state machine if zone not in (self._receiver.zone, ALL_ZONES): return if event not in TELNET_EVENTS: return - # Some updates trigger multiple events like one for artist and one for title for one change - # We skip every event except the last one + # Some updates trigger multiple events like one for + # artist and one for title for one change. + # We skip every event except the last one. if event == "NSE" and not parameter.startswith("4"): return if event == "TA" and not parameter.startswith("ANNAME"): @@ -392,66 +405,79 @@ class DenonDevice(MediaPlayerEntity): """Status of DynamicEQ.""" return self._receiver.dynamic_eq + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_play_pause(self) -> None: """Play or pause the media player.""" await self._receiver.async_toggle_play_pause() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_play(self) -> None: """Send play command.""" await self._receiver.async_play() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_pause(self) -> None: """Send pause command.""" await self._receiver.async_pause() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_stop(self) -> None: """Send stop command.""" await self._receiver.async_stop() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_previous_track(self) -> None: """Send previous track command.""" await self._receiver.async_previous_track() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_media_next_track(self) -> None: """Send next track command.""" await self._receiver.async_next_track() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_select_source(self, source: str) -> None: """Select input source.""" await self._receiver.async_set_input_func(source) + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" await self._receiver.async_set_sound_mode(sound_mode) + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_turn_on(self) -> None: """Turn on media player.""" await self._receiver.async_power_on() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_turn_off(self) -> None: """Turn off media player.""" await self._receiver.async_power_off() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_volume_up(self) -> None: """Volume up the media player.""" await self._receiver.async_volume_up() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_volume_down(self) -> None: """Volume down media player.""" await self._receiver.async_volume_down() + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" @@ -462,6 +488,7 @@ class DenonDevice(MediaPlayerEntity): volume_denon = float(18) await self._receiver.async_set_volume(volume_denon) + # pylint: disable-next=home-assistant-action-swallowed-exception @async_log_errors async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index cbafe35cfc5..da606f65c86 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -1,7 +1,5 @@ """Code to handle a DenonAVR receiver.""" -from __future__ import annotations - from collections.abc import Callable import contextlib import logging diff --git a/homeassistant/components/denonavr/services.py b/homeassistant/components/denonavr/services.py index 0c4523fb98b..126d16c92b1 100644 --- a/homeassistant/components/denonavr/services.py +++ b/homeassistant/components/denonavr/services.py @@ -1,7 +1,5 @@ """Support for Denon AVR receivers using their HTTP interface.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 4639a6cb5e5..a3bd88a38a2 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -1,7 +1,5 @@ """The Derivative integration.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry @@ -65,7 +63,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_options = {**config_entry.options} if new_options.get("unit_prefix") == "none": - # Before we had support for optional selectors, "none" was used for selecting nothing + # Before we had support for optional selectors, + # "none" was used for selecting nothing del new_options["unit_prefix"] hass.config_entries.async_update_entry( diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index f9014681088..2de732540bb 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Derivative integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/derivative/diagnostics.py b/homeassistant/components/derivative/diagnostics.py index 4f5496d72fe..27b66b103a3 100644 --- a/homeassistant/components/derivative/diagnostics.py +++ b/homeassistant/components/derivative/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for derivative.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 8515b54295a..7f3fb925df4 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -1,7 +1,5 @@ """Numeric derivative of data coming from a source sensor over time.""" -from __future__ import annotations - from datetime import datetime, timedelta from decimal import Decimal, DecimalException, InvalidOperation import logging @@ -187,6 +185,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _attr_translation_key = "derivative" _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, @@ -276,7 +275,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if original_unit != self._attr_native_unit_of_measurement: _LOGGER.debug( - "%s: Derivative sensor switched UoM from %s to %s, resetting state to 0", + "%s: Derivative sensor switched UoM from" + " %s to %s, resetting state to 0", self.entity_id, original_unit, self._attr_native_unit_of_measurement, @@ -328,7 +328,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): ) def _handle_invalid_source_state(self, state: State | None) -> bool: - # Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false. + # Check the source state for unknown/unavailable condition. + # If unusable, write unknown/unavailable state and return false. if not state or state.state == STATE_UNAVAILABLE: self._attr_available = False self.async_write_ha_state() @@ -377,10 +378,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): def schedule_max_sub_interval_exceeded(source_state: State | None) -> None: """Schedule calculation using the source state and max_sub_interval. - The callback reference is stored for possible cancellation if the source state - reports a change before max_sub_interval has passed. - If the callback is executed, meaning there was no state change reported, the - source_state is assumed constant and calculation is done using its value. + The callback reference is stored for possible + cancellation if the source state reports a change + before max_sub_interval has passed. + If the callback is executed, meaning there was no + state change reported, the source_state is assumed + constant and calculation is done using its value. """ if ( self._max_sub_interval is not None @@ -395,14 +398,17 @@ class DerivativeSensor(RestoreSensor, SensorEntity): """Calculate derivative based on time and reschedule.""" _LOGGER.debug( - "%s: Recalculating derivative due to max_sub_interval time elapsed", + "%s: Recalculating derivative due to" + " max_sub_interval time elapsed", self.entity_id, ) self._prune_state_list(now) derivative = self._calc_derivative_from_state_list(now) self._write_native_value(derivative) - # If derivative is now zero, don't schedule another timeout callback, as it will have no effect + # If derivative is now zero, don't schedule + # another timeout callback, as it will have + # no effect if derivative != 0: schedule_max_sub_interval_exceeded(source_state) @@ -484,7 +490,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): old_value = self._last_valid_state_time[0] old_timestamp = self._last_valid_state_time[1] else: - # Sensor becomes valid for the first time, just keep the restored value + # Sensor becomes valid for the first time, + # just keep the restored value self.async_write_ha_state() return @@ -526,7 +533,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "%s: Could not calculate derivative: %s", self.entity_id, err ) - # For total inreasing sensors, the value is expected to continuously increase. + # For total increasing sensors, the value is + # expected to continuously increase. # A negative derivative for a total increasing sensor likely indicates the # sensor has been reset. To prevent inaccurate data, discard this sample. if ( @@ -547,8 +555,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): new_timestamp, ) - # If outside of time window just report derivative (is the same as modeling it in the window), - # otherwise take the weighted average with the previous derivatives + # If outside of time window just report derivative + # (is the same as modeling it in the window), + # otherwise take the weighted average with the + # previous derivatives if elapsed_time > self._time_window: derivative = new_derivative else: diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py index be641ad58a5..718c85c0b80 100644 --- a/homeassistant/components/devialet/__init__.py +++ b/homeassistant/components/devialet/__init__.py @@ -1,7 +1,5 @@ """The Devialet integration.""" -from __future__ import annotations - from devialet import DevialetApi from homeassistant.const import CONF_HOST, Platform diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py index 45a00fc4073..4a4befb6ba4 100644 --- a/homeassistant/components/devialet/config_flow.py +++ b/homeassistant/components/devialet/config_flow.py @@ -1,7 +1,5 @@ """Support for Devialet Phantom speakers.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py index 75d6e7aa222..252b546d5df 100644 --- a/homeassistant/components/devialet/diagnostics.py +++ b/homeassistant/components/devialet/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Devialet.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index bcc4ce8548f..f6526076fac 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -1,7 +1,5 @@ """Support for Devialet speakers.""" -from __future__ import annotations - from devialet.const import NORMAL_INPUTS from homeassistant.components.media_player import ( diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 537ddc35c5a..24872752d7d 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -1,7 +1,5 @@ """Helpers for device automations.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from dataclasses import dataclass @@ -164,7 +162,8 @@ async def async_get_device_automation_platform( ) -> DeviceAutomationPlatformType: """Load device automation platform for integration. - Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. + Throws InvalidDeviceAutomationConfig if the integration is not found + or does not support device automation. """ platform_name = automation_type.value.section try: diff --git a/homeassistant/components/device_automation/action.py b/homeassistant/components/device_automation/action.py index b1c63ac439b..b9535d392ef 100644 --- a/homeassistant/components/device_automation/action.py +++ b/homeassistant/components/device_automation/action.py @@ -1,7 +1,5 @@ """Device action validator.""" -from __future__ import annotations - from typing import Any, Protocol import voluptuous as vol diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index dde1ee7bfe0..314ae8722a5 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -1,7 +1,5 @@ """Validate device conditions.""" -from __future__ import annotations - from typing import Any, Protocol import voluptuous as vol @@ -11,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckerType, ConditionConfig, ) @@ -54,6 +51,7 @@ class DeviceCondition(Condition): """Device condition.""" _config: ConfigType + _platform_checker: ConditionCheckerType @classmethod async def async_validate_complete_config( @@ -87,20 +85,19 @@ class DeviceCondition(Condition): assert config.options is not None self._config = config.options - async def async_get_checker(self) -> ConditionChecker: - """Test a device condition.""" + async def _async_setup(self) -> None: + """Set up a device condition.""" platform = await async_get_device_automation_platform( self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - platform_checker = platform.async_condition_from_config( + self._platform_checker = platform.async_condition_from_config( self._hass, self._config ) - def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool: - result = platform_checker(self._hass, variables) - return result is not False - - return checker + def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool: + """Check the condition.""" + result = self._platform_checker(self._hass, variables) + return result is not False CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/device_automation/entity.py b/homeassistant/components/device_automation/entity.py index aaa14dbf9b0..a3886ad2d9c 100644 --- a/homeassistant/components/device_automation/entity.py +++ b/homeassistant/components/device_automation/entity.py @@ -1,7 +1,5 @@ """Device automation helpers for entity.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.homeassistant.triggers import state as state_trigger diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 0d935444a59..b2fa4bbb06f 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -1,7 +1,5 @@ """Helpers for device oriented automations.""" -from __future__ import annotations - from typing import cast import voluptuous as vol @@ -82,7 +80,7 @@ async def async_validate_device_automation_config( # the checks below which look for a config entry matching the device automation # domain if ( - automation_type == DeviceAutomationType.ACTION + automation_type is DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): # Pass the unvalidated config to avoid mutating the raw config twice diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index d2220836226..a7b8ab2fa12 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -1,7 +1,5 @@ """Device automation helpers for toggle entity.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.homeassistant.triggers import state as state_trigger diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index 071b8236086..f297cb47fd0 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -1,7 +1,5 @@ """Offer device oriented automation.""" -from __future__ import annotations - from typing import Any, Protocol import voluptuous as vol diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 3ffb48596c7..8f1489256a6 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,20 +1,16 @@ """Provide functionality to keep track of devices.""" -from __future__ import annotations +import asyncio +from typing import Any -from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey -from .config_entry import ( # noqa: F401 - ScannerEntity, - ScannerEntityDescription, - TrackerEntity, - TrackerEntityDescription, - async_setup_entry, - async_unload_entry, -) from .const import ( # noqa: F401 ATTR_ATTRIBUTES, ATTR_BATTERY, @@ -26,6 +22,8 @@ from .const import ( # noqa: F401 ATTR_LOCATION_NAME, ATTR_MAC, ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, + CONF_ASSOCIATED_ZONE, CONF_CONSIDER_HOME, CONF_NEW_DEVICE_DEFAULTS, CONF_SCAN_INTERVAL, @@ -35,8 +33,19 @@ from .const import ( # noqa: F401 DEFAULT_TRACK_NEW, DOMAIN, ENTITY_ID_FORMAT, + LOGGER, + PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, SourceType, + TrackingType, +) +from .entity import ( # noqa: F401 + BaseScannerEntity, + BaseTrackerEntity, + ScannerEntity, + ScannerEntityDescription, + TrackerEntity, + TrackerEntityDescription, ) from .legacy import ( # noqa: F401 PLATFORM_SCHEMA, @@ -46,13 +55,16 @@ from .legacy import ( # noqa: F401 SOURCE_TYPES, AsyncSeeCallback, DeviceScanner, + DeviceTracker, SeeCallback, + async_create_platform_type, async_setup_integration as async_setup_legacy_integration, see, ) +DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN) + -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return the state if any or a specified device is home.""" return hass.states.is_state(entity_id, STATE_HOME) @@ -60,5 +72,63 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the device tracker.""" - async_setup_legacy_integration(hass, config) + component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity]( + LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + component.config = {} + component.register_shutdown() + + # The tracker is loaded in the async_setup_legacy_integration task so + # we create a future to avoid waiting on it here so that only + # async_platform_discovered will have to wait in the rare event + # a custom component still uses the legacy device tracker discovery. + tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future() + + async def async_platform_discovered( + p_type: str, info: dict[str, Any] | None + ) -> None: + """Load a platform.""" + platform = await async_create_platform_type(hass, config, p_type, {}) + + if platform is None: + return + + if platform.type != PLATFORM_TYPE_LEGACY: + await component.async_setup_platform(p_type, {}, info) + return + + tracker = await tracker_future + await platform.async_setup_legacy(hass, tracker, info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + # + # Legacy and platforms load in a non-awaited tracked task + # to ensure device tracker setup can continue and config + # entry integrations are not waiting for legacy device + # tracker platforms to be set up. + # + hass.async_create_task( + async_setup_legacy_integration(hass, config, tracker_future), + eager_start=True, + ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up an entry.""" + component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN) + + if component is not None: + return await component.async_setup_entry(entry) + + component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity]( + LOGGER, DOMAIN, hass + ) + component.register_shutdown() + + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload an entry.""" + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) diff --git a/homeassistant/components/device_tracker/condition.py b/homeassistant/components/device_tracker/condition.py deleted file mode 100644 index 1593f93f21a..00000000000 --- a/homeassistant/components/device_tracker/condition.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Provides conditions for device trackers.""" - -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import Condition, make_entity_state_condition - -from .const import DOMAIN - -CONDITIONS: dict[str, type[Condition]] = { - "is_home": make_entity_state_condition(DOMAIN, STATE_HOME), - "is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME), -} - - -async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the conditions for device trackers.""" - return CONDITIONS diff --git a/homeassistant/components/device_tracker/conditions.yaml b/homeassistant/components/device_tracker/conditions.yaml deleted file mode 100644 index 0f92b51c20d..00000000000 --- a/homeassistant/components/device_tracker/conditions.yaml +++ /dev/null @@ -1,17 +0,0 @@ -.condition_common: &condition_common - target: - entity: - domain: device_tracker - fields: - behavior: - required: true - default: any - selector: - select: - translation_key: condition_behavior - options: - - all - - any - -is_home: *condition_common -is_not_home: *condition_common diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 15beb879ae4..689482bb006 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -1,460 +1,45 @@ """Code to set up a device tracker platform using a config entry.""" -from __future__ import annotations +from functools import partial -import asyncio -from typing import Any, final - -from propcache.api import cached_property - -from homeassistant.components import zone -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, - STATE_HOME, - STATE_NOT_HOME, - EntityCategory, -) -from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import ( - DeviceInfo, - EventDeviceRegistryUpdatedData, -) -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform -from homeassistant.helpers.typing import StateType -from homeassistant.util.hass_dict import HassKey - -from .const import ( - ATTR_HOST_NAME, - ATTR_IN_ZONES, - ATTR_IP, - ATTR_MAC, - ATTR_SOURCE_TYPE, - CONNECTED_DEVICE_REGISTERED, - DOMAIN, - LOGGER, - SourceType, +from homeassistant.helpers.deprecation import ( + DeprecatedAlias, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, ) -DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN) -DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac") - -# mypy: disallow-any-generics - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up an entry.""" - component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN) - - if component is not None: - return await component.async_setup_entry(entry) - - component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity]( - LOGGER, DOMAIN, hass - ) - component.register_shutdown() - - return await component.async_setup_entry(entry) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload an entry.""" - return await hass.data[DATA_COMPONENT].async_unload_entry(entry) - - -@callback -def _async_connected_device_registered( - hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None -) -> None: - """Register a newly seen connected device. - - This is currently used by the dhcp integration - to listen for newly registered connected devices - for discovery. - """ - async_dispatcher_send( - hass, - CONNECTED_DEVICE_REGISTERED, - { - ATTR_IP: ip_address, - ATTR_MAC: mac, - ATTR_HOST_NAME: hostname, - }, - ) - - -@callback -def _async_register_mac( - hass: HomeAssistant, - domain: str, - mac: str, - unique_id: str, -) -> None: - """Register a mac address with a unique ID.""" - mac = dr.format_mac(mac) - if DATA_KEY in hass.data: - hass.data[DATA_KEY][mac] = (domain, unique_id) - return - - # Setup listening. - - # dict mapping mac -> partial unique ID - data = hass.data[DATA_KEY] = {mac: (domain, unique_id)} - - @callback - def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None: - """Enable the online status entity for the mac of a newly created device.""" - # Only for new devices - if ev.data["action"] != "create": - return - - dev_reg = dr.async_get(hass) - device_entry = dev_reg.async_get(ev.data["device_id"]) - - if device_entry is None: - # This should not happen, since the device was just created. - return - - # Check if device has a mac - mac = None - for conn in device_entry.connections: - if conn[0] == dr.CONNECTION_NETWORK_MAC: - mac = conn[1] - break - - if mac is None: - return - - # Check if we have an entity for this mac - if (unique_id := data.get(mac)) is None: - return - - ent_reg = er.async_get(hass) - - if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None: - return - - entity_entry = ent_reg.entities[entity_id] - - # Make sure entity has a config entry and was disabled by the - # default disable logic in the integration and new entities - # are allowed to be added. - if ( - entity_entry.config_entry_id is None - or ( - ( - config_entry := hass.config_entries.async_get_entry( - entity_entry.config_entry_id - ) - ) - is not None - and config_entry.pref_disable_new_entities - ) - or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION - ): - return - - # Enable entity - ent_reg.async_update_entity(entity_id, disabled_by=None) - - hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event) - - -class BaseTrackerEntity(Entity): - """Represent a tracked device.""" - - _attr_device_info: None = None - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_source_type: SourceType - - @cached_property - def battery_level(self) -> int | None: - """Return the battery level of the device. - - Percentage from 0-100. - """ - return None - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - if hasattr(self, "_attr_source_type"): - return self._attr_source_type - raise NotImplementedError - - @property - def state_attributes(self) -> dict[str, StateType]: - """Return the device state attributes.""" - attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} - - if self.battery_level is not None: - attr[ATTR_BATTERY_LEVEL] = self.battery_level - - return attr - - -class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True): - """A class that describes tracker entities.""" - - -CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = { - "latitude", - "location_accuracy", - "location_name", - "longitude", -} - - -class TrackerEntity( - BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_ -): - """Base class for a tracked device.""" - - entity_description: TrackerEntityDescription - _attr_latitude: float | None = None - _attr_location_accuracy: float = 0 - _attr_location_name: str | None = None - _attr_longitude: float | None = None - _attr_source_type: SourceType = SourceType.GPS - - __active_zone: State | None = None - __in_zones: list[str] | None = None - - @cached_property - def should_poll(self) -> bool: - """No polling for entities that have location pushed.""" - return False - - @property - def force_update(self) -> bool: - """All updates need to be written to the state machine if we're not polling.""" - return not self.should_poll - - @cached_property - def location_accuracy(self) -> float: - """Return the location accuracy of the device. - - Value in meters. - """ - return self._attr_location_accuracy - - @cached_property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - return self._attr_location_name - - @cached_property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self._attr_latitude - - @cached_property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self._attr_longitude - - @callback - def _async_write_ha_state(self) -> None: - """Calculate active zones.""" - if self.available and self.latitude is not None and self.longitude is not None: - self.__active_zone, self.__in_zones = zone.async_in_zones( - self.hass, self.latitude, self.longitude, self.location_accuracy - ) - else: - self.__active_zone = None - self.__in_zones = None - super()._async_write_ha_state() - - @property - def state(self) -> str | None: - """Return the state of the device.""" - if self.location_name is not None: - return self.location_name - - if self.latitude is not None and self.longitude is not None: - zone_state = self.__active_zone - if zone_state is None: - state = STATE_NOT_HOME - elif zone_state.entity_id == zone.ENTITY_ID_HOME: - state = STATE_HOME - else: - state = zone_state.name - return state - - return None - - @final - @property - def state_attributes(self) -> dict[str, Any]: - """Return the device state attributes.""" - attr: dict[str, Any] = {ATTR_IN_ZONES: []} - attr.update(super().state_attributes) - - if self.latitude is not None and self.longitude is not None: - attr[ATTR_IN_ZONES] = self.__in_zones or [] - attr[ATTR_LATITUDE] = self.latitude - attr[ATTR_LONGITUDE] = self.longitude - attr[ATTR_GPS_ACCURACY] = self.location_accuracy - - return attr - - -class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True): - """A class that describes tracker entities.""" - - -CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = { - "ip_address", - "mac_address", - "hostname", -} - - -class ScannerEntity( - BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_ -): - """Base class for a tracked device that is on a scanned network.""" - - entity_description: ScannerEntityDescription - _attr_hostname: str | None = None - _attr_ip_address: str | None = None - _attr_mac_address: str | None = None - _attr_source_type: SourceType = SourceType.ROUTER - - @cached_property - def ip_address(self) -> str | None: - """Return the primary ip address of the device.""" - return self._attr_ip_address - - @cached_property - def mac_address(self) -> str | None: - """Return the mac address of the device.""" - return self._attr_mac_address - - @cached_property - def hostname(self) -> str | None: - """Return hostname of the device.""" - return self._attr_hostname - - @property - def state(self) -> str: - """Return the state of the device.""" - if self.is_connected: - return STATE_HOME - return STATE_NOT_HOME - - @property - def is_connected(self) -> bool: - """Return true if the device is connected to the network.""" - raise NotImplementedError - - @property - def unique_id(self) -> str | None: - """Return unique ID of the entity.""" - return self.mac_address - - @final - @property - def device_info(self) -> DeviceInfo | None: - """Device tracker entities should not create device registry entries.""" - return None - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if entity is enabled by default.""" - # If mac_address is None, we can never find a device entry. - return ( - # Do not disable if we won't activate our attach to device logic - self.mac_address is None - or self.device_info is not None - # Disable if we automatically attach but there is no device - or self.find_device_entry() is not None - ) - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - if self.mac_address and self.unique_id: - _async_register_mac( - hass, - platform.platform_name, - self.mac_address, - self.unique_id, - ) - if self.is_connected and self.ip_address: - _async_connected_device_registered( - hass, - self.mac_address, - self.ip_address, - self.hostname, - ) - - @callback - def find_device_entry(self) -> dr.DeviceEntry | None: - """Return device entry.""" - assert self.mac_address is not None - - return dr.async_get(self.hass).async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)} - ) - - async def async_internal_added_to_hass(self) -> None: - """Handle added to Home Assistant.""" - # Entities without a unique ID don't have a device - if ( - not self.registry_entry - or not self.platform.config_entry - or not self.mac_address - or (device_entry := self.find_device_entry()) is None - # Entities should not have a device info. We opt them out - # of this logic if they do. - or self.device_info - ): - if self.device_info: - LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id) - await super().async_internal_added_to_hass() - return - - # Attach entry to device - if self.registry_entry.device_id != device_entry.id: - self.registry_entry = er.async_get(self.hass).async_update_entity( - self.entity_id, device_id=device_entry.id - ) - - # Attach device to config entry - if self.platform.config_entry.entry_id not in device_entry.config_entries: - dr.async_get(self.hass).async_update_device( - device_entry.id, - add_config_entry_id=self.platform.config_entry.entry_id, - ) - - # Do this last or else the entity registry update listener has been installed - await super().async_internal_added_to_hass() - - @final - @property - def state_attributes(self) -> dict[str, StateType]: - """Return the device state attributes.""" - attr = super().state_attributes - - if ip_address := self.ip_address: - attr[ATTR_IP] = ip_address - if (mac_address := self.mac_address) is not None: - attr[ATTR_MAC] = mac_address - if (hostname := self.hostname) is not None: - attr[ATTR_HOST_NAME] = hostname - - return attr +from . import ( + BaseTrackerEntity as _BaseTrackerEntity, + ScannerEntity as _ScannerEntity, + SourceType as _SourceType, + TrackerEntity as _TrackerEntity, + TrackerEntityDescription as _TrackerEntityDescription, +) + +_DEPRECATED_TrackerEntity = DeprecatedAlias( + _TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6" +) +_DEPRECATED_ScannerEntity = DeprecatedAlias( + _ScannerEntity, "homeassistant.components.device_tracker.ScannerEntity", "2027.6" +) +_DEPRECATED_BaseTrackerEntity = DeprecatedAlias( + _BaseTrackerEntity, + "homeassistant.components.device_tracker.BaseTrackerEntity", + "2027.6", +) +_DEPRECATED_TrackerEntityDescription = DeprecatedAlias( + _TrackerEntityDescription, + "homeassistant.components.device_tracker.TrackerEntityDescription", + "2027.6", +) +_DEPRECATED_SourceType = DeprecatedAlias( + _SourceType, "homeassistant.components.device_tracker.SourceType", "2027.6" +) + +# These can be removed if no deprecated aliases are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 87b8f7d2cbf..2f9e496e2a4 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -1,7 +1,5 @@ """Device tracker constants.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum import logging @@ -27,6 +25,18 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +class TrackingType(StrEnum): + """Tracking type for device trackers. + + Describes how the tracker determines presence: by the device's geographic + position (e.g. GPS) or by its connection to a known endpoint (e.g. a router + or beacon associated with a zone). + """ + + CONNECTION = "connection" + POSITION = "position" + + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) @@ -38,6 +48,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180) CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults" +CONF_ASSOCIATED_ZONE: Final = "associated_zone" + ATTR_ATTRIBUTES: Final = "attributes" ATTR_BATTERY: Final = "battery" ATTR_DEV_ID: Final = "dev_id" @@ -47,6 +59,7 @@ ATTR_IN_ZONES: Final = "in_zones" ATTR_LOCATION_NAME: Final = "location_name" ATTR_MAC: Final = "mac" ATTR_SOURCE_TYPE: Final = "source_type" +ATTR_TRACKING_TYPE: Final = "tracking_type" ATTR_CONSIDER_HOME: Final = "consider_home" ATTR_IP: Final = "ip" diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 2d6d723dc49..d7cf67404fd 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -1,7 +1,5 @@ """Provides device automations for Device tracker.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index cb299236438..8e9c1ddbad8 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Device Tracker.""" -from __future__ import annotations - from operator import attrgetter from typing import Final @@ -14,13 +12,19 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, CONF_EVENT, + CONF_OPTIONS, CONF_PLATFORM, CONF_TYPE, CONF_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + # protected, but only used for legacy triggers + _async_attach_trigger_cls, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -81,16 +85,18 @@ async def async_attach_trigger( event = zone.EVENT_ENTER else: event = zone.EVENT_LEAVE - - zone_config = { - CONF_PLATFORM: ZONE_DOMAIN, - CONF_ENTITY_ID: config[CONF_ENTITY_ID], - CONF_ZONE: config[CONF_ZONE], - CONF_EVENT: event, - } - zone_config = await zone.async_validate_trigger_config(hass, zone_config) - return await zone.async_attach_trigger( - hass, zone_config, action, trigger_info, platform_type="device" + zone_config = await zone.LegacyZoneTrigger.async_validate_config( + hass, + { + CONF_OPTIONS: { + CONF_ENTITY_ID: [config[CONF_ENTITY_ID]], + CONF_ZONE: config[CONF_ZONE], + CONF_EVENT: event, + } + }, + ) + return await _async_attach_trigger_cls( + hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info ) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py new file mode 100644 index 00000000000..7a669775b0f --- /dev/null +++ b/homeassistant/components/device_tracker/entity.py @@ -0,0 +1,711 @@ +"""Provide functionality to keep track of devices.""" + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, final + +from propcache.api import cached_property + +from homeassistant.components import zone +from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + EntityCategory, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + State, + async_get_hass_or_none, + callback, +) +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.device_registry import ( + DeviceInfo, + EventDeviceRegistryUpdatedData, +) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.loader import async_suggest_report_issue +from homeassistant.util.hass_dict import HassKey + +from .const import ( + ATTR_HOST_NAME, + ATTR_IN_ZONES, + ATTR_IP, + ATTR_MAC, + ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, + CONF_ASSOCIATED_ZONE, + CONNECTED_DEVICE_REGISTERED, + DOMAIN, + LOGGER, + SourceType, + TrackingType, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac") + + +@callback +def _async_connected_device_registered( + hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None +) -> None: + """Register a newly seen connected device. + + This is currently used by the dhcp integration + to listen for newly registered connected devices + for discovery. + """ + async_dispatcher_send( + hass, + CONNECTED_DEVICE_REGISTERED, + { + ATTR_IP: ip_address, + ATTR_MAC: mac, + ATTR_HOST_NAME: hostname, + }, + ) + + +@callback +def _async_register_mac( + hass: HomeAssistant, + domain: str, + mac: str, + unique_id: str, +) -> None: + """Register a mac address with a unique ID.""" + mac = dr.format_mac(mac) + if DATA_KEY in hass.data: + hass.data[DATA_KEY][mac] = (domain, unique_id) + return + + # Setup listening. + + # dict mapping mac -> partial unique ID + data = hass.data[DATA_KEY] = {mac: (domain, unique_id)} + + @callback + def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None: + """Enable the online status entity for the mac of a newly created device.""" + # Only for new devices + if ev.data["action"] != "create": + return + + dev_reg = dr.async_get(hass) + device_entry = dev_reg.async_get(ev.data["device_id"]) + + if device_entry is None: + # This should not happen, since the device was just created. + return + + # Check if device has a mac + mac = None + for conn in device_entry.connections: + if conn[0] == dr.CONNECTION_NETWORK_MAC: + mac = conn[1] + break + + if mac is None: + return + + # Check if we have an entity for this mac + if (unique_id := data.get(mac)) is None: + return + + ent_reg = er.async_get(hass) + + if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None: + return + + entity_entry = ent_reg.entities[entity_id] + + # Make sure entity has a config entry and was disabled by the + # default disable logic in the integration and new entities + # are allowed to be added. + if ( + entity_entry.config_entry_id is None + or ( + ( + config_entry := hass.config_entries.async_get_entry( + entity_entry.config_entry_id + ) + ) + is not None + and config_entry.pref_disable_new_entities + ) + or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION + ): + return + + # Enable entity + ent_reg.async_update_entity(entity_id, disabled_by=None) + + hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event) + + +class BaseTrackerEntity(Entity): + """Represent a tracked device. + + Not intended to be directly inherited by integrations. Integrations should + inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead. + """ + + _attr_device_info: None = None + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_source_type: SourceType + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if "battery_level" in cls.__dict__: + if cls.__module__.startswith("homeassistant.components."): + # Don't ask users to report issue for built in integrations, + # they already have issues opened on them. + return + report_issue = async_suggest_report_issue( + async_get_hass_or_none(), module=cls.__module__ + ) + _LOGGER.warning( + ( + "%s::%s is overriding the deprecated battery_level property on " + "a subclass of BaseTrackerEntity, this will be unsupported from " + "Home Assistant 2027.7, please %s" + ), + cls.__module__, + cls.__name__, + report_issue, + ) + + @cached_property + def battery_level(self) -> int | None: + """Return the battery level of the device. + + Percentage from 0-100. + + The property is deprecated and will be removed in Home Assistant 2027.7. + """ + return None + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + if hasattr(self, "_attr_source_type"): + return self._attr_source_type + raise NotImplementedError + + @property + def state_attributes(self) -> dict[str, Any]: + """Return the device state attributes.""" + attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type} + + if self.battery_level is not None: + attr[ATTR_BATTERY_LEVEL] = self.battery_level + + return attr + + +class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes tracker entities.""" + + +CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = { + "in_zones", + "latitude", + "location_accuracy", + "location_name", + "longitude", +} + + +class TrackerEntity( + BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_ +): + """Base class for a tracked device.""" + + entity_description: TrackerEntityDescription + _attr_capability_attributes: dict[str, Any] = { + ATTR_TRACKING_TYPE: TrackingType.POSITION + } + _attr_in_zones: list[str] | None = None + _attr_latitude: float | None = None + _attr_location_accuracy: float = 0 + # _attr_location_name is deprecated and will be removed in Home Assistant 2027.7 + _attr_location_name: str | None = None + _attr_longitude: float | None = None + _attr_source_type: SourceType = SourceType.GPS + + __active_zone: State | None = None + # If we reported setting deprecated _attr_location_name + __deprecated_attr_location_name_reported = False + __in_zones: list[str] | None = None + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if "location_name" in cls.__dict__: + if cls.__module__.startswith("homeassistant.components."): + # Don't ask users to report issue for built in integrations, + # they already have issues opened on them. + return + report_issue = async_suggest_report_issue( + async_get_hass_or_none(), module=cls.__module__ + ) + _LOGGER.warning( + ( + "%s::%s is overriding the deprecated location_name property on " + "an instance of TrackerEntity, this will be unsupported from " + "Home Assistant 2027.7, please %s" + ), + cls.__module__, + cls.__name__, + report_issue, + ) + + @cached_property + def should_poll(self) -> bool: + """No polling for entities that have location pushed.""" + return False + + @property + def force_update(self) -> bool: + """All updates need to be written to the state machine if we're not polling.""" + return not self.should_poll + + @cached_property + def in_zones(self) -> list[str] | None: + """Return the entity_id of zones the device is currently in. + + The list may be in any order; the base class sorts it by zone radius + and discards zones which do not exist. Takes precedence over latitude + and longitude when set (including when set to an empty list). + """ + return self._attr_in_zones + + @cached_property + def location_accuracy(self) -> float: + """Return the location accuracy of the device. + + Value in meters. + """ + return self._attr_location_accuracy + + @cached_property + def location_name(self) -> str | None: + """Return a location name for the current location of the device. + + The property is deprecated and will be removed in Home Assistant 2027.7. + """ + if (location_name := self._attr_location_name) is not None: + if ( + not self.__deprecated_attr_location_name_reported + and not self.__class__.__module__.startswith( + "homeassistant.components." + ) + ): + report_issue = async_suggest_report_issue( + self.hass, module=self.__class__.__module__ + ) + _LOGGER.warning( + ( + "%s::%s is setting the deprecated _attr_location_name attribute " + "on an instance of TrackerEntity, this will be unsupported from " + "Home Assistant 2027.7, please %s" + ), + self.__class__.__module__, + self.__class__.__name__, + report_issue, + ) + self.__deprecated_attr_location_name_reported = True + return location_name + return self._attr_location_name + + @cached_property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._attr_latitude + + @cached_property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._attr_longitude + + @callback + def _async_write_ha_state(self) -> None: + """Calculate active zones.""" + if (zones := self.in_zones) is not None: + zone_states = sorted( + ( + zone_state + for entity_id in zones + if (zone_state := self.hass.states.get(entity_id)) is not None + ), + key=lambda z: z.attributes[ATTR_RADIUS], + ) + self.__active_zone = next( + (z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)), + None, + ) + self.__in_zones = [z.entity_id for z in zone_states] + elif ( + self.available and self.latitude is not None and self.longitude is not None + ): + self.__active_zone, self.__in_zones = zone.async_in_zones( + self.hass, self.latitude, self.longitude, self.location_accuracy + ) + else: + self.__active_zone = None + self.__in_zones = None + super()._async_write_ha_state() + + @property + def state(self) -> str | None: + """Return the state of the device.""" + if self.location_name is not None: + return self.location_name + + if ( + self.latitude is not None and self.longitude is not None + ) or self.__in_zones is not None: + zone_state = self.__active_zone + if zone_state is None: + state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + state = STATE_HOME + else: + state = zone_state.name + return state + + return None + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the device state attributes.""" + attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []} + attr.update(super().state_attributes) + + if self.latitude is not None and self.longitude is not None: + attr[ATTR_LATITUDE] = self.latitude + attr[ATTR_LONGITUDE] = self.longitude + attr[ATTR_GPS_ACCURACY] = self.location_accuracy + + return attr + + +class BaseScannerEntity(BaseTrackerEntity): + """Base class for a tracked device that can be connected or disconnected. + + Unlike ScannerEntity, this entity does not make assumptions about MAC + addresses being used to identify the device. + """ + + _attr_capability_attributes: dict[str, Any] = { + ATTR_TRACKING_TYPE: TrackingType.CONNECTION + } + _scanner_option_associated_zone: str = zone.ENTITY_ID_HOME + _scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None + + async def async_internal_added_to_hass(self) -> None: + """Call when the scanner entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + async def async_internal_will_remove_from_hass(self) -> None: + """Call when the scanner entity is about to be removed from hass.""" + await super().async_internal_will_remove_from_hass() + if not self.registry_entry: + return + if self._scanner_option_associated_zone_unsub is not None: + self._scanner_option_associated_zone_unsub() + self._scanner_option_associated_zone_unsub = None + self._async_clear_associated_zone_issue() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from the entity registry. + + Called when the entity registry entry has been updated and before the + scanner entity is added to the state machine. + """ + assert self.registry_entry + if (scanner_options := self.registry_entry.options.get(DOMAIN)) and ( + associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE) + ): + new_zone = associated_zone + else: + new_zone = zone.ENTITY_ID_HOME + + if new_zone == self._scanner_option_associated_zone: + return + + # Tear down tracking for the previous zone. + if self._scanner_option_associated_zone_unsub is not None: + self._scanner_option_associated_zone_unsub() + self._scanner_option_associated_zone_unsub = None + self._async_clear_associated_zone_issue() + + self._scanner_option_associated_zone = new_zone + + # zone.home is always present so no tracking or issue handling needed. + if new_zone == zone.ENTITY_ID_HOME: + return + + self._scanner_option_associated_zone_unsub = async_track_state_change_event( + self.hass, new_zone, self._async_associated_zone_state_changed + ) + if self.hass.states.get(new_zone) is None: + self._async_create_associated_zone_issue() + + @callback + def _async_associated_zone_state_changed( + self, event: Event[EventStateChangedData] + ) -> None: + """Open or clear the repair issue when the associated zone appears or disappears.""" + if event.data["new_state"] is None: + self._async_create_associated_zone_issue() + else: + self._async_clear_associated_zone_issue() + self.async_write_ha_state() + + @callback + def _async_create_associated_zone_issue(self) -> None: + """Create a repair issue prompting the user to reconfigure the scanner.""" + ir.async_create_issue( + self.hass, + DOMAIN, + self._associated_zone_issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="associated_zone_missing", + translation_placeholders={ + "entity_id": self.entity_id, + "zone": self._scanner_option_associated_zone, + }, + ) + + @callback + def _async_clear_associated_zone_issue(self) -> None: + """Clear the associated-zone-missing repair issue if it exists.""" + ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id) + + @property + def _associated_zone_issue_id(self) -> str: + """Return the issue id for the associated-zone-missing repair.""" + if TYPE_CHECKING: + assert self.registry_entry + return f"associated_zone_missing_{self.registry_entry.id}" + + @property + def state(self) -> str | None: + """Return the state of the device.""" + if self.is_connected is None: + return None + if not self.is_connected: + return STATE_NOT_HOME + associated_zone = self._scanner_option_associated_zone + if associated_zone == zone.ENTITY_ID_HOME: + return STATE_HOME + if zone_state := self.hass.states.get(associated_zone): + return zone_state.name + # Configured zone has been removed; state is unknown. + return None + + @property + def is_connected(self) -> bool | None: + """Return true if the device is connected.""" + raise NotImplementedError + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the device state attributes.""" + attr: dict[str, Any] = {ATTR_IN_ZONES: []} + attr.update(super().state_attributes) + + if not self.is_connected: + return attr + + associated_zone = self._scanner_option_associated_zone + # If the configured zone has been removed, in_zones stays empty so the + # attribute does not claim membership in a zone that no longer exists. + if ( + associated_zone != zone.ENTITY_ID_HOME + and self.hass.states.get(associated_zone) is None + ): + return attr + + attr[ATTR_IN_ZONES] = [ + associated_zone, + *zone.async_get_enclosing_zones(self.hass, associated_zone), + ] + + return attr + + +class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes tracker entities.""" + + +CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = { + "ip_address", + "mac_address", + "hostname", +} + + +class ScannerEntity( + BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_ +): + """Base class for a tracked device that is on a scanned network.""" + + entity_description: ScannerEntityDescription + _attr_hostname: str | None = None + _attr_ip_address: str | None = None + _attr_mac_address: str | None = None + _attr_source_type: SourceType = SourceType.ROUTER + + @cached_property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._attr_ip_address + + @cached_property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self._attr_mac_address + + @cached_property + def hostname(self) -> str | None: + """Return hostname of the device.""" + return self._attr_hostname + + @property + def unique_id(self) -> str | None: + """Return unique ID of the entity.""" + return self.mac_address + + @final + @property + def device_info(self) -> DeviceInfo | None: + """Device tracker entities should not create device registry entries.""" + return None + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if entity is enabled by default.""" + # If mac_address is None, we can never find a device entry. + return ( + # Do not disable if we won't activate our attach to device logic + self.mac_address is None + or self.device_info is not None + # Disable if we automatically attach but there is no device + or self.find_device_entry() is not None + ) + + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + if self.mac_address and self.unique_id: + _async_register_mac( + hass, + platform.platform_name, + self.mac_address, + self.unique_id, + ) + if self.is_connected and self.ip_address: + _async_connected_device_registered( + hass, + self.mac_address, + self.ip_address, + self.hostname, + ) + + @callback + def find_device_entry(self) -> dr.DeviceEntry | None: + """Return device entry.""" + assert self.mac_address is not None + + return dr.async_get(self.hass).async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)} + ) + + async def async_internal_added_to_hass(self) -> None: + """Handle added to Home Assistant.""" + # Entities without a unique ID don't have a device + if ( + not self.registry_entry + or not self.platform.config_entry + or not self.mac_address + or (device_entry := self.find_device_entry()) is None + # Entities should not have a device info. We opt them out + # of this logic if they do. + or self.device_info + ): + if self.device_info: + LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id) + await super().async_internal_added_to_hass() + return + + # Attach entry to device + if self.registry_entry.device_id != device_entry.id: + self.registry_entry = er.async_get(self.hass).async_update_entity( + self.entity_id, device_id=device_entry.id + ) + + # Attach device to config entry + if self.platform.config_entry.entry_id not in device_entry.config_entries: + dr.async_get(self.hass).async_update_device( + device_entry.id, + add_config_entry_id=self.platform.config_entry.entry_id, + ) + + # Do this last or else the entity registry update listener has been installed + await super().async_internal_added_to_hass() + + # BaseScannerEntity.state_attributes is @final to keep external subclasses + # from tampering with it; ScannerEntity is an in-tree subclass that + # intentionally extends it with ip/mac/hostname. + @final # type: ignore[misc] + @property + def state_attributes(self) -> dict[str, Any]: + """Return the device state attributes.""" + attr = super().state_attributes + + if ip_address := self.ip_address: + attr[ATTR_IP] = ip_address + if (mac_address := self.mac_address) is not None: + attr[ATTR_MAC] = mac_address + if (hostname := self.hostname) is not None: + attr[ATTR_HOST_NAME] = hostname + + return attr diff --git a/homeassistant/components/device_tracker/icons.json b/homeassistant/components/device_tracker/icons.json index 4ba56719cb6..4e5b82576cf 100644 --- a/homeassistant/components/device_tracker/icons.json +++ b/homeassistant/components/device_tracker/icons.json @@ -1,12 +1,4 @@ { - "conditions": { - "is_home": { - "condition": "mdi:account" - }, - "is_not_home": { - "condition": "mdi:account-arrow-right" - } - }, "entity_component": { "_": { "default": "mdi:account", @@ -19,13 +11,5 @@ "see": { "service": "mdi:account-eye" } - }, - "triggers": { - "entered_home": { - "trigger": "mdi:account-arrow-left" - }, - "left_home": { - "trigger": "mdi:account-arrow-right" - } } } diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5923aa2ed45..bad2f825458 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -1,11 +1,10 @@ """Legacy device tracker classes.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine, Sequence from datetime import datetime, timedelta import hashlib +import logging from types import ModuleType from typing import Any, Final, Protocol, final @@ -38,10 +37,9 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - discovery, - entity_registry as er, +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity_platform import ( + async_create_platform_config_not_supported_issue, ) from homeassistant.helpers.event import ( async_track_time_interval, @@ -82,6 +80,8 @@ from .const import ( SourceType, ) +_LOGGER = logging.getLogger(__name__) + SERVICE_SEE: Final = "see" SOURCE_TYPES = [cls.value for cls in SourceType] @@ -128,6 +128,8 @@ SERVICE_SEE_PAYLOAD_SCHEMA: Final[vol.Schema] = vol.Schema( YAML_DEVICES: Final = "known_devices.yaml" EVENT_NEW_DEVICE: Final = "device_tracker_new_device" +DATA_LEGACY_TRACKERS: Final = "device_tracker.legacy_trackers" + class SeeCallback(Protocol): """Protocol type for DeviceTracker.see callback.""" @@ -201,40 +203,7 @@ def see( hass.services.call(DOMAIN, SERVICE_SEE, data) -@callback -def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> None: - """Set up the legacy integration.""" - # The tracker is loaded in the _async_setup_integration task so - # we create a future to avoid waiting on it here so that only - # async_platform_discovered will have to wait in the rare event - # a custom component still uses the legacy device tracker discovery. - tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future() - - async def async_platform_discovered( - p_type: str, info: dict[str, Any] | None - ) -> None: - """Load a platform.""" - platform = await async_create_platform_type(hass, config, p_type, {}) - - if platform is None or platform.type != PLATFORM_TYPE_LEGACY: - return - - tracker = await tracker_future - await platform.async_setup_legacy(hass, tracker, info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - # - # Legacy and platforms load in a non-awaited tracked task - # to ensure device tracker setup can continue and config - # entry integrations are not waiting for legacy device - # tracker platforms to be set up. - # - hass.async_create_task( - _async_setup_integration(hass, config, tracker_future), eager_start=True - ) - - -async def _async_setup_integration( +async def async_setup_integration( hass: HomeAssistant, config: ConfigType, tracker_future: asyncio.Future[DeviceTracker], @@ -243,8 +212,19 @@ async def _async_setup_integration( tracker = await get_tracker(hass, config) tracker_future.set_result(tracker) + warned_called_see = False + async def async_see_service(call: ServiceCall) -> None: """Service to see a device.""" + nonlocal warned_called_see + if not warned_called_see: + _LOGGER.warning( + "The %s.%s action is deprecated and will be removed in " + "Home Assistant Core 2027.5", + DOMAIN, + SERVICE_SEE, + ) + warned_called_see = True # Temp workaround for iOS, introduced in 0.65 data = dict(call.data) data.pop("hostname", None) @@ -327,6 +307,18 @@ class DeviceTrackerPlatform: try: scanner = None setup: bool | None = None + + legacy_trackers = hass.data.setdefault(DATA_LEGACY_TRACKERS, set()) + if full_name not in legacy_trackers: + legacy_trackers.add(full_name) + _LOGGER.warning( + "The legacy device tracker platform %s is being set up; legacy " + "device trackers are deprecated and will be removed in Home " + "Assistant Core 2027.5, please migrate to an integration which " + "uses a modern config entry based device tracker", + full_name, + ) + if hasattr(self.platform, "async_get_scanner"): scanner = await self.platform.async_get_scanner( hass, {DOMAIN: self.config} @@ -390,8 +382,8 @@ async def async_extract_config( if platform.type == PLATFORM_TYPE_LEGACY: legacy.append(platform) else: - raise ValueError( - f"Unable to determine type for {platform.name}: {platform.type}" + async_create_platform_config_not_supported_issue( + hass, platform.name, DOMAIN ) return legacy diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index ff71fb30c65..6724c563dfd 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,28 +1,4 @@ { - "common": { - "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more device trackers are home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::condition_behavior_name%]" - } - }, - "name": "Device tracker is home" - }, - "is_not_home": { - "description": "Tests if one or more device trackers are not home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::condition_behavior_name%]" - } - }, - "name": "Device tracker is not home" - } - }, "device_automation": { "condition_type": { "is_home": "{entity_name} is home", @@ -64,23 +40,21 @@ "gps": "GPS", "router": "Router" } + }, + "tracking_type": { + "name": "Tracking type", + "state": { + "connection": "Connection", + "position": "Position" + } } } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } + "issues": { + "associated_zone_missing": { + "description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.", + "title": "Scanner is associated with a removed zone" } }, "services": { @@ -119,25 +93,5 @@ "name": "See device tracker" } }, - "title": "Device tracker", - "triggers": { - "entered_home": { - "description": "Triggers when one or more device trackers enter home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" - } - }, - "name": "Entered home" - }, - "left_home": { - "description": "Triggers when one or more device trackers leave home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" - } - }, - "name": "Left home" - } - } + "title": "Device tracker" } diff --git a/homeassistant/components/device_tracker/trigger.py b/homeassistant/components/device_tracker/trigger.py deleted file mode 100644 index 7f1d2bd068e..00000000000 --- a/homeassistant/components/device_tracker/trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Provides triggers for device_trackers.""" - -from homeassistant.const import STATE_HOME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import ( - Trigger, - make_entity_origin_state_trigger, - make_entity_target_state_trigger, -) - -from .const import DOMAIN - -TRIGGERS: dict[str, type[Trigger]] = { - "entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME), - "left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME), -} - - -async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: - """Return the triggers for device trackers.""" - return TRIGGERS diff --git a/homeassistant/components/device_tracker/triggers.yaml b/homeassistant/components/device_tracker/triggers.yaml deleted file mode 100644 index e75f072ba8c..00000000000 --- a/homeassistant/components/device_tracker/triggers.yaml +++ /dev/null @@ -1,18 +0,0 @@ -.trigger_common: &trigger_common - target: - entity: - domain: device_tracker - fields: - behavior: - required: true - default: any - selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior - -entered_home: *trigger_common -left_home: *trigger_common diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 8c6a857dd48..bb7d9f962bf 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -1,7 +1,5 @@ """The devolo_home_control integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from functools import partial @@ -108,7 +106,10 @@ def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo: def check_mydevolo_and_get_gateway_ids(mydevolo: Mydevolo) -> list[str]: - """Check if the credentials are valid and return user's gateway IDs as long as mydevolo is not in maintenance mode.""" + """Check credentials and return user's gateway IDs. + + Raises if mydevolo is in maintenance mode. + """ if not mydevolo.credentials_valid(): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index ef80005a904..e0318ac097d 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl @@ -31,7 +29,7 @@ async def async_setup_entry( entry: DevoloHomeControlConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Get all binary sensor and multi level sensor devices and setup them via config entry.""" + """Get all binary sensor and multi level sensor devices.""" entities: list[BinarySensorEntity] = [] for gateway in entry.runtime_data: diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 95db596c3ef..ffe40c0a63d 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -1,7 +1,5 @@ """Platform for climate integration.""" -from __future__ import annotations - from typing import Any from devolo_home_control_api.devices.zwave import Zwave @@ -77,7 +75,9 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit return next( ( multi_level_sensor.value - for multi_level_sensor in self._device_instance.multi_level_sensor_property.values() + for multi_level_sensor in ( + self._device_instance.multi_level_sensor_property.values() + ) if multi_level_sensor.sensor_type == "temperature" ), None, diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index 64220949270..ca005aca3f0 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the devolo home control integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -119,7 +117,9 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): ) if self.unique_id != uuid: - # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. + # The old user and the new user are not the same. + # This could mess-up everything as all + # unique IDs might change. raise UuidChanged reauth_entry = self._get_reauth_entry() diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index bafef2b02c9..d498ed77863 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -1,7 +1,5 @@ """Platform for cover integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( diff --git a/homeassistant/components/devolo_home_control/diagnostics.py b/homeassistant/components/devolo_home_control/diagnostics.py index 1ce65d90fd6..b66bd0368d4 100644 --- a/homeassistant/components/devolo_home_control/diagnostics.py +++ b/homeassistant/components/devolo_home_control/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for devolo Home Control.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index ab9f29873cd..da65ea4cd87 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -1,7 +1,5 @@ """Base class for a device entity integrated in devolo Home Control.""" -from __future__ import annotations - import logging from urllib.parse import urlparse @@ -117,7 +115,10 @@ class DevoloDeviceEntity(Entity): class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): - """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + """Representation of a multi level switch device within devolo Home Control. + + Something like a dimmer or a thermostat. + """ _attr_name = None diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 907a46ec27b..03d423a9824 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -1,7 +1,5 @@ """Platform for light integration.""" -from __future__ import annotations - from typing import Any from devolo_home_control_api.devices.zwave import Zwave @@ -72,7 +70,8 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) ) elif self._binary_switch_property is not None: - # Turn on the light device to the latest known value. The value is known by the device itself. + # Turn on the light device to the latest known + # value. The value is known by the device itself. self._binary_switch_property.set(True) else: # If there is no binary switch attached to the device, turn it on to 100 %. diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 9f711ad9c29..b978082bc93 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from devolo_home_control_api.devices.zwave import Zwave diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py index e3f91ca4d7d..42df940ee96 100644 --- a/homeassistant/components/devolo_home_control/siren.py +++ b/homeassistant/components/devolo_home_control/siren.py @@ -18,7 +18,7 @@ async def async_setup_entry( entry: DevoloHomeControlConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Get all binary sensor and multi level sensor devices and setup them via config entry.""" + """Get all binary sensor and multi level sensor devices.""" async_add_entities( DevoloSirenDeviceEntity( diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 62f9326bb89..7d20e02b49f 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -1,7 +1,5 @@ """Platform for switch integration.""" -from __future__ import annotations - from typing import Any from devolo_home_control_api.devices.zwave import Zwave diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 79d00ee50be..979bb568fb7 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,12 +1,11 @@ """The devolo Home Network integration.""" -from __future__ import annotations - import logging from typing import Any from devolo_plc_api import Device from devolo_plc_api.exceptions.device import DeviceNotFound +from yarl import URL from homeassistant.components import zeroconf from homeassistant.const import ( @@ -17,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from .const import ( @@ -123,6 +123,25 @@ async def async_setup_entry( entry.runtime_data.coordinators = coordinators + # Ensure the device exists before forwarding to platforms, so that the + # device tracker (which looks up the device by wifi station MAC) is not + # racing the other platforms that create the device via DeviceInfo. + device_info = dr.DeviceInfo( + configuration_url=URL.build(scheme="http", host=device.ip), + identifiers={(DOMAIN, str(device.serial_number))}, + manufacturer="devolo", + model=device.product, + model_id=device.mt_number, + serial_number=device.serial_number, + sw_version=device.firmware_version, + ) + if device.mac: + device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, device.mac)} + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **device_info, + ) + await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) entry.async_on_unload( diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 3b1debe42c5..9a37186a42c 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 53de2945d00..a7a6c9ad79a 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -1,7 +1,5 @@ """Platform for button integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index 125559eefe4..d0542d71d9f 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -1,7 +1,5 @@ """Config flow for devolo Home Network integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -42,9 +40,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, await device.async_connect(session_instance=async_client) - # Try a password protected, non-writing device API call that raises, if the password is wrong. - # If only the plcnet API is available, we can continue without trying a password as the plcnet - # API does not require a password. + # Try a password protected, non-writing device API + # call that raises, if the password is wrong. + # If only the plcnet API is available, we can continue + # without trying a password as the plcnet API does not + # require a password. if device.device: await device.device.async_uptime() diff --git a/homeassistant/components/devolo_home_network/coordinator.py b/homeassistant/components/devolo_home_network/coordinator.py index 5af9afb12ae..2dff8188ba7 100644 --- a/homeassistant/components/devolo_home_network/coordinator.py +++ b/homeassistant/components/devolo_home_network/coordinator.py @@ -82,7 +82,7 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @callback def update_sw_version(self) -> None: - """Update device registry with new firmware version, if it changed at runtime.""" + """Update device registry with new firmware version.""" device_registry = dr.async_get(self.hass) if ( device_entry := device_registry.async_get_device( diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index d691cc13007..504275c6095 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -1,7 +1,5 @@ """Platform for device tracker integration.""" -from __future__ import annotations - from devolo_plc_api.device import Device from devolo_plc_api.device_api import ConnectedStationInfo diff --git a/homeassistant/components/devolo_home_network/diagnostics.py b/homeassistant/components/devolo_home_network/diagnostics.py index 1683edb4074..f34b70b841b 100644 --- a/homeassistant/components/devolo_home_network/diagnostics.py +++ b/homeassistant/components/devolo_home_network/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for devolo Home Network.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 79b9b846463..9d4f3d4bac7 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -1,7 +1,5 @@ """Generic platform.""" -from __future__ import annotations - from devolo_plc_api.device_api import ( ConnectedStationInfo, NeighborAPInfo, diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 8dc701a30c9..52f469a6427 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -1,7 +1,5 @@ """Platform for image integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 941eec4215d..16e5462a73a 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -37,7 +35,11 @@ PARALLEL_UPDATES = 0 def _last_restart(runtime: int) -> datetime: - """Calculate uptime. As fetching the data might also take some time, let's floor to the nearest 5 seconds.""" + """Calculate uptime. + + As fetching the data might also take some time, + let's floor to the nearest 5 seconds. + """ now = utcnow() return ( now @@ -117,7 +119,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = { key=LAST_RESTART, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, value_func=_last_restart, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 3de47e9a3fc..650a638829c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -75,9 +75,6 @@ "connected_wifi_clients": { "name": "Connected Wi-Fi clients" }, - "last_restart": { - "name": "Last restart of the device" - }, "neighboring_wifi_networks": { "name": "Neighboring Wi-Fi networks" }, diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index e709d0f54b4..0047458fb90 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -1,7 +1,5 @@ """Platform for switch integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index ace12f24358..7ceece97e4d 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -1,7 +1,5 @@ """Platform for update integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index cbe97952fef..2ad53f2c9f9 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Dexcom integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index eac0134f010..958b39e4dfe 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -1,7 +1,5 @@ """Support for Dexcom sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 66e99d116f9..a2055f638b9 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -1,7 +1,5 @@ """The dhcp integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import timedelta diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py index e5ab767ee71..7acf26f76fd 100644 --- a/homeassistant/components/dhcp/helpers.py +++ b/homeassistant/components/dhcp/helpers.py @@ -1,7 +1,5 @@ """The dhcp integration.""" -from __future__ import annotations - from collections.abc import Callable from functools import partial diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 7b8405ffc37..c092aae38fb 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,8 +15,8 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.2.1", - "aiodiscover==2.7.1", - "cached-ipaddress==1.0.1" + "aiodhcpwatcher==1.2.7", + "aiodiscover==3.3.1", + "cached-ipaddress==1.1.2" ] } diff --git a/homeassistant/components/dhcp/models.py b/homeassistant/components/dhcp/models.py index d26993e7f0f..2bafd2303d1 100644 --- a/homeassistant/components/dhcp/models.py +++ b/homeassistant/components/dhcp/models.py @@ -1,7 +1,5 @@ """The dhcp integration.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from dataclasses import dataclass diff --git a/homeassistant/components/dhcp/websocket_api.py b/homeassistant/components/dhcp/websocket_api.py index e6682de2158..dda36c81aef 100644 --- a/homeassistant/components/dhcp/websocket_api.py +++ b/homeassistant/components/dhcp/websocket_api.py @@ -1,7 +1,5 @@ """The dhcp integration websocket apis.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index a19f3c888e5..9d4b5309305 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -1,7 +1,5 @@ """The Diagnostics integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Mapping from dataclasses import dataclass, field from http import HTTPStatus @@ -245,6 +243,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): extra_urls = ["/api/diagnostics/{d_type}/{d_id}/{sub_type}/{sub_id}"] name = "api:diagnostics" + @http.require_admin async def get( self, request: web.Request, diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 9b07fbd2d14..5dd6085e2df 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -1,7 +1,5 @@ """Diagnostic utilities.""" -from __future__ import annotations - from collections.abc import Iterable, Mapping from typing import Any, cast, overload diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index b4bd6ab1b92..44cad0fc68a 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -1,7 +1,5 @@ """Support for Digital Ocean.""" -from __future__ import annotations - import logging import digitalocean diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 6439a97ade8..9588993ec63 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the state of Digital Ocean droplets.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/digital_ocean/const.py b/homeassistant/components/digital_ocean/const.py index 77dfb1bf4e2..23898353b98 100644 --- a/homeassistant/components/digital_ocean/const.py +++ b/homeassistant/components/digital_ocean/const.py @@ -1,7 +1,5 @@ """Support for Digital Ocean.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index a3e6b4f95bf..64cc70a3a5e 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -1,7 +1,5 @@ """Support for interacting with Digital Ocean droplets.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 274cc4cbf53..a6aa9ea9745 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -1,7 +1,5 @@ """The DirecTV integration.""" -from __future__ import annotations - from datetime import timedelta from directv import DIRECTV, DIRECTVError diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 927d2325c2d..f25035078a9 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -1,7 +1,5 @@ """Config flow for DirecTV.""" -from __future__ import annotations - import logging from typing import Any, cast from urllib.parse import urlparse diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 45a3c59991d..4f0126dcc24 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -1,7 +1,5 @@ """Base DirecTV Entity.""" -from __future__ import annotations - from directv import DIRECTV from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 6f57375e878..1426e5938e9 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -1,7 +1,5 @@ """Support for the DirecTV receivers.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index c9aacaae4d3..9ea9f0da849 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -1,7 +1,5 @@ """Support for the DIRECTV remote.""" -from __future__ import annotations - from collections.abc import Iterable from datetime import timedelta import logging @@ -92,6 +90,7 @@ class DIRECTVRemote(DIRECTVEntity, RemoteEntity): for single_command in command: try: await self.dtv.remote(single_command, self._address) + # pylint: disable-next=home-assistant-action-swallowed-exception except DIRECTVError: _LOGGER.exception( "Sending command %s to device %s failed", diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index cce4b5651db..c232a77c88f 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -1,7 +1,5 @@ """Show the amount of records in a user's Discogs collection.""" -from __future__ import annotations - from datetime import timedelta import logging import random @@ -132,7 +130,8 @@ class DiscogsSensor(SensorEntity): "cat_no": self._attrs["labels"][0]["catno"], "cover_image": self._attrs["cover_image"], "format": ( - f"{self._attrs['formats'][0]['name']} ({self._attrs['formats'][0]['descriptions'][0]})" + f"{self._attrs['formats'][0]['name']}" + f" ({self._attrs['formats'][0]['descriptions'][0]})" ), "label": self._attrs["labels"][0]["name"], "released": self._attrs["year"], diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index 975cf533070..cee885eca69 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Discord integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 8a98d172913..536249dbabb 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -1,7 +1,5 @@ """Discord platform for notify component.""" -from __future__ import annotations - from io import BytesIO import logging import os.path @@ -195,6 +193,7 @@ class DiscordNotificationService(BaseNotificationService): _LOGGER.warning("Channel not found for ID: %s", channelid) continue await channel.send(message, files=files, embeds=embeds) + # pylint: disable-next=home-assistant-action-swallowed-exception except (nextcord.HTTPException, nextcord.NotFound) as error: _LOGGER.warning("Communication error: %s", error) await discord_bot.close() diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 65687debd3a..10075a25c3b 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -1,7 +1,5 @@ """The Discovergy integration.""" -from __future__ import annotations - from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError @@ -27,7 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - ) try: - # try to get meters from api to check if credentials are still valid and for later use + # try to get meters from api to check if credentials + # are still valid and for later use; # if no exception is raised everything is fine to go meters = await client.meters() except discovergyError.InvalidLogin as err: diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index f4369951ba3..b528eaa6990 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Discovergy integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py index 80c3c23a8fa..761099d68f7 100644 --- a/homeassistant/components/discovergy/const.py +++ b/homeassistant/components/discovergy/const.py @@ -1,6 +1,4 @@ """Constants for the Discovergy integration.""" -from __future__ import annotations - DOMAIN = "discovergy" MANUFACTURER = "inexogy" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 2c77ab2388e..94212984ade 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Discovergy integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index f4d6a3397d0..39871174d9c 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for discovergy.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 7d4bb6cb052..ae928745225 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -173,7 +173,8 @@ async def async_setup_entry( for coordinator in entry.runtime_data: sensors: tuple[DiscovergySensorEntityDescription, ...] = () - # select sensor descriptions based on meter type and combine with additional sensors + # select sensor descriptions based on meter type + # and combine with additional sensors if coordinator.meter.measurement_type == "ELECTRICITY": sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS elif coordinator.meter.measurement_type == "GAS": @@ -213,7 +214,11 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, - name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + name=( + f"{meter.measurement_type.capitalize()}" + f" {meter.location.street}" + f" {meter.location.street_number}" + ), model=meter.meter_type, manufacturer=MANUFACTURER, serial_number=meter.full_serial_number, diff --git a/homeassistant/components/dlink/__init__.py b/homeassistant/components/dlink/__init__.py index 212fe2e9e21..6acde8c7ab8 100644 --- a/homeassistant/components/dlink/__init__.py +++ b/homeassistant/components/dlink/__init__.py @@ -1,7 +1,5 @@ """The D-Link Power Plug integration.""" -from __future__ import annotations - from pyW215.pyW215 import SmartPlug from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 02ef94dae7d..eb4b7ae1ad9 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the D-Link Power Plug integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/dlink/data.py b/homeassistant/components/dlink/data.py index 939b230f2c3..8e22438c2c5 100644 --- a/homeassistant/components/dlink/data.py +++ b/homeassistant/components/dlink/data.py @@ -1,7 +1,5 @@ """Data for the D-Link Power Plug integration.""" -from __future__ import annotations - from datetime import datetime import logging import urllib.error diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index 228dfd168a5..45ae67895c0 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -1,7 +1,5 @@ """Entity representing a D-Link Power Plug device.""" -from __future__ import annotations - from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index ef1348f613d..76380b8685b 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -1,7 +1,5 @@ """Support for D-Link Power Plug Switches.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index d22f4eb41d4..f5fe16fde57 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -1,7 +1,5 @@ """The dlna_dmr component.""" -from __future__ import annotations - from homeassistant import config_entries from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index ede9119c50d..d0bfb57b6fa 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for DLNA DMR.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from functools import partial from ipaddress import IPv6Address, ip_address diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index df81cee08e4..cb308faea15 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -1,7 +1,5 @@ """Constants for the DLNA DMR component.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Final diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py index 7af396f7c60..7b5a36fffe3 100644 --- a/homeassistant/components/dlna_dmr/data.py +++ b/homeassistant/components/dlna_dmr/data.py @@ -1,6 +1,5 @@ """Data used by this integration.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections import defaultdict diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index e49679f5d55..6472000edec 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -1,7 +1,5 @@ """Support for DLNA DMR (Device Media Renderer).""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine, Sequence import contextlib @@ -263,7 +261,7 @@ class DlnaDmrEntity(MediaPlayerEntity): except KeyError, ValueError: bootid = None - if change == ssdp.SsdpChange.UPDATE: + if change is ssdp.SsdpChange.UPDATE: # This is an announcement that bootid is about to change if self._bootid is not None and self._bootid == bootid: # Store the new value (because our old value matches) so that we @@ -283,7 +281,7 @@ class DlnaDmrEntity(MediaPlayerEntity): await self._device_disconnect() self._bootid = bootid - if change == ssdp.SsdpChange.BYEBYE: + if change is ssdp.SsdpChange.BYEBYE: # Device is going away if self._device: # Disconnect from gone device @@ -292,7 +290,7 @@ class DlnaDmrEntity(MediaPlayerEntity): self._ssdp_connect_failed = False if ( - change == ssdp.SsdpChange.ALIVE + change is ssdp.SsdpChange.ALIVE and not self._device and not self._ssdp_connect_failed ): @@ -607,6 +605,7 @@ class DlnaDmrEntity(MediaPlayerEntity): return None return self._device.volume_level + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" @@ -620,6 +619,7 @@ class DlnaDmrEntity(MediaPlayerEntity): return None return self._device.is_volume_muted + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" @@ -627,24 +627,28 @@ class DlnaDmrEntity(MediaPlayerEntity): desired_mute = bool(mute) await self._device.async_mute_volume(desired_mute) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_pause(self) -> None: """Send pause command.""" assert self._device is not None await self._device.async_pause() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_play(self) -> None: """Send play command.""" assert self._device is not None await self._device.async_play() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_stop(self) -> None: """Send stop command.""" assert self._device is not None await self._device.async_stop() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_seek(self, position: float) -> None: """Send seek command.""" @@ -652,6 +656,7 @@ class DlnaDmrEntity(MediaPlayerEntity): time = timedelta(seconds=position) await self._device.async_seek_rel_time(time) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any @@ -713,19 +718,21 @@ class DlnaDmrEntity(MediaPlayerEntity): # If already playing, or don't want to autoplay, no need to call Play autoplay = extra.get("autoplay", True) - if self._device.transport_state == TransportState.PLAYING or not autoplay: + if self._device.transport_state is TransportState.PLAYING or not autoplay: return # Play it await self._device.async_wait_for_can_play() await self.async_media_play() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_previous_track(self) -> None: """Send previous track command.""" assert self._device is not None await self._device.async_previous() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_media_next_track(self) -> None: """Send next track command.""" @@ -741,11 +748,12 @@ class DlnaDmrEntity(MediaPlayerEntity): if not (play_mode := self._device.play_mode): return None - if play_mode == PlayMode.VENDOR_DEFINED: + if play_mode is PlayMode.VENDOR_DEFINED: return None return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" @@ -774,10 +782,10 @@ class DlnaDmrEntity(MediaPlayerEntity): if not (play_mode := self._device.play_mode): return None - if play_mode == PlayMode.VENDOR_DEFINED: + if play_mode is PlayMode.VENDOR_DEFINED: return None - if play_mode == PlayMode.REPEAT_ONE: + if play_mode is PlayMode.REPEAT_ONE: return RepeatMode.ONE if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM): @@ -785,6 +793,7 @@ class DlnaDmrEntity(MediaPlayerEntity): return RepeatMode.OFF + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" @@ -811,6 +820,7 @@ class DlnaDmrEntity(MediaPlayerEntity): return None return self._device.preset_names + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" diff --git a/homeassistant/components/dlna_dms/__init__.py b/homeassistant/components/dlna_dms/__init__.py index 668a2e9d965..189edc9fb3e 100644 --- a/homeassistant/components/dlna_dms/__init__.py +++ b/homeassistant/components/dlna_dms/__init__.py @@ -4,8 +4,6 @@ A single config entry is used, with SSDP discovery for media servers. Each server is wrapped in a DmsEntity, and the server's USN is used as the unique_id. """ -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index a87b4a510f5..04cbbdeae79 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -1,7 +1,5 @@ """Config flow for DLNA DMS.""" -from __future__ import annotations - import logging from pprint import pformat from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/dlna_dms/const.py b/homeassistant/components/dlna_dms/const.py index 686e6c63108..4bc3d58d079 100644 --- a/homeassistant/components/dlna_dms/const.py +++ b/homeassistant/components/dlna_dms/const.py @@ -1,7 +1,5 @@ """Constants for the DLNA MediaServer integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Final diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8da971b7b49..7133c6f86a5 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -1,6 +1,5 @@ """Wrapper for media_source around async_upnp_client's DmsDevice .""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine @@ -237,7 +236,7 @@ class DmsDeviceSource: except KeyError, ValueError: bootid = None - if change == ssdp.SsdpChange.UPDATE: + if change is ssdp.SsdpChange.UPDATE: # This is an announcement that bootid is about to change if self._bootid is not None and self._bootid == bootid: # Store the new value (because our old value matches) so that we @@ -259,7 +258,7 @@ class DmsDeviceSource: await self.device_disconnect() self._bootid = bootid - if change == ssdp.SsdpChange.BYEBYE: + if change is ssdp.SsdpChange.BYEBYE: # Device is going away if self._device: # Disconnect from gone device @@ -268,7 +267,7 @@ class DmsDeviceSource: self._ssdp_connect_failed = False if ( - change == ssdp.SsdpChange.ALIVE + change is ssdp.SsdpChange.ALIVE and not self._device and not self._ssdp_connect_failed ): diff --git a/homeassistant/components/dlna_dms/media_source.py b/homeassistant/components/dlna_dms/media_source.py index f5bb440f978..0cf84133704 100644 --- a/homeassistant/components/dlna_dms/media_source.py +++ b/homeassistant/components/dlna_dms/media_source.py @@ -10,8 +10,6 @@ Media identifiers can look like: for the syntax. """ -from __future__ import annotations - from homeassistant.components.media_player import BrowseError, MediaClass, MediaType from homeassistant.components.media_source import ( BrowseMediaSource, diff --git a/homeassistant/components/dlna_dms/util.py b/homeassistant/components/dlna_dms/util.py index 78ada3c708a..ebc05c6bf90 100644 --- a/homeassistant/components/dlna_dms/util.py +++ b/homeassistant/components/dlna_dms/util.py @@ -1,7 +1,5 @@ """Small utility functions for the dlna_dms integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.util import slugify diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 52d27e02c26..96315a92e2d 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,28 +1,114 @@ """The DNS IP integration.""" -from __future__ import annotations +import asyncio +from dataclasses import dataclass +import logging + +import aiodns +from aiodns.error import DNSError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT -from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS +from .const import ( + CONF_HOSTNAME, + CONF_IPV4, + CONF_IPV6, + CONF_PORT_IPV6, + CONF_RESOLVER, + CONF_RESOLVER_IPV6, + DEFAULT_PORT, + PLATFORMS, +) + +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass +class DnsIPRuntimeData: + """Runtime data for DNS IP integration.""" + + resolver_ipv4: aiodns.DNSResolver | None + resolver_ipv6: aiodns.DNSResolver | None + + +type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Set up DNS IP from a config entry.""" + hostname = entry.data[CONF_HOSTNAME] + resolver_ipv4: aiodns.DNSResolver | None = None + resolver_ipv6: aiodns.DNSResolver | None = None + queries: list = [] + + if entry.data[CONF_IPV4]: + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER]], + tcp_port=entry.options[CONF_PORT], + udp_port=entry.options[CONF_PORT], + ) + queries.append(resolver_ipv4.query(hostname, "A")) + + if entry.data[CONF_IPV6]: + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER_IPV6]], + tcp_port=entry.options[CONF_PORT_IPV6], + udp_port=entry.options[CONF_PORT_IPV6], + ) + queries.append(resolver_ipv6.query(hostname, "AAAA")) + + async def _close_resolvers() -> None: + if resolver_ipv4 is not None: + await resolver_ipv4.close() + if resolver_ipv6 is not None: + await resolver_ipv6.close() + + try: + async with asyncio.timeout(10): + results = await asyncio.gather(*queries, return_exceptions=True) + except TimeoutError as err: + await _close_resolvers() + raise ConfigEntryNotReady( + f"DNS lookup timed out for {hostname}: {err}" + ) from err + + errors = [ + result for result in results if isinstance(result, (TimeoutError, DNSError)) + ] + if errors and len(errors) == len(results): + await _close_resolvers() + raise ConfigEntryNotReady( + f"DNS lookup failed for {hostname}: {errors[0]}" + ) from errors[0] + + entry.runtime_data = DnsIPRuntimeData( + resolver_ipv4=resolver_ipv4, + resolver_ipv6=resolver_ipv6, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Unload DNS IP config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + if entry.runtime_data.resolver_ipv4 is not None: + await entry.runtime_data.resolver_ipv4.close() + if entry.runtime_data.resolver_ipv6 is not None: + await entry.runtime_data.resolver_ipv6.close() + return unload_ok -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: DnsIPConfigEntry +) -> bool: """Migrate old entry to a newer version.""" if config_entry.version > 1: @@ -30,12 +116,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return False if config_entry.version < 2 and config_entry.minor_version < 2: - version = config_entry.version - minor_version = config_entry.minor_version _LOGGER.debug( "Migrating configuration from version %s.%s", - version, - minor_version, + config_entry.version, + config_entry.minor_version, ) new_options = {**config_entry.options} @@ -46,10 +130,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, options=new_options, minor_version=2 ) + _LOGGER.debug("Migration to configuration version %s.%s successful", 1, 2) + + if config_entry.version < 2 and config_entry.minor_version < 3: _LOGGER.debug( - "Migration to configuration version %s.%s successful", - 1, - 2, + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, ) + hass.config_entries.async_update_entry( + config_entry, unique_id=None, minor_version=3 + ) + + _LOGGER.debug("Migration to configuration version %s.%s successful", 1, 3) + return True diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 0ea2a9d092b..0d65bb63226 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for dnsip integration.""" -from __future__ import annotations - import asyncio import contextlib from typing import Any, Literal @@ -18,9 +16,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers import config_validation as cv from .const import ( + CONF_ADVANCED_OPTIONS, CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, @@ -39,15 +39,17 @@ from .const import ( DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string, - } -) -DATA_SCHEMA_ADV = vol.Schema( - { - vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string, - vol.Optional(CONF_RESOLVER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_RESOLVER_IPV6): cv.string, - vol.Optional(CONF_PORT_IPV6): cv.port, + vol.Required(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_RESOLVER): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_PORT_IPV6): cv.port, + } + ), + SectionConfig(collapsed=True), + ), } ) @@ -93,7 +95,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for dnsip integration.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback @@ -113,10 +115,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: hostname = user_input[CONF_HOSTNAME] name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname - resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) - resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6) - port = user_input.get(CONF_PORT, DEFAULT_PORT) - port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT) + advanced_options = user_input[CONF_ADVANCED_OPTIONS] + resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER) + resolver_ipv6 = advanced_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) validate = await async_validate_hostname( hostname, resolver, resolver_ipv6, port, port_ipv6 @@ -133,8 +138,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ): errors["base"] = "invalid_hostname" else: - await self.async_set_unique_id(hostname) - self._abort_if_unique_id_configured() + self._async_abort_entries_match({CONF_HOSTNAME: hostname}) return self.async_create_entry( title=name, @@ -152,12 +156,6 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - if self.show_advanced_options is True: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA_ADV, - errors=errors, - ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index 2e81099df34..5d170246166 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -12,6 +12,7 @@ CONF_PORT_IPV6 = "port_ipv6" CONF_IPV4 = "ipv4" CONF_IPV6 = "ipv6" CONF_IPV6_V4 = "ipv6_v4" +CONF_ADVANCED_OPTIONS = "advanced_options" DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index d700c89ab6f..04c5699b9aa 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/dnsip", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["aiodns==4.0.0"] + "requirements": ["aiodns==4.0.4"] } diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index adadfd5e23d..ca965f33019 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -1,23 +1,21 @@ """Get your own public IP address or that of any host.""" -from __future__ import annotations - import asyncio from datetime import timedelta from ipaddress import IPv4Address, IPv6Address import logging -from typing import Literal +from typing import TYPE_CHECKING, Literal import aiodns from aiodns.error import DNSError from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import DnsIPConfigEntry from .const import ( CONF_HOSTNAME, CONF_IPV4, @@ -48,7 +46,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DnsIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the dnsip sensor entry.""" @@ -56,16 +54,29 @@ async def async_setup_entry( hostname = entry.data[CONF_HOSTNAME] name = entry.data[CONF_NAME] - nameserver_ipv4 = entry.options[CONF_RESOLVER] - nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] - port_ipv4 = entry.options[CONF_PORT] - port_ipv6 = entry.options[CONF_PORT_IPV6] - entities = [] if entry.data[CONF_IPV4]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4)) + entities.append( + WanIpSensor( + entry, + name, + hostname, + entry.options[CONF_RESOLVER], + False, + entry.options[CONF_PORT], + ) + ) if entry.data[CONF_IPV6]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6)) + entities.append( + WanIpSensor( + entry, + name, + hostname, + entry.options[CONF_RESOLVER_IPV6], + True, + entry.options[CONF_PORT_IPV6], + ) + ) async_add_entities(entities, update_before_add=True) @@ -77,10 +88,9 @@ class WanIpSensor(SensorEntity): _attr_translation_key = "dnsip" _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) - resolver: aiodns.DNSResolver - def __init__( self, + entry: DnsIPConfigEntry, name: str, hostname: str, nameserver: str, @@ -88,6 +98,8 @@ class WanIpSensor(SensorEntity): port: int, ) -> None: """Initialize the DNS IP sensor.""" + self.entry = entry + self.ipv6 = ipv6 self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname @@ -106,28 +118,43 @@ class WanIpSensor(SensorEntity): model=aiodns.__version__, name=name, ) - self.create_dns_resolver() + + @property + def _resolver(self) -> aiodns.DNSResolver: + """Return the active DNS resolver from runtime data.""" + resolver = ( + self.entry.runtime_data.resolver_ipv6 + if self.ipv6 + else self.entry.runtime_data.resolver_ipv4 + ) + if TYPE_CHECKING: + assert resolver is not None + return resolver def create_dns_resolver(self) -> None: - """Create the DNS resolver.""" - self.resolver = aiodns.DNSResolver( + """Create a new DNS resolver and store it on runtime data.""" + new_resolver = aiodns.DNSResolver( nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port ) + if self.ipv6: + self.entry.runtime_data.resolver_ipv6 = new_resolver + else: + self.entry.runtime_data.resolver_ipv4 = new_resolver async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" - if self.resolver._closed: # noqa: SLF001 + if self._resolver._closed: # noqa: SLF001 self.create_dns_resolver() response = None try: async with asyncio.timeout(10): - response = await self.resolver.query(self.hostname, self.querytype) + response = await self._resolver.query(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) - await self.resolver.close() + await self._resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) - await self.resolver.close() + await self._resolver.close() if response: sorted_ips = sort_ips( diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index a841cdffcde..d1268f3e548 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -9,11 +9,28 @@ "step": { "user": { "data": { - "hostname": "The hostname for which to perform the DNS query", - "port": "Port for IPV4 lookup", - "port_ipv6": "Port for IPV6 lookup", - "resolver": "Resolver for IPV4 lookup", - "resolver_ipv6": "Resolver for IPV6 lookup" + "hostname": "Hostname" + }, + "data_description": { + "hostname": "The hostname for which to perform the DNS query." + }, + "sections": { + "advanced_options": { + "data": { + "port": "IPv4 port", + "port_ipv6": "IPv6 port", + "resolver": "IPv4 resolver", + "resolver_ipv6": "IPv6 resolver" + }, + "data_description": { + "port": "Port used for the IPv4 lookup.", + "port_ipv6": "Port used for the IPv6 lookup.", + "resolver": "Resolver used for the IPv4 lookup.", + "resolver_ipv6": "Resolver used for the IPv6 lookup." + }, + "description": "Optionally change resolvers and ports.", + "name": "Advanced options" + } } } } @@ -46,11 +63,18 @@ "step": { "init": { "data": { - "port": "[%key:component::dnsip::config::step::user::data::port%]", - "port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]", - "resolver": "[%key:component::dnsip::config::step::user::data::resolver%]", - "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]" - } + "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%]" + }, + "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%]" + }, + "description": "Optionally change resolvers and ports." } } } diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index a00f942ec61..6f17b569a2b 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -1,7 +1,5 @@ """Support for the DOODS service.""" -from __future__ import annotations - import io import logging import os diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 6505f63d363..bee7cb77b29 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==12.1.1"] + "requirements": ["pydoods==1.0.2", "Pillow==12.2.0"] } diff --git a/homeassistant/components/door/__init__.py b/homeassistant/components/door/__init__.py index cd19966ffdf..558b9ea14d4 100644 --- a/homeassistant/components/door/__init__.py +++ b/homeassistant/components/door/__init__.py @@ -1,7 +1,5 @@ """Integration for door triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/door/conditions.yaml b/homeassistant/components/door/conditions.yaml index ed1c3d79ec5..ca1bff0bfdd 100644 --- a/homeassistant/components/door/conditions.yaml +++ b/homeassistant/components/door/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/door/strings.json b/homeassistant/components/door/strings.json index c6e5961ceff..40ed892e658 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Door", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::trigger_for_name%]" } }, "name": "Door closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::trigger_for_name%]" } }, "name": "Door opened" diff --git a/homeassistant/components/door/triggers.yaml b/homeassistant/components/door/triggers.yaml index 770a79f2221..a10197c6c0b 100644 --- a/homeassistant/components/door/triggers.yaml +++ b/homeassistant/components/door/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: closed: fields: *trigger_common_fields diff --git a/homeassistant/components/doorbell/__init__.py b/homeassistant/components/doorbell/__init__.py new file mode 100644 index 00000000000..50f50bbe2ad --- /dev/null +++ b/homeassistant/components/doorbell/__init__.py @@ -0,0 +1,15 @@ +"""Integration for doorbell triggers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "doorbell" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/doorbell/icons.json b/homeassistant/components/doorbell/icons.json new file mode 100644 index 00000000000..aecd411fc9b --- /dev/null +++ b/homeassistant/components/doorbell/icons.json @@ -0,0 +1,7 @@ +{ + "triggers": { + "rang": { + "trigger": "mdi:doorbell" + } + } +} diff --git a/homeassistant/components/doorbell/manifest.json b/homeassistant/components/doorbell/manifest.json new file mode 100644 index 00000000000..9fd730c1079 --- /dev/null +++ b/homeassistant/components/doorbell/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "doorbell", + "name": "Doorbell", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/doorbell", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/doorbell/strings.json b/homeassistant/components/doorbell/strings.json new file mode 100644 index 00000000000..8ca74a7a2c4 --- /dev/null +++ b/homeassistant/components/doorbell/strings.json @@ -0,0 +1,9 @@ +{ + "title": "Doorbell", + "triggers": { + "rang": { + "description": "Triggers after one or more doorbells rang.", + "name": "Doorbell rang" + } + } +} diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py new file mode 100644 index 00000000000..e111a92c78c --- /dev/null +++ b/homeassistant/components/doorbell/trigger.py @@ -0,0 +1,31 @@ +"""Provides triggers for doorbells.""" + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + DOMAIN as EVENT_DOMAIN, + DoorbellEventType, + EventDeviceClass, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger + + +class DoorbellRangTrigger(StatelessEntityTriggerBase): + """Trigger for doorbell event entity when a ring event is received.""" + + _domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)} + + def is_valid_state(self, state: State) -> bool: + """Check if the event type is ring.""" + return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING + + +TRIGGERS: dict[str, type[Trigger]] = { + "rang": DoorbellRangTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for doorbells.""" + return TRIGGERS diff --git a/homeassistant/components/doorbell/triggers.yaml b/homeassistant/components/doorbell/triggers.yaml new file mode 100644 index 00000000000..86e2e38a8d5 --- /dev/null +++ b/homeassistant/components/doorbell/triggers.yaml @@ -0,0 +1,5 @@ +rang: + target: + entity: + domain: event + device_class: doorbell diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 5090f309c49..b01c44eeab5 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -1,7 +1,5 @@ """Support for DoorBird devices.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index a41e7c41b28..86ca345d41e 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -1,7 +1,5 @@ """Support for viewing the camera feed from a DoorBird video doorbell.""" -from __future__ import annotations - import datetime import logging @@ -96,6 +94,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): self._last_image = await self._door_station.device.get_image( self._url, timeout=_TIMEOUT ) + # pylint: disable-next=home-assistant-action-swallowed-exception except TimeoutError: _LOGGER.error("DoorBird %s: Camera image timed out", self.name) return self._last_image diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 7a9764876fe..31b354d098e 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -1,7 +1,5 @@ """Config flow for DoorBird integration.""" -from __future__ import annotations - from collections.abc import Mapping from http import HTTPStatus import logging @@ -55,6 +53,8 @@ def _schema_with_defaults( { vol.Required(CONF_HOST, default=host): str, **AUTH_VOL_DICT, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=name): str, } ) @@ -116,7 +116,8 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN): This method performs the following verification steps: 1. Ensures that the stored credentials work before updating the entry. - 2. Verifies that the device at the discovered IP address has the expected MAC address. + 2. Verifies that the device at the discovered IP + address has the expected MAC address. """ info, errors = await self._async_validate_or_error( { diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index 17067b81d9b..ea435d78851 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -1,7 +1,5 @@ """Support for DoorBird devices.""" -from __future__ import annotations - from collections import defaultdict from dataclasses import dataclass from http import HTTPStatus diff --git a/homeassistant/components/doorbird/logbook.py b/homeassistant/components/doorbird/logbook.py index 70fedf97faf..d9fc2f471c9 100644 --- a/homeassistant/components/doorbird/logbook.py +++ b/homeassistant/components/doorbird/logbook.py @@ -1,7 +1,5 @@ """Describe logbook events.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.logbook import ( diff --git a/homeassistant/components/doorbird/models.py b/homeassistant/components/doorbird/models.py index e4ea64653a2..0b99c7bde51 100644 --- a/homeassistant/components/doorbird/models.py +++ b/homeassistant/components/doorbird/models.py @@ -1,7 +1,5 @@ """The doorbird integration models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/doorbird/repairs.py b/homeassistant/components/doorbird/repairs.py index c8f9b73ecbd..a14b5a0dc1b 100644 --- a/homeassistant/components/doorbird/repairs.py +++ b/homeassistant/components/doorbird/repairs.py @@ -1,11 +1,8 @@ """Repairs for DoorBird.""" -from __future__ import annotations - import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -19,13 +16,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: self.hass.config_entries.async_schedule_reload(self.entry_id) diff --git a/homeassistant/components/doorbird/view.py b/homeassistant/components/doorbird/view.py index 71e9d33b681..0ebe4be0d70 100644 --- a/homeassistant/components/doorbird/view.py +++ b/homeassistant/components/doorbird/view.py @@ -1,7 +1,5 @@ """Support for DoorBird devices.""" -from __future__ import annotations - from http import HTTPStatus from aiohttp import web diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 0a00490f3d9..852934849b6 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -1,7 +1,5 @@ """The Dormakaba dKey integration.""" -from __future__ import annotations - from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.models import AssociationData diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 719afb03b58..cb4ea3d38a1 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -1,7 +1,5 @@ """Dormakaba dKey integration binary sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 369accb83d8..94a778d2354 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Dormakaba dKey integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/dormakaba_dkey/coordinator.py b/homeassistant/components/dormakaba_dkey/coordinator.py index 32f71ebf59d..c11a23c51c6 100644 --- a/homeassistant/components/dormakaba_dkey/coordinator.py +++ b/homeassistant/components/dormakaba_dkey/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Dormakaba dKey integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/dormakaba_dkey/entity.py b/homeassistant/components/dormakaba_dkey/entity.py index cc34a70014d..e3e3283f58b 100644 --- a/homeassistant/components/dormakaba_dkey/entity.py +++ b/homeassistant/components/dormakaba_dkey/entity.py @@ -1,7 +1,5 @@ """Dormakaba dKey integration base entity.""" -from __future__ import annotations - import abc from py_dormakaba_dkey.commands import Notifications diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 12a553adba3..e48ee9e92f6 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -1,7 +1,5 @@ """Dormakaba dKey integration lock platform.""" -from __future__ import annotations - from typing import Any from py_dormakaba_dkey.commands import UnlockStatus diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index 413ea1c56b1..8fe732bd720 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -1,7 +1,5 @@ """Dormakaba dKey integration sensor platform.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py index b074b4cc17c..c5987908e43 100644 --- a/homeassistant/components/dovado/notify.py +++ b/homeassistant/components/dovado/notify.py @@ -1,7 +1,5 @@ """Support for SMS notifications from the Dovado router.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 06a2e935d79..0b31024188c 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -1,7 +1,5 @@ """Support for sensors from the Dovado router.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import re diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 8b33c1d7ed3..93d0e7a0c8a 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -1,7 +1,5 @@ """Support for functionality to download files.""" -from __future__ import annotations - import os from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/downloader/config_flow.py b/homeassistant/components/downloader/config_flow.py index 3c3d6189f8a..2d58606ce14 100644 --- a/homeassistant/components/downloader/config_flow.py +++ b/homeassistant/components/downloader/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Downloader integration.""" -from __future__ import annotations - import os from typing import Any diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index 74b503bebda..c9af95bbab3 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -1,17 +1,14 @@ """Support for functionality to download files.""" -from __future__ import annotations - from http import HTTPStatus import os import re -import threading import requests import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -31,8 +28,8 @@ from .const import ( ) -def download_file(service: ServiceCall) -> None: - """Start thread to download file specified in the URL.""" +async def download_file(service: ServiceCall) -> None: + """Download file specified in the URL.""" entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] download_path = entry.data[CONF_DOWNLOAD_DIR] @@ -126,18 +123,7 @@ def download_file(service: ServiceCall) -> None: {"url": url, "filename": filename}, ) - except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occurred for %s", url) - service.hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - except ValueError: - _LOGGER.exception("Invalid value") + except requests.exceptions.ConnectionError as err: service.hass.bus.fire( f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", {"url": url, "filename": filename}, @@ -147,7 +133,28 @@ def download_file(service: ServiceCall) -> None: if final_path and os.path.isfile(final_path): os.remove(final_path) - threading.Thread(target=do_download).start() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"url": url}, + ) from err + except ValueError as err: + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value", + translation_placeholders={"url": url}, + ) from err + + await service.hass.async_add_executor_job(do_download) @callback diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index e18654212a8..0b34e6e0ba4 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -8,11 +8,23 @@ }, "step": { "user": { + "data": { + "download_dir": "Download directory" + }, + "data_description": { + "download_dir": "The directory where downloaded files will be stored. This can be an absolute path or a path relative to the Home Assistant configuration directory." + }, "description": "Select a location to get to store downloads. The setup will check if the directory exists." } } }, "exceptions": { + "connection_error": { + "message": "Connection error occurred while downloading {url}" + }, + "invalid_value": { + "message": "Invalid filename derived from {url}" + }, "subdir_invalid": { "message": "Invalid subdirectory, got: {subdir}" }, diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index 33a8ad0e67f..fe00089896b 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -1,7 +1,5 @@ """The Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" -from __future__ import annotations - from dremel3dpy import Dremel3DPrinter from requests.exceptions import ConnectTimeout, HTTPError diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 923bcdad09c..f83035a45c5 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring Dremel 3D Printer binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index 880b179650f..27c9bf4ca07 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -1,7 +1,5 @@ """Support for Dremel 3D Printer buttons.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py index ccb7eeaa658..5789996e591 100644 --- a/homeassistant/components/dremel_3d_printer/camera.py +++ b/homeassistant/components/dremel_3d_printer/camera.py @@ -1,7 +1,5 @@ """Support for Dremel 3D45 Camera.""" -from __future__ import annotations - from homeassistant.components.camera import CameraEntityDescription from homeassistant.components.mjpeg import MjpegCamera from homeassistant.core import HomeAssistant @@ -21,7 +19,10 @@ async def async_setup_entry( config_entry: DremelConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras.""" + """Set up a MJPEG IP Camera for the 3D45 Model. + + The 3D20 and 3D40 models don't have built in cameras. + """ async_add_entities([Dremel3D45Camera(config_entry.runtime_data, CAMERA_TYPE)]) diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py index 64989cb60c4..b4fcbb86a13 100644 --- a/homeassistant/components/dremel_3d_printer/config_flow.py +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Dremel 3D Printer (3D20, 3D40, 3D45).""" -from __future__ import annotations - from json.decoder import JSONDecodeError from typing import Any diff --git a/homeassistant/components/dremel_3d_printer/const.py b/homeassistant/components/dremel_3d_printer/const.py index f060daf0d57..cf7f7a04c51 100644 --- a/homeassistant/components/dremel_3d_printer/const.py +++ b/homeassistant/components/dremel_3d_printer/const.py @@ -1,7 +1,5 @@ """Constants for the Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" -from __future__ import annotations - import logging LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 1f02b1fe239..683dc4aa284 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring Dremel 3D Printer sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index 52b8f5a7d6e..13e88554445 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -1,7 +1,5 @@ """The drop_connect integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index f133be431f0..29e49b7c35e 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -1,7 +1,5 @@ """Support for DROP binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -83,7 +81,6 @@ BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ ), DROPBinarySensorEntityDescription( key=POWER, - translation_key=None, # Use name provided by binary sensor device class device_class=BinarySensorDeviceClass.POWER, value_fn=lambda device: device.drop_api.power(), ), diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py index 476b244f345..8f57f53d2b9 100644 --- a/homeassistant/components/drop_connect/config_flow.py +++ b/homeassistant/components/drop_connect/config_flow.py @@ -1,20 +1,18 @@ """Config flow for drop_connect integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any from dropmqttapi.discovery import DropDiscovery from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE_ID from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from .const import ( CONF_COMMAND_TOPIC, CONF_DATA_TOPIC, CONF_DEVICE_DESC, - CONF_DEVICE_ID, CONF_DEVICE_NAME, CONF_DEVICE_OWNER_ID, CONF_DEVICE_TYPE, @@ -56,8 +54,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): f"{self._drop_discovery.hub_id}_{self._drop_discovery.device_id}" ) if existing_entry is not None: - # Note: returning "invalid_discovery_info" here instead of "already_configured" - # allows discovery of additional device types. + # Note: returning "invalid_discovery_info" here + # instead of "already_configured" allows discovery + # of additional device types. return self.async_abort(reason="invalid_discovery_info") self.context.update({"title_placeholders": {"name": self._drop_discovery.name}}) diff --git a/homeassistant/components/drop_connect/const.py b/homeassistant/components/drop_connect/const.py index f1012f9652c..9942defdcab 100644 --- a/homeassistant/components/drop_connect/const.py +++ b/homeassistant/components/drop_connect/const.py @@ -4,7 +4,6 @@ CONF_COMMAND_TOPIC = "drop_command_topic" CONF_DATA_TOPIC = "drop_data_topic" CONF_DEVICE_DESC = "device_desc" -CONF_DEVICE_ID = "device_id" CONF_DEVICE_TYPE = "device_type" CONF_HUB_ID = "drop_hub_id" CONF_DEVICE_NAME = "name" diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index d37127d89ed..a44b2f248f0 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -1,7 +1,5 @@ """DROP device data update coordinator object.""" -from __future__ import annotations - import logging from dropmqttapi.mqttapi import DropAPI diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py index 459552e8511..69d8f5c5697 100644 --- a/homeassistant/components/drop_connect/entity.py +++ b/homeassistant/components/drop_connect/entity.py @@ -1,7 +1,5 @@ """Base entity class for DROP entities.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index e198033d0f7..1d5e4dd6245 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -1,7 +1,5 @@ """Support for DROP selects.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index cc3356cb8e9..8021b684bd6 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -1,7 +1,5 @@ """Support for DROP sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -15,6 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + TEMPERATURE, EntityCategory, UnitOfPressure, UnitOfTemperature, @@ -51,7 +50,6 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure" HIGH_SYSTEM_PRESSURE = "high_system_pressure" LOW_SYSTEM_PRESSURE = "low_system_pressure" BATTERY = "battery" -TEMPERATURE = "temperature" INLET_TDS = "inlet_tds" OUTLET_TDS = "outlet_tds" CARTRIDGE_1_LIFE = "cart1" diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index d52d17c5ea0..61c05485475 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -1,7 +1,5 @@ """Support for DROP switches.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py index 4be8074a5cd..f0a01ec72fb 100644 --- a/homeassistant/components/dropbox/__init__.py +++ b/homeassistant/components/dropbox/__init__.py @@ -1,7 +1,5 @@ """The Dropbox integration.""" -from __future__ import annotations - from python_dropbox_api import ( DropboxAPIClient, DropboxAuthException, diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py index 3babe856a28..64bb3f910dc 100644 --- a/homeassistant/components/dropbox/application_credentials.py +++ b/homeassistant/components/dropbox/application_credentials.py @@ -25,7 +25,10 @@ async def async_get_auth_implementation( class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): - """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" + """Custom Dropbox OAuth2 implementation. + + Adds the necessary authorize url parameters. + """ @property def extra_authorize_data(self) -> dict: diff --git a/homeassistant/components/droplet/__init__.py b/homeassistant/components/droplet/__init__.py index 47378742804..62f09735594 100644 --- a/homeassistant/components/droplet/__init__.py +++ b/homeassistant/components/droplet/__init__.py @@ -1,7 +1,5 @@ """The Droplet integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/droplet/config_flow.py b/homeassistant/components/droplet/config_flow.py index 929998b9c27..e66e276ce49 100644 --- a/homeassistant/components/droplet/config_flow.py +++ b/homeassistant/components/droplet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Droplet integration.""" -from __future__ import annotations - from typing import Any from pydroplet.droplet import DropletConnection, DropletDiscovery diff --git a/homeassistant/components/droplet/coordinator.py b/homeassistant/components/droplet/coordinator.py index 33a5468ebd8..fc751dfdb6d 100644 --- a/homeassistant/components/droplet/coordinator.py +++ b/homeassistant/components/droplet/coordinator.py @@ -1,7 +1,5 @@ """Droplet device data update coordinator object.""" -from __future__ import annotations - import asyncio import logging import time diff --git a/homeassistant/components/droplet/sensor.py b/homeassistant/components/droplet/sensor.py index 73420abc121..6cb4d223348 100644 --- a/homeassistant/components/droplet/sensor.py +++ b/homeassistant/components/droplet/sensor.py @@ -1,7 +1,5 @@ """Support for Droplet.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/dsmr/__init__.py b/homeassistant/components/dsmr/__init__.py index e21262cf807..10a2170e6f8 100644 --- a/homeassistant/components/dsmr/__init__.py +++ b/homeassistant/components/dsmr/__init__.py @@ -1,7 +1,5 @@ """The dsmr component.""" -from __future__ import annotations - from asyncio import CancelledError, Task from contextlib import suppress from dataclasses import dataclass diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 577def8b3ec..80018730ca0 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -1,10 +1,7 @@ """Config flow for DSMR integration.""" -from __future__ import annotations - import asyncio from functools import partial -import os from typing import Any from dsmr_parser import obis_references as obis_ref @@ -14,8 +11,6 @@ from dsmr_parser.clients.rfxtrx_protocol import ( create_rfxtrx_tcp_dsmr_reader, ) from dsmr_parser.objects import DSMRObject -import serial -import serial.tools.list_ports import voluptuous as vol from homeassistant.config_entries import ( @@ -27,6 +22,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SerialPortSelector from .const import ( CONF_DSMR_VERSION, @@ -41,8 +37,6 @@ from .const import ( RFXTRX_DSMR_PROTOCOL, ) -CONF_MANUAL_PATH = "Enter Manually" - class DSMRConnection: """Test the connection to DSMR and receive telegram to read serial ids.""" @@ -120,7 +114,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except serial.SerialException, OSError: + except OSError: LOGGER.exception("Error connecting to DSMR") return False @@ -129,7 +123,9 @@ class DSMRConnection: async with asyncio.timeout(30): await protocol.wait_closed() except TimeoutError: - # Timeout (no data received), close transport and return True (if telegram is empty, will result in CannotCommunicate error) + # Timeout (no data received), close transport + # and return True (if telegram is empty, will + # result in CannotCommunicate error) transport.close() await protocol.wait_closed() return True @@ -167,8 +163,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _dsmr_version: str | None = None - @staticmethod @callback def async_get_options_flow( @@ -224,35 +218,13 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): """Step when setting up serial configuration.""" errors: dict[str, str] = {} if user_input is not None: - user_selection = user_input[CONF_PORT] - if user_selection == CONF_MANUAL_PATH: - self._dsmr_version = user_input[CONF_DSMR_VERSION] - return await self.async_step_setup_serial_manual_path() - - dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, user_selection - ) - - validate_data = { - CONF_PORT: dev_path, - CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION], - } - - data = await self.async_validate_dsmr(validate_data, errors) + data = await self.async_validate_dsmr(user_input, errors) if not errors: return self.async_create_entry(title=data[CONF_PORT], data=data) - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) - list_of_ports = { - port.device: f"{port}, s/n: {port.serial_number or 'n/a'}" - + (f" - {port.manufacturer}" if port.manufacturer else "") - for port in ports - } - list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH - schema = vol.Schema( { - vol.Required(CONF_PORT): vol.In(list_of_ports), + vol.Required(CONF_PORT): SerialPortSelector(), vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) @@ -262,27 +234,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_setup_serial_manual_path( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Select path manually.""" - if user_input is not None: - validate_data = { - CONF_PORT: user_input[CONF_PORT], - CONF_DSMR_VERSION: self._dsmr_version, - } - - errors: dict[str, str] = {} - data = await self.async_validate_dsmr(validate_data, errors) - if not errors: - return self.async_create_entry(title=data[CONF_PORT], data=data) - - schema = vol.Schema({vol.Required(CONF_PORT): str}) - return self.async_show_form( - step_id="setup_serial_manual_path", - data_schema=schema, - ) - async def async_validate_dsmr( self, input_data: dict[str, Any], errors: dict[str, str] ) -> dict[str, Any]: @@ -335,18 +286,6 @@ class DSMROptionFlowHandler(OptionsFlow): ) -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path - - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 2682b4df1cc..8dbcef1ffc7 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -1,7 +1,5 @@ """Constants for the DSMR integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/dsmr/diagnostics.py b/homeassistant/components/dsmr/diagnostics.py index 6f3b76273e1..bdda8ca5642 100644 --- a/homeassistant/components/dsmr/diagnostics.py +++ b/homeassistant/components/dsmr/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for DSMR.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 32366c55784..4c2a55e59bf 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -3,9 +3,10 @@ "name": "DSMR Smart Meter", "codeowners": ["@Robbie1221"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/dsmr", "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.5.0"] + "requirements": ["dsmr-parser==1.7.0"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0c4595e8f7f..378a2a08b15 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -1,7 +1,5 @@ """Support for Dutch Smart Meter (also known as Smartmeter or P1 port).""" -from __future__ import annotations - import asyncio from asyncio import CancelledError from collections.abc import Callable, Generator @@ -17,7 +15,6 @@ from dsmr_parser.clients.rfxtrx_protocol import ( create_rfxtrx_tcp_dsmr_reader, ) from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram -import serial from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -87,6 +84,7 @@ class MbusDeviceType(IntEnum): GAS = 3 HEAT = 4 WATER = 7 + HEAT_COOL = 12 SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( @@ -571,6 +569,16 @@ SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = { state_class=SensorStateClass.TOTAL_INCREASING, ), ), + MbusDeviceType.HEAT_COOL: ( + DSMRSensorEntityDescription( + key="heat_reading", + translation_key="heat_meter_reading", + obis_reference="MBUS_METER_READING", + is_heat=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ), } @@ -837,7 +845,7 @@ async def async_setup_entry( # throttle reconnect attempts await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - except serial.SerialException, OSError: + except OSError: # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 61c753b2e7f..519c23cc569 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -26,12 +26,6 @@ }, "title": "[%key:common::config_flow::data::device%]" }, - "setup_serial_manual_path": { - "data": { - "port": "[%key:common::config_flow::data::usb_path%]" - }, - "title": "[%key:common::config_flow::data::path%]" - }, "user": { "data": { "type": "Connection type" diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 4f2485ec647..f855f683aa4 100644 --- a/homeassistant/components/dsmr_reader/config_flow.py +++ b/homeassistant/components/dsmr_reader/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure DSMR Reader.""" -from __future__ import annotations - from collections.abc import Awaitable from typing import Any @@ -18,7 +16,10 @@ async def _async_has_devices(_: HomeAssistant) -> bool: class DsmrReaderFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DOMAIN): - """Handle DSMR Reader config flow. The MQTT step is inherited from the parent class.""" + """Handle DSMR Reader config flow. + + The MQTT step is inherited from the parent class. + """ VERSION = 1 diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 62d095aa993..a9185c51250 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,7 +1,5 @@ """Definitions for DSMR Reader sensors added to MQTT.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Final @@ -40,7 +38,7 @@ def tariff_transform(value: str) -> str: @dataclass(frozen=True) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class DSMRReaderSensorEntityDescription(SensorEntityDescription): """Sensor entity description for DSMR Reader.""" diff --git a/homeassistant/components/dsmr_reader/diagnostics.py b/homeassistant/components/dsmr_reader/diagnostics.py index 554d90cc5dd..f87ef965eee 100644 --- a/homeassistant/components/dsmr_reader/diagnostics.py +++ b/homeassistant/components/dsmr_reader/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for DSMR Reader.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 82cc4589b30..35e5514b8b4 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -1,7 +1,5 @@ """Support for DSMR Reader through MQTT.""" -from __future__ import annotations - from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 8720be7330f..540ab675a49 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -4,8 +4,6 @@ For more info on the API see : https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61 """ -from __future__ import annotations - from contextlib import suppress from datetime import datetime, timedelta from http import HTTPStatus diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 42fb32f2643..e7fab85777d 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,13 +1,7 @@ """Duck DNS integration.""" -from __future__ import annotations - import logging -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -18,18 +12,7 @@ from .services import async_setup_services _LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -37,15 +20,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_services(hass) - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True diff --git a/homeassistant/components/duckdns/config_flow.py b/homeassistant/components/duckdns/config_flow.py index 0a2ad9bdc19..4a2bf9036f2 100644 --- a/homeassistant/components/duckdns/config_flow.py +++ b/homeassistant/components/duckdns/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Duck DNS integration.""" -from __future__ import annotations - import logging from typing import Any @@ -18,7 +16,6 @@ from homeassistant.helpers.selector import ( from .const import DOMAIN from .helpers import update_duckdns -from .issue import deprecate_yaml_issue _LOGGER = logging.getLogger(__name__) @@ -70,18 +67,6 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={"url": "https://www.duckdns.org/"}, ) - async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: - """Import config from yaml.""" - - self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]}) - result = await self.async_step_user(import_info) - if errors := result.get("errors"): - deprecate_yaml_issue(self.hass, import_success=False) - return self.async_abort(reason=errors["base"]) - - deprecate_yaml_issue(self.hass, import_success=True) - return result - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/duckdns/coordinator.py b/homeassistant/components/duckdns/coordinator.py index 9c972b4fa11..048ab20171f 100644 --- a/homeassistant/components/duckdns/coordinator.py +++ b/homeassistant/components/duckdns/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Duck DNS integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -52,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]): """Update Duck DNS.""" retry_after = BACKOFF_INTERVALS[ - min(self.failed, len(BACKOFF_INTERVALS)) + min(self.failed, len(BACKOFF_INTERVALS) - 1) ].total_seconds() try: diff --git a/homeassistant/components/duckdns/issue.py b/homeassistant/components/duckdns/issue.py index 34a23fdbc63..af764ae7f38 100644 --- a/homeassistant/components/duckdns/issue.py +++ b/homeassistant/components/duckdns/issue.py @@ -1,45 +1,11 @@ """Issues for Duck DNS integration.""" -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN -@callback -def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None: - """Deprecate yaml issue.""" - if import_success: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2026.6.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Duck DNS", - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_error", - breaks_in_ha_version="2026.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_error", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=duckdns" - }, - ) - - def action_called_without_config_entry(hass: HomeAssistant) -> None: """Deprecate the use of action without config entry.""" diff --git a/homeassistant/components/duckdns/services.py b/homeassistant/components/duckdns/services.py index b6a0e5174bf..cfbf4bbba11 100644 --- a/homeassistant/components/duckdns/services.py +++ b/homeassistant/components/duckdns/services.py @@ -1,7 +1,5 @@ """Actions for Duck DNS.""" -from __future__ import annotations - from aiohttp import ClientError import voluptuous as vol diff --git a/homeassistant/components/duckdns/strings.json b/homeassistant/components/duckdns/strings.json index 87262c913e3..9581a1cc111 100644 --- a/homeassistant/components/duckdns/strings.json +++ b/homeassistant/components/duckdns/strings.json @@ -49,10 +49,6 @@ "deprecated_call_without_config_entry": { "description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.", "title": "Detected deprecated use of action without config entry" - }, - "deprecated_yaml_import_issue_error": { - "description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.", - "title": "The Duck DNS YAML configuration import failed" } }, "services": { diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py new file mode 100644 index 00000000000..bc75a459711 --- /dev/null +++ b/homeassistant/components/duco/__init__.py @@ -0,0 +1,46 @@ +"""The Duco integration.""" + +import re + +from duco_connectivity import DucoClient + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import DucoConfigEntry, DucoCoordinator + +_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$") + + +async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: + """Set up Duco from a config entry.""" + # Remove entity registry entries for the temperature and box_temperature + # sensors that were removed when migrating to python-duco-connectivity. + entity_registry = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + if _REMOVED_SENSOR_RE.search(entity_entry.unique_id): + entity_registry.async_remove(entity_entry.entity_id) + + client = DucoClient( + session=async_get_clientsession(hass), + host=entry.data[CONF_HOST], + ) + + coordinator = DucoCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: + """Unload a Duco config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/duco/config_flow.py b/homeassistant/components/duco/config_flow.py new file mode 100644 index 00000000000..5a860527b7f --- /dev/null +++ b/homeassistant/components/duco/config_flow.py @@ -0,0 +1,183 @@ +"""Config flow for the Duco integration.""" + +import logging +from typing import Any + +from duco_connectivity import DucoClient +from duco_connectivity.exceptions import DucoConnectionError, DucoError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN +from .validation import UnsupportedBoardError, async_get_supported_board_info + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class DucoConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Duco.""" + + VERSION = 1 + MINOR_VERSION = 1 + + _host: str + _box_name: str + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + try: + box_name, _ = await self._validate_input(discovery_info.ip) + except UnsupportedBoardError: + _LOGGER.debug( + "Unsupported Duco board discovered via DHCP at %s", discovery_info.ip + ) + return self.async_abort(reason="unsupported_board") + except DucoConnectionError: + return self.async_abort(reason="cannot_connect") + except DucoError: + _LOGGER.exception("Unexpected error discovering Duco box via DHCP") + return self.async_abort(reason="unknown") + + self._host = discovery_info.ip + self._box_name = box_name + self.context["title_placeholders"] = {"name": box_name} + + return await self.async_step_discovery_confirm() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + try: + box_name, mac = await self._validate_input(discovery_info.host) + except UnsupportedBoardError: + _LOGGER.debug( + "Unsupported Duco board discovered via zeroconf at %s", + discovery_info.host, + ) + return self.async_abort(reason="unsupported_board") + except DucoConnectionError: + return self.async_abort(reason="cannot_connect") + except DucoError: + _LOGGER.exception("Unexpected error discovering Duco box via zeroconf") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self._host = discovery_info.host + self._box_name = box_name + self.context["title_placeholders"] = {"name": box_name} + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self._box_name, + data={CONF_HOST: self._host}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self._box_name}, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + box_name, mac = await self._validate_input(user_input[CONF_HOST]) + except UnsupportedBoardError: + errors["base"] = "unsupported_board" + except DucoConnectionError: + errors["base"] = "cannot_connect" + except DucoError: + _LOGGER.exception("Unexpected error connecting to Duco box") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=box_name, + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_SCHEMA, reconfigure_entry.data + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + box_name, mac = await self._validate_input(user_input[CONF_HOST]) + except UnsupportedBoardError: + errors["base"] = "unsupported_board" + except DucoConnectionError: + errors["base"] = "cannot_connect" + except DucoError: + _LOGGER.exception("Unexpected error connecting to Duco box") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(mac), raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=box_name, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + async def _validate_input(self, host: str) -> tuple[str, str]: + """Validate the user input by connecting to the Duco box. + + Returns a tuple of (box_name, mac_address). + """ + client = DucoClient( + session=async_get_clientsession(self.hass), + host=host, + ) + board_info = await async_get_supported_board_info(client) + lan_info = await client.async_get_lan_info() + return board_info.box_name, lan_info.mac diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py new file mode 100644 index 00000000000..c3dde6ce046 --- /dev/null +++ b/homeassistant/components/duco/const.py @@ -0,0 +1,9 @@ +"""Constants for the Duco integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "duco" +PLATFORMS = [Platform.FAN, Platform.SENSOR] +SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py new file mode 100644 index 00000000000..c68fe7e9e05 --- /dev/null +++ b/homeassistant/components/duco/coordinator.py @@ -0,0 +1,116 @@ +"""Data update coordinator for the Duco integration.""" + +from dataclasses import dataclass +import logging + +from duco_connectivity import DucoClient +from duco_connectivity.exceptions import ( + DucoConnectionError, + DucoError, + DucoResponseError, +) +from duco_connectivity.models import BoardInfo, Node + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL +from .validation import UnsupportedBoardError, async_get_supported_board_info + +_LOGGER = logging.getLogger(__name__) + +type DucoConfigEntry = ConfigEntry[DucoCoordinator] + + +@dataclass +class DucoData: + """Data returned by the Duco coordinator.""" + + nodes: dict[int, Node] + rssi_wifi: int | None + + +class DucoCoordinator(DataUpdateCoordinator[DucoData]): + """Coordinator for the Duco integration.""" + + config_entry: DucoConfigEntry + board_info: BoardInfo + + def __init__( + self, + hass: HomeAssistant, + config_entry: DucoConfigEntry, + client: DucoClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_setup(self) -> None: + """Fetch board info once during initial setup.""" + try: + self.board_info = await async_get_supported_board_info(self.client) + except UnsupportedBoardError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unsupported_board", + ) from err + except DucoResponseError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": repr(err)}, + ) from err + + async def _async_update_data(self) -> DucoData: + """Fetch node data from the Duco box.""" + try: + nodes = await self.client.async_get_nodes() + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": repr(err)}, + ) from err + + # LAN info only backs the diagnostic RSSI sensor, so failures on this + # supplemental endpoint, including connection failures, should not make + # the primary node entities unavailable. + rssi_wifi = self.data.rssi_wifi if self.data else None + try: + lan_info = await self.client.async_get_lan_info() + except DucoError as err: + _LOGGER.debug("Could not fetch Duco LAN info", exc_info=err) + else: + rssi_wifi = lan_info.rssi_wifi + + return DucoData( + nodes={node.node_id: node for node in nodes}, + rssi_wifi=rssi_wifi, + ) diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py new file mode 100644 index 00000000000..9a901e8349c --- /dev/null +++ b/homeassistant/components/duco/diagnostics.py @@ -0,0 +1,74 @@ +"""Diagnostics support for Duco.""" + +from dataclasses import asdict +from typing import Any + +from duco_connectivity.exceptions import DucoConnectionError + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .coordinator import DucoConfigEntry + +# MAC addresses and serial numbers are redacted because a Duco installer or +# manufacturer could cross-reference them against an installation registry to +# identify the physical location of the device. +TO_REDACT = { + CONF_HOST, + "mac", + "host_name", + "serial_board_box", + "serial_board_comm", + "serial_duco_box", + "serial_duco_comm", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: DucoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + board = asdict(coordinator.board_info) + # `time` is a Unix epoch timestamp of the last board + # info fetch; not useful for support triage. + board.pop("time") + if board["public_api_version"] is None: + board.pop("public_api_version") + if board["software_version"] is None: + board.pop("software_version") + + try: + api_info_obj = await coordinator.client.async_get_api_info() + lan_info = await coordinator.client.async_get_lan_info() + duco_diags = await coordinator.client.async_get_diagnostics() + write_remaining = await coordinator.client.async_get_write_requests_remaining() + except DucoConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + + api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version} + if api_info_obj.reported_api_version is not None: + api_info["reported_api_version"] = api_info_obj.reported_api_version + + return async_redact_data( + { + "entry_data": entry.data, + "board_info": board, + "api_info": api_info, + "lan_info": asdict(lan_info), + "nodes": { + str(node_id): asdict(node) + for node_id, node in coordinator.data.nodes.items() + }, + "duco_diagnostics": [asdict(d) for d in duco_diags], + "write_requests_remaining": write_remaining, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py new file mode 100644 index 00000000000..233bca42f70 --- /dev/null +++ b/homeassistant/components/duco/entity.py @@ -0,0 +1,50 @@ +"""Base entity for the Duco integration.""" + +from duco_connectivity.models import Node + +from homeassistant.const import ATTR_VIA_DEVICE +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import DucoCoordinator + + +class DucoEntity(CoordinatorEntity[DucoCoordinator]): + """Base class for Duco entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._node_id = node.node_id + mac = coordinator.config_entry.unique_id + assert mac is not None + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}_{node.node_id}")}, + manufacturer="Duco", + model=coordinator.board_info.box_name + if node.general.node_type == "BOX" + else node.general.node_type, + name=node.general.name or f"Node {node.node_id}", + ) + device_info.update( + { + "connections": {(CONNECTION_NETWORK_MAC, mac)}, + "serial_number": coordinator.board_info.serial_board_box, + } + if node.general.node_type == "BOX" + else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")} + ) + self._attr_device_info = device_info + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._node_id in self.coordinator.data.nodes + + @property + def _node(self) -> Node: + """Return the current node data from the coordinator.""" + return self.coordinator.data.nodes[self._node_id] diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py new file mode 100644 index 00000000000..2f4809041bc --- /dev/null +++ b/homeassistant/components/duco/fan.py @@ -0,0 +1,137 @@ +"""Fan platform for the Duco integration.""" + +import logging + +from duco_connectivity.exceptions import DucoError, DucoRateLimitError +from duco_connectivity.models import Node, NodeType, VentilationState + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import percentage_to_ordered_list_item + +from .const import DOMAIN +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +# Permanent speed states ordered low → high. +ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [ + VentilationState.CNT1, + VentilationState.CNT2, + VentilationState.CNT3, +] + +PRESET_AUTO = "auto" + +# Upper-bound percentages for 3 speed levels: 33 / 66 / 100. +# Using upper bounds guarantees that reading a percentage back and writing it +# again always round-trips to the same Duco state. +_SPEED_LEVEL_PERCENTAGES: list[int] = [ + (i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS) + for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS) +] + +# Maps every active Duco state (including timed MAN variants) to its +# display percentage so externally-set timed modes show the correct level. +_STATE_TO_PERCENTAGE: dict[VentilationState, int] = { + VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco fan entities.""" + coordinator = entry.runtime_data + + # BOX is always node 1 and is never dynamically added + # or removed, so no listener needed. + async_add_entities( + DucoVentilationFanEntity(coordinator, node) + for node in coordinator.data.nodes.values() + if node.general.node_type == NodeType.BOX + ) + + +class DucoVentilationFanEntity(DucoEntity, FanEntity): + """Fan entity for the ventilation control of a Duco node.""" + + _attr_translation_key = "ventilation" + _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + _attr_preset_modes = [PRESET_AUTO] + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) + + def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: + """Initialize the fan entity.""" + super().__init__(coordinator, node) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}" + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage, or None when in AUTO mode.""" + node = self._node + if node.ventilation is None: + return None + return _STATE_TO_PERCENTAGE.get(node.ventilation.state) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode (auto when Duco controls, else None).""" + node = self._node + if node.ventilation is None: + return None + if node.ventilation.state not in _STATE_TO_PERCENTAGE: + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode: 'auto' hands control back to Duco.""" + self._valid_preset_mode_or_raise(preset_mode) + await self._async_set_state(VentilationState.AUTO) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed as a percentage (maps to low/medium/high).""" + if percentage == 0: + await self._async_set_state(VentilationState.AUTO) + return + state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) + await self._async_set_state(state) + + async def _async_set_state(self, state: VentilationState) -> None: + """Send the ventilation state to the device and refresh coordinator.""" + try: + await self.coordinator.client.async_set_ventilation_state( + self._node_id, state + ) + except DucoRateLimitError as err: + _LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rate_limit_exceeded", + ) from err + except DucoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_state", + translation_placeholders={"error": repr(err)}, + ) from err + await self.coordinator.async_refresh() diff --git a/homeassistant/components/duco/icons.json b/homeassistant/components/duco/icons.json new file mode 100644 index 00000000000..909e5936a79 --- /dev/null +++ b/homeassistant/components/duco/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "iaq_co2": { + "default": "mdi:molecule-co2" + }, + "iaq_rh": { + "default": "mdi:water-percent" + }, + "target_flow_level": { + "default": "mdi:gauge" + }, + "time_state_end": { + "default": "mdi:timer-outline" + }, + "ventilation_state": { + "default": "mdi:tune-variant" + } + } + } +} diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json new file mode 100644 index 00000000000..939cc693791 --- /dev/null +++ b/homeassistant/components/duco/manifest.json @@ -0,0 +1,23 @@ +{ + "domain": "duco", + "name": "Duco", + "codeowners": ["@ronaldvdmeer"], + "config_flow": true, + "dhcp": [ + { + "hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]" + } + ], + "documentation": "https://www.home-assistant.io/integrations/duco", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["duco_connectivity"], + "quality_scale": "platinum", + "requirements": ["python-duco-connectivity==0.6.0"], + "zeroconf": [ + { + "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", + "type": "_http._tcp.local." + } + ] +} diff --git a/homeassistant/components/duco/quality_scale.yaml b/homeassistant/components/duco/quality_scale.yaml new file mode 100644 index 00000000000..598e5529854 --- /dev/null +++ b/homeassistant/components/duco/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration uses a coordinator; entities do not subscribe to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not provide an option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by the DataUpdateCoordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration uses a local API that requires no credentials. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: >- + The integration has no actionable repair scenarios. Connection failures are + handled by the coordinator (unavailable entities) and resolve automatically. + There are no credentials to expire and no versioned API to become + incompatible with. + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py new file mode 100644 index 00000000000..fb82acea067 --- /dev/null +++ b/homeassistant/components/duco/sensor.py @@ -0,0 +1,255 @@ +"""Sensor platform for the Duco integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +import logging + +from duco_connectivity.models import Node, NodeType, VentilationState + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +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 DOMAIN +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class DucoSensorEntityDescription(SensorEntityDescription): + """Duco sensor entity description.""" + + value_fn: Callable[[Node], datetime | int | float | str | None] + node_types: tuple[NodeType, ...] + + +@dataclass(frozen=True, kw_only=True) +class DucoBoxSensorEntityDescription(SensorEntityDescription): + """Duco sensor entity description for box-level diagnostic data.""" + + value_fn: Callable[[DucoCoordinator], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = ( + DucoSensorEntityDescription( + key="ventilation_state", + translation_key="ventilation_state", + device_class=SensorDeviceClass.ENUM, + options=[ + state.lower() + for state in VentilationState + if state != VentilationState.UNKNOWN + ], + value_fn=lambda node: ( + node.ventilation.state.lower() + if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN + else None + ), + node_types=(NodeType.BOX,), + ), + DucoSensorEntityDescription( + key="target_flow_level", + translation_key="target_flow_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=lambda node: ( + node.ventilation.flow_lvl_tgt if node.ventilation else None + ), + node_types=(NodeType.BOX,), + ), + DucoSensorEntityDescription( + key="time_state_end", + translation_key="time_state_end", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda node: ( + dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace( + second=0, microsecond=0 + ) + if node.ventilation and node.ventilation.time_state_end != 0 + else None + ), + node_types=(NodeType.BOX,), + ), + DucoSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + value_fn=lambda node: node.sensor.co2 if node.sensor else None, + node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH), + ), + DucoSensorEntityDescription( + key="iaq_co2", + translation_key="iaq_co2", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None, + node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH), + ), + DucoSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda node: node.sensor.rh if node.sensor else None, + node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH), + ), + DucoSensorEntityDescription( + key="iaq_rh", + translation_key="iaq_rh", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None, + node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH), + ), +) + +BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = ( + DucoBoxSensorEntityDescription( + key="rssi_wifi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coordinator: coordinator.data.rssi_wifi, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco sensor entities.""" + coordinator = entry.runtime_data + + # Track the node IDs for which entities have already been created, so we + # can detect both newly added and stale (deregistered) nodes on every + # coordinator update. + known_nodes: set[int] = set() + + @callback + def _async_add_new_entities() -> None: + """Add new sensor entities and remove stale ones on coordinator updates.""" + # Remove devices whose nodes have disappeared from the API. + # The firmware removes deregistered RF/wired nodes automatically. + # BSRH box sensors that are physically unplugged from the PCB are + # not deregistered by the firmware and will never appear here as stale. + stale_node_ids = known_nodes - coordinator.data.nodes.keys() + if stale_node_ids: + device_reg = dr.async_get(hass) + mac = entry.unique_id + for node_id in stale_node_ids: + device = device_reg.async_get_device( + identifiers={(DOMAIN, f"{mac}_{node_id}")} + ) + if device: + device_reg.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + known_nodes.difference_update(stale_node_ids) + + new_entities: list[SensorEntity] = [] + for node in coordinator.data.nodes.values(): + if node.node_id in known_nodes: + continue + if node.general.node_type == NodeType.UNKNOWN: + # Do not add the node to known_nodes so that it is re-evaluated + # on every coordinator update. This allows entities to be + # created automatically once a firmware update or library + # update adds support for the device type. + _LOGGER.debug( + "Duco node %s (%s) has an unsupported device type and will be " + "retried on subsequent coordinator updates", + node.node_id, + node.general.name, + ) + continue + known_nodes.add(node.node_id) + new_entities.extend( + DucoSensorEntity(coordinator, node, description) + for description in SENSOR_DESCRIPTIONS + if node.general.node_type in description.node_types + ) + new_entities.extend( + DucoBoxSensorEntity(coordinator, node, description) + for description in BOX_SENSOR_DESCRIPTIONS + if node.general.node_type == NodeType.BOX + ) + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities)) + _async_add_new_entities() + + +class DucoSensorEntity(DucoEntity, SensorEntity): + """Sensor entity for a Duco node.""" + + entity_description: DucoSensorEntityDescription + + def __init__( + self, + coordinator: DucoCoordinator, + node: Node, + description: DucoSensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator, node) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}" + ) + + @property + def native_value(self) -> datetime | int | float | str | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self._node) + + +class DucoBoxSensorEntity(DucoEntity, SensorEntity): + """Sensor entity for box-level diagnostic data.""" + + entity_description: DucoBoxSensorEntityDescription + + def __init__( + self, + coordinator: DucoCoordinator, + node: Node, + description: DucoBoxSensorEntityDescription, + ) -> None: + """Initialize the box sensor entity.""" + super().__init__(coordinator, node) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}" + ) + + @property + def native_value(self) -> int | float | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json new file mode 100644 index 00000000000..b1680b18aa5 --- /dev/null +++ b/homeassistant/components/duco/strings.json @@ -0,0 +1,113 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device you entered belongs to a different Duco box.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to set up {name}?" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::duco::config::step::user::data_description::host%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "IP address or hostname of your Duco ventilation box." + } + } + } + }, + "entity": { + "fan": { + "ventilation": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]" + } + } + } + } + }, + "sensor": { + "iaq_co2": { + "name": "CO2 air quality index" + }, + "iaq_rh": { + "name": "Humidity air quality index" + }, + "target_flow_level": { + "name": "Target flow level" + }, + "time_state_end": { + "name": "State end time" + }, + "ventilation_state": { + "name": "Ventilation state", + "state": { + "aut1": "AUT1", + "aut2": "AUT2", + "aut3": "AUT3", + "auto": "AUTO", + "cnt1": "CNT1", + "cnt2": "CNT2", + "cnt3": "CNT3", + "empt": "EMPT", + "man1": "MAN1", + "man1x2": "MAN1x2", + "man1x3": "MAN1x3", + "man2": "MAN2", + "man2x2": "MAN2x2", + "man2x3": "MAN2x3", + "man3": "MAN3", + "man3x2": "MAN3x2", + "man3x3": "MAN3x3" + } + } + } + }, + "exceptions": { + "api_error": { + "message": "Unexpected error from the Duco API: {error}" + }, + "cannot_connect": { + "message": "An error occurred while trying to connect to the Duco instance: {error}" + }, + "connection_error": { + "message": "Could not connect to the Duco device." + }, + "failed_to_set_state": { + "message": "Failed to set ventilation state: {error}" + }, + "rate_limit_exceeded": { + "message": "The Duco device has reached its daily write limit. Try again tomorrow." + }, + "unsupported_board": { + "message": "[%key:component::duco::config::abort::unsupported_board%]" + } + }, + "system_health": { + "info": { + "write_requests_remaining": "Remaining write requests today" + } + } +} diff --git a/homeassistant/components/duco/system_health.py b/homeassistant/components/duco/system_health.py new file mode 100644 index 00000000000..72c43c9980a --- /dev/null +++ b/homeassistant/components/duco/system_health.py @@ -0,0 +1,47 @@ +"""Provide info to system health.""" + +from typing import Any + +from duco_connectivity.exceptions import DucoConnectionError + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import DucoConfigEntry + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def _async_get_write_requests_remaining( + config_entry: DucoConfigEntry, +) -> int | dict[str, str]: + """Get the remaining write-request quota for system health.""" + try: + return ( + await config_entry.runtime_data.client.async_get_write_requests_remaining() + ) + except DucoConnectionError: + return {"type": "failed", "error": "unreachable"} + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + + if not config_entries: + return {} + + return { + "write_requests_remaining": _async_get_write_requests_remaining( + config_entries[0] + ) + } diff --git a/homeassistant/components/duco/validation.py b/homeassistant/components/duco/validation.py new file mode 100644 index 00000000000..71c41d18125 --- /dev/null +++ b/homeassistant/components/duco/validation.py @@ -0,0 +1,58 @@ +"""Validation helpers for supported Duco systems.""" + +from awesomeversion import ( + AwesomeVersion, + AwesomeVersionStrategy, + AwesomeVersionStrategyException, +) +from duco_connectivity import DucoClient +from duco_connectivity.exceptions import DucoResponseError +from duco_connectivity.models import BoardInfo + +# Newer Connectivity boards expose /info with PublicApiVersion. We use that +# endpoint to distinguish supported Connectivity hardware from older +# Communication board V1 hardware. +_MIN_PUBLIC_API_VERSION = AwesomeVersion( + "2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) + + +class UnsupportedBoardError(Exception): + """Raised when the Duco system is not supported by this integration.""" + + +def validate_board_support(board_info: BoardInfo) -> None: + """Raise UnsupportedBoardError if the board does not meet support requirements.""" + version = board_info.public_api_version + if version is None: + raise UnsupportedBoardError("Board did not report a public API version") + try: + parsed_version = AwesomeVersion( + version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER + ) + except AwesomeVersionStrategyException as err: + raise UnsupportedBoardError( + f"Board reported malformed public API version: {version}" + ) from err + if parsed_version < _MIN_PUBLIC_API_VERSION: + raise UnsupportedBoardError( + "Board public API version " + f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}" + ) + + +async def async_get_supported_board_info(client: DucoClient) -> BoardInfo: + """Fetch and validate board info for a supported Duco system.""" + try: + board_info = await client.async_get_board_info() + except DucoResponseError as err: + if err.status == 404: + # Duco indicated that Communication board V1 does not implement + # /info, so a 404 is enough to treat the device as unsupported. + raise UnsupportedBoardError( + "Board does not expose the /info endpoint" + ) from err + raise + + validate_board_support(board_info) + return board_info diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py index 302a7280128..9462185d51a 100644 --- a/homeassistant/components/dunehd/__init__.py +++ b/homeassistant/components/dunehd/__init__.py @@ -1,7 +1,5 @@ """The Dune HD component.""" -from __future__ import annotations - from typing import Final from pdunehd import DuneHDPlayer diff --git a/homeassistant/components/dunehd/config_flow.py b/homeassistant/components/dunehd/config_flow.py index 33ffd4a812a..f2d1e2f7c56 100644 --- a/homeassistant/components/dunehd/config_flow.py +++ b/homeassistant/components/dunehd/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Dune HD integration.""" -from __future__ import annotations - from typing import Any from pdunehd import DuneHDPlayer diff --git a/homeassistant/components/dunehd/const.py b/homeassistant/components/dunehd/const.py index b4aa34ee72c..bd32bdd58b4 100644 --- a/homeassistant/components/dunehd/const.py +++ b/homeassistant/components/dunehd/const.py @@ -1,7 +1,5 @@ """Constants for Dune HD integration.""" -from __future__ import annotations - from typing import Final ATTR_MANUFACTURER: Final = "Dune" diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index 3960d7b6d3a..d1222d885cd 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -1,7 +1,5 @@ """Dune HD implementation of the media player.""" -from __future__ import annotations - from typing import Any, Final from pdunehd import DuneHDPlayer diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 766fad49e81..b1b1483e33a 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -1,7 +1,5 @@ """The duotecno integration.""" -from __future__ import annotations - from duotecno.controller import PyDuotecno from duotecno.exceptions import InvalidPassword, LoadFailure diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index e2431b5eade..f23254f6bac 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Duotecno binary sensors.""" -from __future__ import annotations - from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 0ae6735feb5..562a80c1521 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -1,7 +1,5 @@ """Support for Duotecno climate devices.""" -from __future__ import annotations - from typing import Any, Final from duotecno.unit import SensUnit diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py index 51b92d4673a..4e69be3319d 100644 --- a/homeassistant/components/duotecno/config_flow.py +++ b/homeassistant/components/duotecno/config_flow.py @@ -1,7 +1,5 @@ """Config flow for duotecno integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index e184cf7ffb3..df950a69ca0 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -1,7 +1,5 @@ """Support for Velbus covers.""" -from __future__ import annotations - from typing import Any from duotecno.unit import DuoswitchUnit diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index 3908440a182..c4310838924 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -1,7 +1,5 @@ """Support for Velbus devices.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 727fcf95339..7945f39aeb2 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -1,7 +1,5 @@ """The dwd_weather_warnings component.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index 064cf52d04d..6292d797012 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the dwd_weather_warnings integration.""" -from __future__ import annotations - from typing import Any from dwdwfsapi import DwdWeatherWarningsAPI diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py index 4f0a6767660..7ff8f9751a0 100644 --- a/homeassistant/components/dwd_weather_warnings/const.py +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -1,7 +1,5 @@ """Constants for the dwd_weather_warnings integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index 61656a82de6..f60ab166a43 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -1,7 +1,5 @@ """Data coordinator for the dwd_weather_warnings integration.""" -from __future__ import annotations - from dwdwfsapi import DwdWeatherWarningsAPI from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 6069fdc6a2f..a33edccaefc 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -9,9 +9,6 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor Wetterwarnungen (Stufe 1) """ -from __future__ import annotations - -from datetime import UTC, datetime from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -19,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import ( ADVANCE_WARNING_SENSOR, @@ -102,7 +100,7 @@ class DwdWeatherWarningsSensor( if warnings is None: return [] - now = datetime.now(UTC) + now = dt_util.utcnow() return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now] @property diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py index 730ebf4b71e..01398ef595c 100644 --- a/homeassistant/components/dwd_weather_warnings/util.py +++ b/homeassistant/components/dwd_weather_warnings/util.py @@ -1,7 +1,5 @@ """Util functions for the dwd_weather_warnings integration.""" -from __future__ import annotations - from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -32,7 +30,8 @@ def get_position_data( longitude = entity.attributes.get(ATTR_LONGITUDE) if not longitude: raise AttributeError( - f"Failed to find attribute '{ATTR_LONGITUDE}' in {registry_entry.entity_id}", + f"Failed to find attribute '{ATTR_LONGITUDE}'" + f" in {registry_entry.entity_id}", ATTR_LONGITUDE, ) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 1eb6b4f2e44..5846f19bb9b 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,7 +1,5 @@ """Support for the Dynalite networks.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 162d1167e81..94841e3b2f6 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,7 +1,5 @@ """Code to handle a Dynalite bridge.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from typing import Any @@ -66,7 +64,9 @@ class DynaliteBridge: def update_device(self, device: DynaliteBaseDevice | None = None) -> None: """Call when a device or all devices should be updated.""" if not device: - # This is used to signal connection or disconnection, so all devices may become available or not. + # This is used to signal connection or + # disconnection, so all devices may become + # available or not. log_string = ( "Connected" if self.dynalite_devices.connected else "Disconnected" ) @@ -104,7 +104,10 @@ class DynaliteBridge: self.async_add_devices[platform](self.waiting_devices[platform]) def add_devices_when_registered(self, devices: list[DynaliteBaseDevice]) -> None: - """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" + """Add the devices to HA if the add devices callback was registered. + + Otherwise queue until it is. + """ for platform in PLATFORMS: platform_devices = [ device for device in devices if device.category == platform diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 4b111c25cc9..ef6852ba2a0 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Dynalite hub.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index e37ce93ece4..36af6c8a41f 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -1,7 +1,5 @@ """Convert the HA config to the dynalite config.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 2cd473a2977..e5809abaed4 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -20,7 +20,7 @@ async def async_setup_entry( config_entry: DynaliteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Record the async_add_entities function to add them later when received from Dynalite.""" + """Record the async_add_entities function to add them later.""" @callback def cover_from_device(device: Any, bridge: DynaliteBridge) -> CoverEntity: @@ -86,7 +86,7 @@ class DynaliteCover(DynaliteBase, CoverEntity): class DynaliteCoverWithTilt(DynaliteCover): - """Representation of a Dynalite Channel as a Home Assistant Cover that uses up and down for tilt.""" + """Representation of a Dynalite Channel as a Cover with tilt.""" @property def current_cover_tilt_position(self) -> int: diff --git a/homeassistant/components/dynalite/entity.py b/homeassistant/components/dynalite/entity.py index 7957e9c8515..f0a2f37c1c5 100644 --- a/homeassistant/components/dynalite/entity.py +++ b/homeassistant/components/dynalite/entity.py @@ -1,7 +1,5 @@ """Support for the Dynalite devices as entities.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any @@ -23,7 +21,7 @@ def async_setup_entry_base( platform: str, entity_from_device: Callable, ) -> None: - """Record the async_add_entities function to add them later when received from Dynalite.""" + """Record the async_add_entities function to add them later.""" LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) bridge = config_entry.runtime_data diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index e9816c828db..0139983461c 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -15,7 +15,7 @@ async def async_setup_entry( config_entry: DynaliteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Record the async_add_entities function to add them later when received from Dynalite.""" + """Record the async_add_entities function to add them later.""" async_setup_entry_base( hass, config_entry, async_add_entities, "light", DynaliteLight ) diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py index d470243782b..024cc3f9177 100644 --- a/homeassistant/components/dynalite/schema.py +++ b/homeassistant/components/dynalite/schema.py @@ -1,7 +1,5 @@ """Schema for config entries.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -92,7 +90,7 @@ TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) def validate_area(config: dict[str, Any]) -> dict[str, Any]: - """Validate that template parameters are only used if area is using the relevant template.""" + """Validate template params are only used with relevant template.""" conf_set = set() for configs in DEFAULT_TEMPLATES.values(): for conf in configs: diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index 2621df61853..e5ad9c374af 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -1,7 +1,5 @@ """Support for the Dynalite networks.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index 29f78ecbc20..b8e5b06540c 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -16,7 +16,7 @@ async def async_setup_entry( config_entry: DynaliteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Record the async_add_entities function to add them later when received from Dynalite.""" + """Record the async_add_entities function to add them later.""" async_setup_entry_base( hass, config_entry, async_add_entities, "switch", DynaliteSwitch ) diff --git a/homeassistant/components/eafm/config_flow.py b/homeassistant/components/eafm/config_flow.py index 1b446ea62f3..125a1b0e32e 100644 --- a/homeassistant/components/eafm/config_flow.py +++ b/homeassistant/components/eafm/config_flow.py @@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN): self.stations = {} for station in stations: label = station["label"] - rloId = station["RLOIid"] + rlo_id = station["RLOIid"] # API annoyingly sometimes returns a list and some times returns a string # E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level'] @@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN): # Similar for RLOIid # E.g. 0018 has an RLOIid of ['10427', '9154'] - if isinstance(rloId, list): - rloId = rloId[-1] + if isinstance(rlo_id, list): + rlo_id = rlo_id[-1] - fullName = label + " - " + rloId - self.stations[fullName] = station["stationReference"] + full_name = label + " - " + rlo_id + self.stations[full_name] = station["stationReference"] if not self.stations: return self.async_abort(reason="no_stations") diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index ce5aa35e6a2..369d414bc20 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -114,7 +114,8 @@ class Measurement(CoordinatorEntity, SensorEntity): if "latestReading" not in self.coordinator.data["measures"][self.key]: return False - # Sometimes lastestReading key is present but actually a URL rather than a piece of data + # Sometimes lastestReading key is present but actually + # a URL rather than a piece of data. # This is usually because the sensor has been archived if not isinstance( self.coordinator.data["measures"][self.key]["latestReading"], dict diff --git a/homeassistant/components/earn_e_p1/__init__.py b/homeassistant/components/earn_e_p1/__init__.py new file mode 100644 index 00000000000..184b9be3969 --- /dev/null +++ b/homeassistant/components/earn_e_p1/__init__.py @@ -0,0 +1,59 @@ +"""The EARN-E P1 Meter integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern + +from earn_e_p1 import DEFAULT_PORT, EarnEP1Listener + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_SERIAL, DOMAIN +from .coordinator import EarnEP1Coordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type EarnEP1ConfigEntry = ConfigEntry[EarnEP1Coordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: EarnEP1ConfigEntry) -> bool: + """Set up EARN-E P1 Meter from a config entry.""" + host = entry.data[CONF_HOST] + serial = entry.data[CONF_SERIAL] + + # Get or create shared listener + if DOMAIN not in hass.data: + listener = EarnEP1Listener() + try: + await listener.start() + except OSError as err: + raise ConfigEntryNotReady( + f"Cannot start UDP listener on port {DEFAULT_PORT}: {err}" + ) from err + hass.data[DOMAIN] = listener + + listener = hass.data[DOMAIN] + coordinator = EarnEP1Coordinator(hass, entry, host, serial, listener) + coordinator.start() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EarnEP1ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + entry.runtime_data.stop() + + # Stop shared listener if no other entries are loaded + other_loaded = any( + e.state is ConfigEntryState.LOADED and e.entry_id != entry.entry_id + for e in hass.config_entries.async_entries(DOMAIN) + ) + if not other_loaded: + await hass.data[DOMAIN].stop() + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/homeassistant/components/earn_e_p1/config_flow.py b/homeassistant/components/earn_e_p1/config_flow.py new file mode 100644 index 00000000000..510d4ffed46 --- /dev/null +++ b/homeassistant/components/earn_e_p1/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for the EARN-E P1 Meter integration.""" + +import logging +from typing import Any + +from earn_e_p1 import EarnEP1Device, EarnEP1Listener, discover, validate +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST + +from .const import CONF_SERIAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DISCOVERY_TIMEOUT = 10 +VALIDATION_TIMEOUT = 65 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class EarnEP1ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for EARN-E P1 Meter.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_device: EarnEP1Device | None = None + + async def _async_discover(self) -> EarnEP1Device | None: + """Discover an EARN-E device on the network.""" + listener: EarnEP1Listener | None = self.hass.data.get(DOMAIN) + if listener is not None: + devices = await listener.discover(timeout=DISCOVERY_TIMEOUT) + else: + try: + devices = await discover(timeout=DISCOVERY_TIMEOUT) + except OSError: + return None + return devices[0] if devices else None + + async def _async_validate_host(self, host: str) -> EarnEP1Device | None: + """Validate a host and wait for a packet containing its serial. + + Uses the shared listener if available, otherwise creates a temporary one. + Returns the device if serial is found, None on timeout. + """ + listener: EarnEP1Listener | None = self.hass.data.get(DOMAIN) + if listener is not None: + return await listener.validate(host, timeout=VALIDATION_TIMEOUT) + return await validate(host, timeout=VALIDATION_TIMEOUT) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + return await self._async_validate_and_create(user_input) + + # Attempt auto-discovery before showing manual form + device = await self._async_discover() + if device: + self._discovered_device = device + return await self.async_step_discovery_confirm() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def _async_validate_and_create( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Validate manual IP entry and create config entry.""" + errors: dict[str, str] = {} + host = user_input[CONF_HOST] + + try: + device = await self._async_validate_host(host) + except OSError: + errors["base"] = "cannot_connect" + device = None + except Exception: + _LOGGER.exception("Unexpected error validating device") + errors["base"] = "unknown" + device = None + + if device is None and "base" not in errors: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + assert device is not None + await self.async_set_unique_id(device.serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"EARN-E P1 ({host})", + data={CONF_HOST: host, CONF_SERIAL: device.serial}, + ) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setup of a discovered device.""" + assert self._discovered_device is not None + device = self._discovered_device + + if user_input is not None: + # If discovery already got the serial, use it directly + if device.serial: + await self.async_set_unique_id(device.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"EARN-E P1 ({device.host})", + data={CONF_HOST: device.host, CONF_SERIAL: device.serial}, + ) + + # Discovery didn't get serial — validate to obtain it + try: + validated = await self._async_validate_host(device.host) + except OSError: + validated = None + except Exception: + _LOGGER.exception("Unexpected error validating device") + return self.async_abort(reason="unknown") + + if validated is None: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(validated.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"EARN-E P1 ({validated.host})", + data={CONF_HOST: validated.host, CONF_SERIAL: validated.serial}, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"host": device.host}, + ) diff --git a/homeassistant/components/earn_e_p1/const.py b/homeassistant/components/earn_e_p1/const.py new file mode 100644 index 00000000000..9b9f4cbfbe6 --- /dev/null +++ b/homeassistant/components/earn_e_p1/const.py @@ -0,0 +1,4 @@ +"""Constants for the EARN-E P1 Meter integration.""" + +DOMAIN = "earn_e_p1" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/earn_e_p1/coordinator.py b/homeassistant/components/earn_e_p1/coordinator.py new file mode 100644 index 00000000000..01866aa049f --- /dev/null +++ b/homeassistant/components/earn_e_p1/coordinator.py @@ -0,0 +1,69 @@ +"""DataUpdateCoordinator for the EARN-E P1 Meter integration.""" + +import logging +from typing import TYPE_CHECKING, Any + +from earn_e_p1 import EarnEP1Device, EarnEP1Listener + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import EarnEP1ConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class EarnEP1Coordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for the EARN-E P1 Meter.""" + + def __init__( + self, + hass: HomeAssistant, + entry: EarnEP1ConfigEntry, + host: str, + serial: str, + listener: EarnEP1Listener, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + ) + self.host = host + self.serial = serial + self.identifier = serial + self.model: str | None = None + self.sw_version: str | None = None + self._listener = listener + + def _handle_update(self, device: EarnEP1Device, _raw: dict[str, Any]) -> None: + """Handle data update from the listener.""" + if self.model != device.model or self.sw_version != device.sw_version: + self.model = device.model + self.sw_version = device.sw_version + device_registry = dr.async_get(self.hass) + if ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, self.identifier)} + ) + ) is not None: + device_registry.async_update_device( + device_entry.id, + model=self.model, + sw_version=self.sw_version, + ) + self.async_set_updated_data(device.data) + + def start(self) -> None: + """Register with the shared listener.""" + self._listener.register(self.host, self._handle_update) + + def stop(self) -> None: + """Unregister from the shared listener.""" + self._listener.unregister(self.host) diff --git a/homeassistant/components/earn_e_p1/entity.py b/homeassistant/components/earn_e_p1/entity.py new file mode 100644 index 00000000000..abc86c34034 --- /dev/null +++ b/homeassistant/components/earn_e_p1/entity.py @@ -0,0 +1,25 @@ +"""Base entity for the EARN-E P1 Meter integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EarnEP1Coordinator + + +class EarnEP1Entity(CoordinatorEntity[EarnEP1Coordinator]): + """Base class for EARN-E P1 entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: EarnEP1Coordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.identifier)}, + name="EARN-E P1 Meter", + manufacturer="EARN-E", + model=coordinator.model, + serial_number=coordinator.serial, + sw_version=coordinator.sw_version, + ) diff --git a/homeassistant/components/earn_e_p1/manifest.json b/homeassistant/components/earn_e_p1/manifest.json new file mode 100644 index 00000000000..39f9064f14e --- /dev/null +++ b/homeassistant/components/earn_e_p1/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "earn_e_p1", + "name": "EARN-E P1 Meter", + "codeowners": ["@Miggets7"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/earn_e_p1", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["earn-e-p1==0.1.0"] +} diff --git a/homeassistant/components/earn_e_p1/quality_scale.yaml b/homeassistant/components/earn_e_p1/quality_scale.yaml new file mode 100644 index 00000000000..cb5f4ea7493 --- /dev/null +++ b/homeassistant/components/earn_e_p1/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not have custom actions. + appropriate-polling: + status: exempt + comment: Integration uses local_push via UDP, no polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not have custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Uses CoordinatorEntity which handles event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not have custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no configuration options beyond initial setup. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: exempt + comment: >- + Push-based integration; the device stops sending UDP packets when + unavailable. The entity becomes unavailable via the custom available + property but there is no error event to log. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Each config entry represents a single physical device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not have any known issues that require repair. + stale-devices: + status: exempt + comment: Each config entry represents a single physical device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/earn_e_p1/sensor.py b/homeassistant/components/earn_e_p1/sensor.py new file mode 100644 index 00000000000..df3f8d3ff2c --- /dev/null +++ b/homeassistant/components/earn_e_p1/sensor.py @@ -0,0 +1,159 @@ +"""Sensor platform for the EARN-E P1 Meter integration.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import EarnEP1ConfigEntry +from .coordinator import EarnEP1Coordinator +from .entity import EarnEP1Entity + +PARALLEL_UPDATES = 0 + +SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="power_delivered", + translation_key="power_imported", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="power_returned", + translation_key="power_exported", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="voltage_l1", + translation_key="voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key="current_l1", + translation_key="current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_delivered_tariff1", + translation_key="energy_imported_tariff1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_delivered_tariff2", + translation_key="energy_imported_tariff2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_returned_tariff1", + translation_key="energy_exported_tariff1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_returned_tariff2", + translation_key="energy_exported_tariff2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="gas_delivered", + translation_key="gas_consumed", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="wifiRSSI", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EarnEP1ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up EARN-E P1 sensor entities.""" + coordinator = entry.runtime_data + added = False + + @callback + def _async_add_sensors() -> None: + nonlocal added + if added or coordinator.data is None: + return + added = True + async_add_entities( + EarnEP1Sensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if description.key in coordinator.data + ) + + entry.async_on_unload(coordinator.async_add_listener(_async_add_sensors)) + _async_add_sensors() + + +class EarnEP1Sensor(EarnEP1Entity, SensorEntity): + """Representation of an EARN-E P1 sensor.""" + + def __init__( + self, + coordinator: EarnEP1Coordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def available(self) -> bool: + """Return True if the sensor value is available.""" + return super().available and self.coordinator.data is not None + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/earn_e_p1/strings.json b/homeassistant/components/earn_e_p1/strings.json new file mode 100644 index 00000000000..903cf82b88d --- /dev/null +++ b/homeassistant/components/earn_e_p1/strings.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect — no data received from the device.", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Cannot connect — no data received from the device. Verify the IP address and that the EARN-E is powered on.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "discovery_confirm": { + "description": "An EARN-E P1 meter was found at **{host}**.", + "title": "Discovered EARN-E P1 meter" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "host": "The local IP address of your EARN-E P1 meter (e.g. 192.168.1.100)." + }, + "description": "No device was automatically discovered. Enter the IP address of your EARN-E energy monitor manually.", + "title": "Connect to EARN-E P1 meter" + } + } + }, + "entity": { + "sensor": { + "current_l1": { + "name": "Current L1" + }, + "energy_exported_tariff1": { + "name": "Energy exported tariff 1" + }, + "energy_exported_tariff2": { + "name": "Energy exported tariff 2" + }, + "energy_imported_tariff1": { + "name": "Energy imported tariff 1" + }, + "energy_imported_tariff2": { + "name": "Energy imported tariff 2" + }, + "gas_consumed": { + "name": "Gas consumed" + }, + "power_exported": { + "name": "Power exported" + }, + "power_imported": { + "name": "Power imported" + }, + "voltage_l1": { + "name": "Voltage L1" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + } + } + } +} diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index 0548431f09d..9e5d11249b6 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -1,7 +1,5 @@ """The easyEnergy integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/easyenergy/config_flow.py b/homeassistant/components/easyenergy/config_flow.py index 07e94060b74..ab39aa0b833 100644 --- a/homeassistant/components/easyenergy/config_flow.py +++ b/homeassistant/components/easyenergy/config_flow.py @@ -1,10 +1,12 @@ """Config flow for easyEnergy integration.""" -from __future__ import annotations - from typing import Any +from easyenergy import EasyEnergy, EasyEnergyConnectionError + from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -18,14 +20,22 @@ class EasyEnergyFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + errors: dict[str, str] = {} await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured() - if user_input is None: - return self.async_show_form(step_id="user") + if user_input is not None: + easyenergy = EasyEnergy(session=async_get_clientsession(self.hass)) + today = dt_util.now().date() + try: + await easyenergy.energy_prices(start_date=today, end_date=today) + except EasyEnergyConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="easyEnergy", + data={}, + ) - return self.async_create_entry( - title="easyEnergy", - data={}, - ) + return self.async_show_form(step_id="user", errors=errors) diff --git a/homeassistant/components/easyenergy/const.py b/homeassistant/components/easyenergy/const.py index 4670e9c4edd..b4c61f71f91 100644 --- a/homeassistant/components/easyenergy/const.py +++ b/homeassistant/components/easyenergy/const.py @@ -1,7 +1,5 @@ """Constants for the easyEnergy integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index e36bdf188ee..de532a52359 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -1,7 +1,5 @@ """The Coordinator for easyEnergy.""" -from __future__ import annotations - from datetime import timedelta from typing import NamedTuple @@ -78,7 +76,10 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): ) except EasyEnergyConnectionError as err: - raise UpdateFailed("Error communicating with easyEnergy API") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err return EasyEnergyData( energy_today=energy_today, diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index 64f30ba61fd..fda2ae9d2c6 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -1,11 +1,10 @@ """Diagnostics support for easyEnergy.""" -from __future__ import annotations - from datetime import timedelta from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .coordinator import EasyEnergyConfigEntry, EasyEnergyData @@ -23,9 +22,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: """ if not data.gas_today: return None - return data.gas_today.price_at_time( - data.gas_today.utcnow() + timedelta(hours=hours) - ) + return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours)) async def async_get_config_entry_diagnostics( @@ -40,21 +37,21 @@ async def async_get_config_entry_diagnostics( "title": entry.title, }, "energy_usage": { - "current_hour_price": energy_today.current_usage_price, + "current_hour_price": energy_today.current_price, "next_hour_price": energy_today.price_at_time( - energy_today.utcnow() + timedelta(hours=1) + dt_util.utcnow() + timedelta(hours=1) ), - "average_price": energy_today.average_usage_price, - "max_price": energy_today.extreme_usage_prices[1], - "min_price": energy_today.extreme_usage_prices[0], - "highest_price_time": energy_today.highest_usage_price_time, - "lowest_price_time": energy_today.lowest_usage_price_time, - "percentage_of_max": energy_today.pct_of_max_usage, + "average_price": energy_today.average_price, + "max_price": energy_today.extreme_prices[1], + "min_price": energy_today.extreme_prices[0], + "highest_price_time": energy_today.highest_price_time, + "lowest_price_time": energy_today.lowest_price_time, + "percentage_of_max": energy_today.pct_of_max, }, "energy_return": { "current_hour_price": energy_today.current_return_price, - "next_hour_price": energy_today.price_at_time( - energy_today.utcnow() + timedelta(hours=1), "return" + "next_hour_price": energy_today.return_price_at_time( + dt_util.utcnow() + timedelta(hours=1) ), "average_price": energy_today.average_return_price, "max_price": energy_today.extreme_return_prices[1], diff --git a/homeassistant/components/easyenergy/icons.json b/homeassistant/components/easyenergy/icons.json index b4303b7b360..3e9d57e7736 100644 --- a/homeassistant/components/easyenergy/icons.json +++ b/homeassistant/components/easyenergy/icons.json @@ -1,12 +1,33 @@ { "entity": { "sensor": { + "average_price": { + "default": "mdi:cash-multiple" + }, + "current_hour_price": { + "default": "mdi:cash" + }, + "highest_price_time": { + "default": "mdi:clock-outline" + }, "hours_priced_equal_or_higher": { "default": "mdi:clock" }, "hours_priced_equal_or_lower": { "default": "mdi:clock" }, + "lowest_price_time": { + "default": "mdi:clock-outline" + }, + "max_price": { + "default": "mdi:cash-plus" + }, + "min_price": { + "default": "mdi:cash-minus" + }, + "next_hour_price": { + "default": "mdi:cash" + }, "percentage_of_max": { "default": "mdi:percent" } diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index c987e75e718..2b2195c43bd 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["easyenergy==2.2.0"], + "requirements": ["easyenergy==3.0.1"], "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 35fab870af3..2a327045d1a 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -1,7 +1,5 @@ """Support for easyEnergy sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -24,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import ( @@ -32,6 +31,9 @@ from .coordinator import ( EasyEnergyDataUpdateCoordinator, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EasyEnergySensorEntityDescription(SensorEntityDescription): @@ -63,7 +65,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( service_type="today_energy_usage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.current_usage_price, + value_fn=lambda data: data.energy_today.current_price, ), EasyEnergySensorEntityDescription( key="next_hour_price", @@ -71,7 +73,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( - data.energy_today.utcnow() + timedelta(hours=1) + dt_util.utcnow() + timedelta(hours=1) ), ), EasyEnergySensorEntityDescription( @@ -79,42 +81,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( translation_key="average_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.average_usage_price, + value_fn=lambda data: data.energy_today.average_price, ), EasyEnergySensorEntityDescription( key="max_price", translation_key="max_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.extreme_usage_prices[1], + value_fn=lambda data: data.energy_today.extreme_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", translation_key="min_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.extreme_usage_prices[0], + value_fn=lambda data: data.energy_today.extreme_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", translation_key="highest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.energy_today.highest_usage_price_time, + value_fn=lambda data: data.energy_today.highest_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", translation_key="lowest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.energy_today.lowest_usage_price_time, + value_fn=lambda data: data.energy_today.lowest_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", translation_key="percentage_of_max", service_type="today_energy_usage", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: data.energy_today.pct_of_max_usage, + value_fn=lambda data: data.energy_today.pct_of_max, ), EasyEnergySensorEntityDescription( key="current_hour_price", @@ -129,8 +131,8 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( translation_key="next_hour_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data.energy_today.price_at_time( - data.energy_today.utcnow() + timedelta(hours=1), "return" + value_fn=lambda data: data.energy_today.return_price_at_time( + dt_util.utcnow() + timedelta(hours=1) ), ), EasyEnergySensorEntityDescription( @@ -180,14 +182,14 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( translation_key="hours_priced_equal_or_lower", service_type="today_energy_usage", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage, + value_fn=lambda data: data.energy_today.periods_priced_equal_or_lower, ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_higher", translation_key="hours_priced_equal_or_higher", service_type="today_energy_return", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return, + value_fn=lambda data: data.energy_today.return_periods_priced_equal_or_higher, ), ) @@ -205,9 +207,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: """ if data.gas_today is None: return None - return data.gas_today.price_at_time( - data.gas_today.utcnow() + timedelta(hours=hours) - ) + return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours)) async def async_setup_entry( @@ -244,7 +244,10 @@ class EasyEnergySensorEntity( self.entity_id = ( f"{SENSOR_DOMAIN}.{DOMAIN}_{description.service_type}_{description.key}" ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.service_type}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{description.service_type}_{description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 1ae7d5c5b5a..19a2eb1235e 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -1,13 +1,19 @@ """Services for easyEnergy integration.""" -from __future__ import annotations - -from datetime import date, datetime +from datetime import date, datetime, timedelta from enum import StrEnum from functools import partial from typing import Final -from easyenergy import Electricity, Gas, VatOption +from easyenergy import ( + Electricity, + ElectricityGranularity, + ElectricityPriceType, + Gas, + PriceInterval, + VatOption, +) +from easyenergy.const import MARKET_TIMEZONE import voluptuous as vol from homeassistant.core import ( @@ -28,25 +34,15 @@ ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" ATTR_END: Final = "end" ATTR_INCL_VAT: Final = "incl_vat" +ATTR_GRANULARITY: Final = "granularity" +ATTR_PRICE_TYPE: Final = "price_type" GAS_SERVICE_NAME: Final = "get_gas_prices" ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" -SERVICE_SCHEMA: Final = vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Required(ATTR_INCL_VAT): bool, - vol.Optional(ATTR_START): str, - vol.Optional(ATTR_END): str, - } -) -class PriceType(StrEnum): +class ServicePriceType(StrEnum): """Type of price.""" ENERGY_USAGE = "energy_usage" @@ -54,22 +50,93 @@ class PriceType(StrEnum): GAS = "gas" -def __get_date(date_input: str | None) -> date | datetime: - """Get date.""" +GRANULARITY_OPTIONS: Final = tuple( + granularity.value for granularity in ElectricityGranularity +) +PRICE_TYPE_OPTIONS: Final = tuple( + electricity_price_type.value for electricity_price_type in ElectricityPriceType +) + +BASE_SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) +GAS_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional( + ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value + ): vol.In(PRICE_TYPE_OPTIONS), + } +) +ENERGY_USAGE_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional( + ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value + ): vol.In(GRANULARITY_OPTIONS), + vol.Optional( + ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value + ): vol.In(PRICE_TYPE_OPTIONS), + } +) +ENERGY_RETURN_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend( + { + vol.Optional( + ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value + ): vol.In(GRANULARITY_OPTIONS), + } +) + + +def __get_date( + date_input: str | None, +) -> tuple[date, datetime | None]: + """Get date for the API and optional datetime for response filtering.""" if not date_input: - return dt_util.now().date() + return dt_util.now().date(), None - if value := dt_util.parse_datetime(date_input): - return value + if date_value := dt_util.parse_date(date_input): + return date_value, None - raise ServiceValidationError( - "Invalid datetime provided.", - translation_domain=DOMAIN, - translation_key="invalid_date", - translation_placeholders={ - "date": date_input, - }, - ) + if not (datetime_value := dt_util.parse_datetime(date_input)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + datetime_utc = dt_util.as_utc(datetime_value) + return datetime_utc.astimezone(MARKET_TIMEZONE).date(), datetime_utc + + +def __filter_prices( + prices: list[dict[str, float | datetime]], + intervals: tuple[PriceInterval, ...], + start: datetime, + end: datetime, +) -> list[dict[str, float | datetime]]: + """Filter prices to the requested datetime range.""" + included_timestamps = { + interval.starts_at + for interval in intervals + if interval.ends_at > start and interval.starts_at < end + } + + return [ + timestamp_price + for timestamp_price in prices + if timestamp_price["timestamp"] in included_timestamps + ] def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse: @@ -85,6 +152,19 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp } +def __select_prices( + data: Electricity | Gas, use_invoice: bool +) -> list[dict[str, float | datetime]]: + """Select market or invoice prices from price data.""" + if not use_invoice: + return data.timestamp_prices + + return [ + {"timestamp": interval.starts_at, "price": interval.invoice_price} + for interval in data.intervals + ] + + def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator: """Get the coordinator from the entry.""" entry: EasyEnergyConfigEntry = service.async_get_config_entry( @@ -96,36 +176,60 @@ def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator: async def __get_prices( call: ServiceCall, *, - price_type: PriceType, + service_price_type: ServicePriceType, ) -> ServiceResponse: """Get prices from easyEnergy.""" coordinator = __get_coordinator(call) - start = __get_date(call.data.get(ATTR_START)) - end = __get_date(call.data.get(ATTR_END)) + start_date, start_datetime = __get_date(call.data.get(ATTR_START)) + end_date, end_datetime = __get_date(call.data.get(ATTR_END)) vat = VatOption.INCLUDE if call.data.get(ATTR_INCL_VAT) is False: vat = VatOption.EXCLUDE data: Electricity | Gas + prices: list[dict[str, float | datetime]] - if price_type == PriceType.GAS: + if service_price_type == ServicePriceType.GAS: data = await coordinator.easyenergy.gas_prices( - start_date=start, - end_date=end, + start_date=start_date, + end_date=end_date, + vat=vat, + ) + prices = __select_prices( + data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value + ) + else: + data = await coordinator.easyenergy.energy_prices( + start_date=start_date, + end_date=end_date, + granularity=ElectricityGranularity(call.data[ATTR_GRANULARITY]), vat=vat, ) - return __serialize_prices(data.timestamp_prices) - data = await coordinator.easyenergy.energy_prices( - start_date=start, - end_date=end, - vat=vat, - ) - if price_type == PriceType.ENERGY_USAGE: - return __serialize_prices(data.timestamp_usage_prices) - return __serialize_prices(data.timestamp_return_prices) + if service_price_type == ServicePriceType.ENERGY_USAGE: + prices = __select_prices( + data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value + ) + else: + prices = data.timestamp_return_prices + + if start_datetime or end_datetime: + filter_start = start_datetime or dt_util.as_utc( + dt_util.start_of_local_day(start_date) + ) + filter_end = end_datetime or dt_util.as_utc( + dt_util.start_of_local_day(end_date + timedelta(days=1)) + ) + prices = __filter_prices( + prices, + data.intervals, + filter_start, + filter_end, + ) + + return __serialize_prices(prices) @callback @@ -135,21 +239,21 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, price_type=PriceType.GAS), - schema=SERVICE_SCHEMA, + partial(__get_prices, service_price_type=ServicePriceType.GAS), + schema=GAS_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_USAGE_SERVICE_NAME, - partial(__get_prices, price_type=PriceType.ENERGY_USAGE), - schema=SERVICE_SCHEMA, + partial(__get_prices, service_price_type=ServicePriceType.ENERGY_USAGE), + schema=ENERGY_USAGE_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_RETURN_SERVICE_NAME, - partial(__get_prices, price_type=PriceType.ENERGY_RETURN), - schema=SERVICE_SCHEMA, + partial(__get_prices, service_price_type=ServicePriceType.ENERGY_RETURN), + schema=ENERGY_RETURN_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/easyenergy/services.yaml b/homeassistant/components/easyenergy/services.yaml index 63187256f00..097edd33ab6 100644 --- a/homeassistant/components/easyenergy/services.yaml +++ b/homeassistant/components/easyenergy/services.yaml @@ -10,6 +10,15 @@ get_gas_prices: default: true selector: boolean: + price_type: + required: false + default: market + selector: + select: + translation_key: price_type_selector + options: + - market + - invoice start: required: false example: "2024-01-01 00:00:00" @@ -32,6 +41,24 @@ get_energy_usage_prices: default: true selector: boolean: + granularity: + required: false + default: hour + selector: + select: + translation_key: granularity_selector + options: + - hour + - quarter + price_type: + required: false + default: market + selector: + select: + translation_key: price_type_selector + options: + - market + - invoice start: required: false example: "2024-01-01 00:00:00" @@ -49,6 +76,15 @@ get_energy_return_prices: selector: config_entry: integration: easyenergy + granularity: + required: false + default: hour + selector: + select: + translation_key: granularity_selector + options: + - hour + - quarter start: required: false example: "2024-01-01 00:00:00" diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index b82b6e8744b..06d6ab25da8 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "step": { "user": { "description": "[%key:common::config_flow::description::confirm_setup%]" @@ -44,10 +47,27 @@ } }, "exceptions": { + "connection_error": { + "message": "Error communicating with the easyEnergy API." + }, "invalid_date": { "message": "Invalid date provided. Got {date}" } }, + "selector": { + "granularity_selector": { + "options": { + "hour": "Hour", + "quarter": "Quarter" + } + }, + "price_type_selector": { + "options": { + "invoice": "All-in price", + "market": "Market price" + } + } + }, "services": { "get_energy_return_prices": { "description": "Requests return energy prices from easyEnergy.", @@ -60,6 +80,10 @@ "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]", "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]" }, + "granularity": { + "description": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::description%]", + "name": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::name%]" + }, "start": { "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]", "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]" @@ -78,10 +102,18 @@ "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]", "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]" }, + "granularity": { + "description": "The interval size for the electricity prices.", + "name": "Granularity" + }, "incl_vat": { "description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]", "name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]" }, + "price_type": { + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::description%]", + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::name%]" + }, "start": { "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]", "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]" @@ -104,6 +136,10 @@ "description": "Whether the prices should include VAT.", "name": "VAT included" }, + "price_type": { + "description": "The type of prices to retrieve.", + "name": "Price type" + }, "start": { "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted.", "name": "Start" diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index a7628e78a9a..56352312262 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -3,8 +3,6 @@ Get data from 'My Usage Page' page: https://client.ebox.ca/myusage """ -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index 6f6c536f75d..31fe24312e8 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,7 +1,5 @@ """Constants for ebus component.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.components.sensor import SensorDeviceClass diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index a69a0343220..be19382225d 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -1,7 +1,5 @@ """Support for Ebusd sensors.""" -from __future__ import annotations - import datetime import logging from typing import Any, cast diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py index 4ce52d283fc..0d388253ce1 100644 --- a/homeassistant/components/ecoal_boiler/sensor.py +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -1,7 +1,5 @@ """Allows reading temperatures from ecoal/esterownik.pl controller.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ecoal_boiler/switch.py b/homeassistant/components/ecoal_boiler/switch.py index 7fede88bc2b..88b5892f42e 100644 --- a/homeassistant/components/ecoal_boiler/switch.py +++ b/homeassistant/components/ecoal_boiler/switch.py @@ -1,7 +1,5 @@ """Allows to configuration ecoal (esterownik.pl) pumps as switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 080d269baa4..d09af504313 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -8,12 +8,16 @@ from pyecobee import ( ECOBEE_REFRESH_TOKEN, ECOBEE_USERNAME, Ecobee, + EcobeeAuthFailedError, + EcobeeAuthMfaRequiredError, + EcobeeAuthUnknownError, ExpiredTokenError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.util import Throttle from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS @@ -102,7 +106,26 @@ class EcobeeData: async def refresh(self) -> bool: """Refresh ecobee tokens and update config entry.""" _LOGGER.debug("Refreshing ecobee tokens and updating config entry") - if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens): + try: + success = await self._hass.async_add_executor_job( + self.ecobee.refresh_tokens + ) + except EcobeeAuthMfaRequiredError as err: + raise ConfigEntryAuthFailed( + "ecobee account requires MFA; reauthentication needed" + ) from err + except EcobeeAuthFailedError as err: + if self.ecobee.config.get(ECOBEE_USERNAME): + raise ConfigEntryAuthFailed( + "ecobee rejected stored credentials" + ) from err + _LOGGER.error("Ecobee rejected stored credentials: %s", err) + return False + except EcobeeAuthUnknownError: + _LOGGER.exception("Unexpected error refreshing ecobee tokens") + return False + + if success: data = {} if self.ecobee.config.get(ECOBEE_API_KEY): data = { diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 76b3399ec6e..da00f8e9742 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Ecobee binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 62bb3886107..1eb713eadb3 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -1,7 +1,5 @@ """Support for Ecobee Thermostats.""" -from __future__ import annotations - import collections from typing import Any @@ -286,6 +284,7 @@ async def async_setup_entry( thermostat.schedule_update_ha_state(True) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_CREATE_VACATION, @@ -293,6 +292,7 @@ async def async_setup_entry( schema=CREATE_VACATION_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_DELETE_VACATION, @@ -300,6 +300,7 @@ async def async_setup_entry( schema=DELETE_VACATION_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, @@ -307,6 +308,7 @@ async def async_setup_entry( schema=SET_FAN_MIN_ON_TIME_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_RESUME_PROGRAM, @@ -465,7 +467,7 @@ class Thermostat(ClimateEntity): @property def has_humidifier_control(self) -> bool: - """Return true if humidifier connected to thermostat and set to manual/on mode.""" + """Return true if humidifier connected to thermostat and manual/on.""" return ( bool(self.settings.get("hasHumidifier")) and self.settings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE @@ -890,7 +892,8 @@ class Thermostat(ClimateEntity): current_sensors_in_climate = self._sensors_in_preset_mode(preset_mode) if set(sensor_names) == set(current_sensors_in_climate): _LOGGER.debug( - "This action would not be an update, current sensors on climate (%s) are: %s", + "This action would not be an update, current sensors" + " on climate (%s) are: %s", preset_mode, ", ".join(current_sensors_in_climate), ) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index 2340cb56140..48fa8753843 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -1,12 +1,22 @@ """Config flow to configure ecobee.""" +from collections.abc import Mapping from typing import Any -from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee +from pyecobee import ( + ECOBEE_API_KEY, + ECOBEE_PASSWORD, + ECOBEE_USERNAME, + Ecobee, + EcobeeAuthFailedError, + EcobeeAuthMfaRequiredError, + EcobeeAuthUnknownError, + MfaChallenge, +) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_CODE, CONF_PASSWORD, CONF_USERNAME from .const import CONF_REFRESH_TOKEN, DOMAIN @@ -18,6 +28,9 @@ _USER_SCHEMA = vol.Schema( } ) +_MFA_SCHEMA = vol.Schema({vol.Required(CONF_CODE): str}) +_REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle an ecobee config flow.""" @@ -25,12 +38,15 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 _ecobee: Ecobee + _mfa_challenge: MfaChallenge | None = None + _pending_username: str | None = None + _pending_password: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: api_key = user_input.get(CONF_API_KEY) @@ -38,27 +54,34 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): password = user_input.get(CONF_PASSWORD) if api_key and not (username or password): - # Use the user-supplied API key to attempt to obtain a PIN from ecobee. self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key}) if await self.hass.async_add_executor_job(self._ecobee.request_pin): - # We have a PIN; move to the next step of the flow. return await self.async_step_authorize() errors["base"] = "pin_request_failed" elif username and password and not api_key: + self._pending_username = username + self._pending_password = password self._ecobee = Ecobee( config={ ECOBEE_USERNAME: username, ECOBEE_PASSWORD: password, } ) - if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens): - config = { - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_REFRESH_TOKEN: self._ecobee.refresh_token, - } - return self.async_create_entry(title=DOMAIN, data=config) - errors["base"] = "login_failed" + try: + success = await self.hass.async_add_executor_job( + self._ecobee.refresh_tokens + ) + except EcobeeAuthMfaRequiredError as err: + self._mfa_challenge = err.args[0] + return await self.async_step_mfa() + except EcobeeAuthFailedError: + errors["base"] = "invalid_auth" + except EcobeeAuthUnknownError: + errors["base"] = "unknown" + else: + if success: + return self._async_create_or_update_entry() + errors["base"] = "login_failed" else: errors["base"] = "invalid_auth" @@ -68,16 +91,46 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_mfa( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect an MFA OTP code and complete the login.""" + assert self._mfa_challenge is not None + errors: dict[str, str] = {} + + if user_input is not None: + code = user_input[CONF_CODE].strip() + if not code: + errors["base"] = "invalid_mfa_code" + else: + try: + success = await self.hass.async_add_executor_job( + self._ecobee.submit_mfa_code, self._mfa_challenge, code + ) + except EcobeeAuthFailedError: + errors["base"] = "invalid_mfa_code" + except EcobeeAuthUnknownError: + errors["base"] = "unknown" + else: + if success: + return self._async_create_or_update_entry() + errors["base"] = "invalid_mfa_code" + + return self.async_show_form( + step_id="mfa", + data_schema=_MFA_SCHEMA, + errors=errors, + description_placeholders={"mfa_type": self._mfa_challenge.mfa_type}, + ) + async def async_step_authorize( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Present the user with the PIN so that the app can be authorized on ecobee.com.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - # Attempt to obtain tokens from ecobee and finish the flow. if await self.hass.async_add_executor_job(self._ecobee.request_tokens): - # Refresh token obtained; create the config entry. config = { CONF_API_KEY: self._ecobee.api_key, CONF_REFRESH_TOKEN: self._ecobee.refresh_token, @@ -93,3 +146,61 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): "auth_url": "https://www.ecobee.com/consumerportal/index.html", }, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an ecobee authentication error.""" + self._pending_username = entry_data.get(CONF_USERNAME) + self._pending_password = entry_data.get(CONF_PASSWORD) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Re-run the web login. May surface a fresh MFA challenge.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._pending_password = user_input[CONF_PASSWORD] + self._ecobee = Ecobee( + config={ + ECOBEE_USERNAME: self._pending_username, + ECOBEE_PASSWORD: self._pending_password, + } + ) + try: + success = await self.hass.async_add_executor_job( + self._ecobee.refresh_tokens + ) + except EcobeeAuthMfaRequiredError as err: + self._mfa_challenge = err.args[0] + return await self.async_step_mfa() + except EcobeeAuthFailedError: + errors["base"] = "invalid_auth" + except EcobeeAuthUnknownError: + errors["base"] = "unknown" + else: + if success: + return self._async_create_or_update_entry() + errors["base"] = "login_failed" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=_REAUTH_SCHEMA, + errors=errors, + description_placeholders={"username": self._pending_username or ""}, + ) + + def _async_create_or_update_entry(self) -> ConfigFlowResult: + """Create a new entry or update the existing one on reauth.""" + data = { + CONF_USERNAME: self._pending_username, + CONF_PASSWORD: self._pending_password, + CONF_REFRESH_TOKEN: self._ecobee.refresh_token, + } + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + return self.async_create_entry(title=DOMAIN, data=data) diff --git a/homeassistant/components/ecobee/entity.py b/homeassistant/components/ecobee/entity.py index 08ec1968999..ebc3706ab53 100644 --- a/homeassistant/components/ecobee/entity.py +++ b/homeassistant/components/ecobee/entity.py @@ -1,7 +1,5 @@ """Base classes shared among Ecobee entities.""" -from __future__ import annotations - import logging from typing import Any @@ -17,6 +15,8 @@ _LOGGER = logging.getLogger(__name__) class EcobeeBaseEntity(Entity): """Base methods for Ecobee entities.""" + _attr_has_entity_name = True + def __init__(self, data: EcobeeData, thermostat_index: int) -> None: """Initiate base methods for Ecobee entities.""" self.data = data diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index a6f3c16f84a..eee649b5961 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -1,7 +1,5 @@ """Support for using humidifier with ecobee thermostats.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/ecobee/icons.json b/homeassistant/components/ecobee/icons.json index fba0696eb08..e4764206da0 100644 --- a/homeassistant/components/ecobee/icons.json +++ b/homeassistant/components/ecobee/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "number": { + "fan_min_on_time": { + "default": "mdi:fan-clock" + } + } + }, "services": { "create_vacation": { "service": "mdi:umbrella-beach" diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 12a5314ad2a..b1870106fe5 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyecobee"], - "requirements": ["python-ecobee-api==0.3.2"], + "requirements": ["python-ecobee-api==0.4.0"], "single_config_entry": true, "zeroconf": [ { diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 2cf6a30acd7..0670a53b6ff 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -1,7 +1,5 @@ """Support for Ecobee Send Message service.""" -from __future__ import annotations - from homeassistant.components.notify import NotifyEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,7 +24,6 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity): """Implement the notification entity for the Ecobee thermostat.""" _attr_name = None - _attr_has_entity_name = True def __init__(self, data: EcobeeData, thermostat_index: int) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 50e9170394d..e7034ad04a0 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -1,7 +1,5 @@ """Support for using number with ecobee thermostats.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging @@ -76,11 +74,15 @@ async def async_setup_entry( ) ) + entities.extend( + EcobeeFanMinOnTime(data, index) for index in range(len(data.ecobee.thermostats)) + ) + async_add_entities(entities, True) class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): - """A number class, representing min time for an ecobee thermostat with ventilator attached.""" + """Represent min time for an ecobee thermostat with ventilator.""" entity_description: EcobeeNumberEntityDescription @@ -88,7 +90,6 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): _attr_native_max_value = 60 _attr_native_step = 5 _attr_native_unit_of_measurement = UnitOfTime.MINUTES - _attr_has_entity_name = True def __init__( self, @@ -132,7 +133,6 @@ class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity): """ _attr_device_class = NumberDeviceClass.TEMPERATURE - _attr_has_entity_name = True _attr_icon = "mdi:thermometer-off" _attr_mode = NumberMode.BOX _attr_native_min_value = -25 @@ -167,3 +167,39 @@ class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity): """Set new compressor minimum temperature.""" self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value) self.update_without_throttle = True + + +class EcobeeFanMinOnTime(EcobeeBaseEntity, NumberEntity): + """Minimum minutes per hour that the fan must run on an ecobee thermostat.""" + + _attr_native_min_value = 0 + _attr_native_max_value = 60 + _attr_native_step = 5 + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_translation_key = "fan_min_on_time" + + def __init__( + self, + data: EcobeeData, + thermostat_index: int, + ) -> None: + """Initialize ecobee fan minimum on time.""" + super().__init__(data, thermostat_index) + self._attr_unique_id = f"{self.base_unique_id}_fan_min_on_time" + self.update_without_throttle = False + + async def async_update(self) -> None: + """Get the latest state from the thermostat.""" + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() + self._attr_native_value = self.thermostat["settings"]["fanMinOnTime"] + + def set_native_value(self, value: float) -> None: + """Set new fan minimum on time value.""" + step = self._attr_native_step + aligned_value = int(round(value / step) * step) + self.data.ecobee.set_fan_min_on_time(self.thermostat_index, aligned_value) + self.update_without_throttle = True diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 759f167ec1c..53e98ebdfa7 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,7 +1,5 @@ """Support for Ecobee sensors.""" -from __future__ import annotations - from dataclasses import dataclass from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 62ab46aad9d..207549d5d38 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -1,18 +1,33 @@ { "config": { "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "The MFA code was not accepted by ecobee; please try again.", "login_failed": "Error authenticating with ecobee; please verify your credentials are correct.", "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", - "token_request_failed": "Error requesting tokens from ecobee; please try again." + "token_request_failed": "Error requesting tokens from ecobee; please try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "authorize": { "description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**." }, + "mfa": { + "data": { + "code": "MFA code" + }, + "description": "ecobee requires multi-factor authentication. Enter the {mfa_type} code from your authenticator app." + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Reauthenticate the ecobee account for **{username}**." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -37,6 +52,9 @@ "compressor_protection_min_temp": { "name": "Compressor minimum temperature" }, + "fan_min_on_time": { + "name": "Fan minimum on time" + }, "ventilator_min_type_away": { "name": "Ventilator minimum time away" }, diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index e0848913b39..d406680cb5a 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -1,7 +1,5 @@ """Support for using switch with ecobee thermostats.""" -from __future__ import annotations - from datetime import tzinfo import logging from typing import Any @@ -53,9 +51,8 @@ async def async_setup_entry( class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): - """A Switch class, representing 20 min timer for an ecobee thermostat with ventilator attached.""" + """Represent 20 min timer for an ecobee thermostat with ventilator.""" - _attr_has_entity_name = True _attr_name = "Ventilator 20m Timer" def __init__( @@ -106,7 +103,6 @@ class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): class EcobeeSwitchAuxHeatOnly(EcobeeBaseEntity, SwitchEntity): """Representation of a aux_heat_only ecobee switch.""" - _attr_has_entity_name = True _attr_translation_key = "aux_heat_only" def __init__( diff --git a/homeassistant/components/ecobee/util.py b/homeassistant/components/ecobee/util.py index e2e607c84d0..8dbc11c8cf6 100644 --- a/homeassistant/components/ecobee/util.py +++ b/homeassistant/components/ecobee/util.py @@ -26,9 +26,10 @@ def ecobee_time(time_string): def is_indefinite_hold(start_date_string: str, end_date_string: str) -> bool: - """Determine if the given start and end dates from the ecobee API represent an indefinite hold. + """Determine if the ecobee API dates represent an indefinite hold. - This is not documented in the API, so a rough heuristic is used where a hold over 1 year is considered indefinite. + This is not documented in the API, so a rough heuristic is + used where a hold over 1 year is considered indefinite. """ return date.fromisoformat(end_date_string) - date.fromisoformat( start_date_string diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 8c918db3038..4b239300d0b 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -1,7 +1,5 @@ """Support for displaying weather info from Ecobee API.""" -from __future__ import annotations - from datetime import timedelta from pyecobee.const import ECOBEE_STATE_UNKNOWN diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index e5350beba8e..b4ed673351b 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -1,7 +1,5 @@ """The Ecoforest integration.""" -from __future__ import annotations - import logging import httpx diff --git a/homeassistant/components/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py index 9c0f15f390b..07ce39ce40e 100644 --- a/homeassistant/components/ecoforest/config_flow.py +++ b/homeassistant/components/ecoforest/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ecoforest integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ecoforest/entity.py b/homeassistant/components/ecoforest/entity.py index 539b0e55e19..0533a1d31ed 100644 --- a/homeassistant/components/ecoforest/entity.py +++ b/homeassistant/components/ecoforest/entity.py @@ -1,7 +1,5 @@ """Base Entity for Ecoforest.""" -from __future__ import annotations - from pyecoforest.models.device import Device from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index c1d5f5f3055..321d7655916 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -1,7 +1,5 @@ """Support for Ecoforest number platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index d0e4c17abe1..63c64954e33 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -1,7 +1,5 @@ """Support for Ecoforest sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index bd83bfc9ee5..ad4f3b3f7e6 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -1,7 +1,5 @@ """Switch platform for Ecoforest.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index b9bcd72dd28..311ed0d31f0 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Rheem EcoNet water heaters.""" -from __future__ import annotations - from pyeconet.equipment import Equipment, EquipmentType from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/econet/select.py b/homeassistant/components/econet/select.py index 35d5e55d679..1369e0b7c35 100644 --- a/homeassistant/components/econet/select.py +++ b/homeassistant/components/econet/select.py @@ -1,7 +1,5 @@ """Support for Rheem EcoNet thermostats with variable fan speeds and fan modes.""" -from __future__ import annotations - from pyeconet.equipment import EquipmentType from pyeconet.equipment.thermostat import Thermostat, ThermostatFanMode diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 1cc806ca8d5..b19c4cd98fc 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -1,7 +1,5 @@ """Support for Rheem EcoNet water heaters.""" -from __future__ import annotations - from pyeconet.equipment import Equipment, EquipmentType from homeassistant.components.sensor import ( diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index a19100baf9c..7794be8e4b4 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -1,7 +1,5 @@ """Support for using switch with ecoNet thermostats.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 2637dbbddf8..ac0043557da 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ecovacs mqtt integration.""" -from __future__ import annotations - from functools import partial import logging import ssl @@ -139,10 +137,6 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - - if not self.show_advanced_options: - return await self.async_step_auth() - if user_input: self._mode = user_input[CONF_MODE] return await self.async_step_auth() diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 127262f00bf..cb94505e4a7 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -1,7 +1,5 @@ """Controller module.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from functools import partial diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py index 22a55d9c6ab..6a8a3716c55 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -1,7 +1,5 @@ """Ecovacs diagnostics.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 85a788d7afe..2e1ff2fc9bc 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -1,7 +1,5 @@ """Ecovacs mqtt entity module.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py index b9af67fafcd..0ae37674753 100644 --- a/homeassistant/components/ecovacs/lawn_mower.py +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -1,7 +1,5 @@ """Ecovacs mower entity.""" -from __future__ import annotations - import logging from deebot_client.capabilities import Capabilities, DeviceType diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 878e86888e1..a0c1997bc2f 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==18.1.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index e8cefbd6d1f..201a80f458f 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -1,7 +1,5 @@ """Ecovacs number module.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index c0265dd6def..c0219cdbf1e 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -174,7 +174,8 @@ class EcovacsActiveMapSelectEntity( if self._attr_current_option not in self._option_to_id: self._attr_current_option = None - # Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order. + # Sort named maps first, then numeric IDs + # (unnamed maps during building) in ascending order. self._attr_options = sorted( self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower()) ) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index b368b92a579..c5d88900b23 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -1,7 +1,5 @@ """Ecovacs sensor module.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ecovacs/services.py b/homeassistant/components/ecovacs/services.py index 1b37ddd2a48..9d541f2d5aa 100644 --- a/homeassistant/components/ecovacs/services.py +++ b/homeassistant/components/ecovacs/services.py @@ -1,7 +1,5 @@ """Ecovacs services.""" -from __future__ import annotations - from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.core import HomeAssistant, SupportsResponse, callback from homeassistant.helpers import service diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 40a7776e83b..f8d6066fcbd 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -294,6 +294,9 @@ "vacuum_raw_get_positions_not_supported": { "message": "Retrieving the positions of the chargers and the device itself is not supported" }, + "vacuum_send_command_not_supported": { + "message": "The {command} command is not supported by {name}" + }, "vacuum_send_command_params_dict": { "message": "Params must be a dictionary and not a list" }, diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index d26bd1981d7..b5c6cb84461 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -1,7 +1,5 @@ """Ecovacs util functions.""" -from __future__ import annotations - from enum import Enum import random import string diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 19ddfa0562f..e7fa8f32db0 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -1,7 +1,5 @@ """Support for Ecovacs Ecovacs Vacuums.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -357,8 +355,8 @@ class EcovacsVacuum( name = info.get("nick", info["name"]) raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="vacuum_send_command_area_not_supported", - translation_placeholders={"name": name}, + translation_key="vacuum_send_command_not_supported", + translation_placeholders={"command": command, "name": name}, ) if command == "spot_area": @@ -415,9 +413,11 @@ class EcovacsVacuum( """Get the segments that can be cleaned.""" last_seen = self.last_seen_segments or [] if self._room_event is None or not self._maps: - # If we don't have the necessary information to determine segments, return the last - # seen segments to avoid temporarily losing all segments until we get the necessary - # information, which could cause unnecessary issues to be created + # If we don't have the necessary information to + # determine segments, return the last seen segments to + # avoid temporarily losing all segments until we get + # the necessary information, which could cause + # unnecessary issues to be created return last_seen map_id = self._room_event.map_id @@ -431,8 +431,9 @@ class EcovacsVacuum( for map_obj in self._maps.values() if map_obj.id != self._room_event.map_id } - # Include segments from the current map and any segments from other maps that were - # previously seen, as we want to continue showing segments from other maps for + # Include segments from the current map and any segments + # from other maps that were previously seen, as we want + # to continue showing segments from other maps for # mapping purposes segments = [ seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids @@ -488,7 +489,8 @@ class EcovacsVacuum( if not valid_room_ids: _LOGGER.warning( - "No valid segments to clean after validation, skipping clean segments command" + "No valid segments to clean after validation," + " skipping clean segments command" ) return diff --git a/homeassistant/components/ecowitt/__init__.py b/homeassistant/components/ecowitt/__init__.py index 3097160f463..ea9c3e3150f 100644 --- a/homeassistant/components/ecowitt/__init__.py +++ b/homeassistant/components/ecowitt/__init__.py @@ -1,7 +1,5 @@ """The Ecowitt Weather Station Component.""" -from __future__ import annotations - from aioecowitt import EcoWittListener from aiohttp import web diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index 1d36f5232db..366e16f20ba 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcowittConfigEntry from .entity import EcowittEntity +PARALLEL_UPDATES = 0 + ECOWITT_BINARYSENSORS_MAPPING: Final = { EcoWittSensorTypes.LEAK: BinarySensorEntityDescription( key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE diff --git a/homeassistant/components/ecowitt/config_flow.py b/homeassistant/components/ecowitt/config_flow.py index b131cbea6ae..943d4ee1901 100644 --- a/homeassistant/components/ecowitt/config_flow.py +++ b/homeassistant/components/ecowitt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ecowitt.""" -from __future__ import annotations - import secrets from typing import Any diff --git a/homeassistant/components/ecowitt/diagnostics.py b/homeassistant/components/ecowitt/diagnostics.py index 4c0afa25e0c..e936eb92a98 100644 --- a/homeassistant/components/ecowitt/diagnostics.py +++ b/homeassistant/components/ecowitt/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for EcoWitt.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index 114270e98a6..67deccc3289 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -1,7 +1,5 @@ """The Ecowitt Weather Station Entity.""" -from __future__ import annotations - import time from aioecowitt import EcoWittSensor diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 296490511cb..0f8b61e3587 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -1,7 +1,5 @@ """Support for Ecowitt Weather Stations.""" -from __future__ import annotations - import dataclasses from datetime import datetime import logging @@ -40,6 +38,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from . import EcowittConfigEntry from .entity import EcowittEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -213,11 +213,13 @@ ECOWITT_SENSORS_MAPPING: Final = { ), EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( key="LIGHTNING_DISTANCE_KM", + device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES: SensorEntityDescription( key="LIGHTNING_DISTANCE_MILES", + device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/edifier_infrared/__init__.py b/homeassistant/components/edifier_infrared/__init__.py new file mode 100644 index 00000000000..a319fa77e39 --- /dev/null +++ b/homeassistant/components/edifier_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Edifier infrared integration for Home Assistant.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Edifier IR from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload an Edifier IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/edifier_infrared/config_flow.py b/homeassistant/components/edifier_infrared/config_flow.py new file mode 100644 index 00000000000..64e662e75e6 --- /dev/null +++ b/homeassistant/components/edifier_infrared/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for Edifier infrared integration.""" + +from typing import Any + +from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_MODEL +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, DOMAIN + + +class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Edifier IR.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - select IR entity and speaker model.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID] + model = EdifierModel(user_input[CONF_MODEL]) + command_set = MODEL_TO_COMMAND_SET[model] + + await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}") + self._abort_if_unique_id_configured() + + entity_name = infrared_entity_id + if state := self.hass.states.get(infrared_entity_id): + entity_name = state.name or infrared_entity_id + + return self.async_create_entry( + title=f"Edifier {model.value} via {entity_name}", + data={ + CONF_INFRARED_ENTITY_ID: infrared_entity_id, + CONF_MODEL: model.value, + CONF_COMMAND_SET: command_set.value, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids + ) + ), + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=[model.value for model in EdifierModel], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py new file mode 100644 index 00000000000..057f71a7c51 --- /dev/null +++ b/homeassistant/components/edifier_infrared/const.py @@ -0,0 +1,19 @@ +"""Constants for the Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode +from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode +from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode +from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode + +DOMAIN = "edifier_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_COMMAND_SET = "command_set" + +type EdifierCode = ( + EdifierR1700BTCode + | EdifierR1280DBCode + | EdifierR1280TCode + | EdifierS360DBCode + | EdifierRC20GCode +) diff --git a/homeassistant/components/edifier_infrared/entity.py b/homeassistant/components/edifier_infrared/entity.py new file mode 100644 index 00000000000..6d483f56e78 --- /dev/null +++ b/homeassistant/components/edifier_infrared/entity.py @@ -0,0 +1,27 @@ +"""Common entity for Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.models import EdifierModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class EdifierIrEntity(Entity): + """Edifier IR base entity providing common device info.""" + + _attr_has_entity_name = True + + def __init__( + self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str + ) -> None: + """Initialize Edifier IR entity.""" + self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"Edifier {model.value}", + manufacturer="Edifier", + model=model.value, + ) diff --git a/homeassistant/components/edifier_infrared/manifest.json b/homeassistant/components/edifier_infrared/manifest.json new file mode 100644 index 00000000000..38f3c96ce75 --- /dev/null +++ b/homeassistant/components/edifier_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "edifier_infrared", + "name": "Edifier Infrared", + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/edifier_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py new file mode 100644 index 00000000000..dfd3559d2c1 --- /dev/null +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -0,0 +1,174 @@ +"""Media player platform for Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel +from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode +from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode +from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode +from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode +from .entity import EdifierIrEntity + +PARALLEL_UPDATES = 1 + + +COMMAND_SET_COMMANDS: dict[ + EdifierCommandSet, + dict[ + MediaPlayerEntityFeature, + tuple[EdifierCode | tuple[EdifierCode, ...], ...], + ], +] = { + EdifierCommandSet.R1700BT: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1700BTCode.VOLUME_UP,), + (EdifierR1700BTCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,), + }, + EdifierCommandSet.R1280DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1280DBCode.VOLUME_UP,), + (EdifierR1280DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,), + }, + EdifierCommandSet.R1280T: { + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1280TCode.VOLUME_UP,), + (EdifierR1280TCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,), + }, + EdifierCommandSet.S360DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS360DBCode.VOLUME_UP,), + (EdifierS360DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,), + }, + EdifierCommandSet.RC20G: { + MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT), + (EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), + }, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Edifier IR media player.""" + infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET]) + model = EdifierModel(entry.data[CONF_MODEL]) + async_add_entities( + [EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)] + ) + + +class EdifierIrMediaPlayer( + EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity +): + """Edifier IR media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__( + self, + entry: ConfigEntry, + model: EdifierModel, + infrared_entity_id: str, + command_set: EdifierCommandSet, + ) -> None: + """Initialize Edifier IR media player.""" + super().__init__(entry, model, unique_id_suffix="media_player") + self._infrared_emitter_entity_id = infrared_entity_id + self._commands = COMMAND_SET_COMMANDS[command_set] + self._attr_state = MediaPlayerState.ON + self._attr_supported_features = MediaPlayerEntityFeature(0) + for feature in self._commands: + self._attr_supported_features |= feature + + async def _send_codes(self, *codes: EdifierCode) -> None: + """Send one or more IR commands.""" + for code in codes: + await self._send_command(code.to_command()) + + async def async_turn_on(self) -> None: + """Turn on the speaker.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON]) + + async def async_turn_off(self) -> None: + """Turn off the speaker.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF]) + + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0]) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1]) + + async def async_mute_volume(self, mute: bool) -> None: + """Send mute command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE]) + + async def async_media_play(self) -> None: + """Send play command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY]) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE]) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK]) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK]) diff --git a/homeassistant/components/edifier_infrared/quality_scale.yaml b/homeassistant/components/edifier_infrared/quality_scale.yaml new file mode 100644 index 00000000000..839ccdab171 --- /dev/null +++ b/homeassistant/components/edifier_infrared/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: done + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + Discovery is not supported for infrared integrations. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: + status: exempt + comment: | + The media player entity is the primary entity and does not need a category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + The media player entity is the primary entity and should be enabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json new file mode 100644 index 00000000000..332ec2ddff0 --- /dev/null +++ b/homeassistant/components/edifier_infrared/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "This Edifier device has already been configured with this transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "infrared_entity_id": "IR transmitter", + "model": "Speaker model" + }, + "data_description": { + "infrared_entity_id": "Select the infrared transmitter entity to use.", + "model": "Choose your Edifier speaker model from the list." + }, + "description": "Configure your Edifier speaker for IR control.", + "title": "Set up Edifier IR speaker" + } + } + } +} diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index ccf439059b1..82e2d8b4f99 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -1,7 +1,5 @@ """Support for Edimax switches.""" -from __future__ import annotations - from typing import Any from pyedimax.smartplug import SmartPlug diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py index 5a5db507bff..0793efbd57f 100644 --- a/homeassistant/components/edl21/config_flow.py +++ b/homeassistant/components/edl21/config_flow.py @@ -3,12 +3,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import SerialPortSelector from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_SERIAL_PORT): str, + vol.Required(CONF_SERIAL_PORT): SerialPortSelector(), } ) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 28b61c4c0e1..d9d25c06d98 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -4,8 +4,8 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/edl21", - "integration_type": "hub", + "integration_type": "device", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.1.5"] + "requirements": ["pysml==0.1.7"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 3194781d71c..a4a3c647b32 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -1,9 +1,6 @@ """Support for EDL21 Smart Meters.""" -from __future__ import annotations - from collections.abc import Mapping -from datetime import timedelta from typing import Any from sml import SmlGetListResponse @@ -31,7 +28,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import utcnow from .const import ( CONF_SERIAL_PORT, @@ -41,8 +37,6 @@ from .const import ( SIGNAL_EDL21_TELEGRAM, ) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - # OBIS format: A-B:C.D.E*F SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity @@ -393,8 +387,6 @@ class EDL21Entity(SensorEntity): self._electricity_id = electricity_id self._obis = obis self._telegram = telegram - self._min_time = MIN_TIME_BETWEEN_UPDATES - self._last_update = utcnow() self._async_remove_dispatcher = None self.entity_description = entity_description self._attr_unique_id = f"{electricity_id}_{obis}" @@ -416,12 +408,7 @@ class EDL21Entity(SensorEntity): if self._telegram == telegram: return - now = utcnow() - if now - self._last_update < self._min_time: - return - self._telegram = telegram - self._last_update = now self.async_write_ha_state() self._async_remove_dispatcher = async_dispatcher_connect( diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index bec28c13003..196d14c0946 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -6,7 +6,10 @@ "step": { "user": { "data": { - "serial_port": "[%key:common::config_flow::data::usb_path%]" + "serial_port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "serial_port": "Serial port path to connect to" }, "title": "Add your EDL21 smart meter" } diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index fd5aa930027..c7475a8b319 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -1,7 +1,5 @@ """The Efergy integration.""" -from __future__ import annotations - from pyefergy import Efergy, exceptions from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py index 5b132211587..e32d710b44b 100644 --- a/homeassistant/components/efergy/config_flow.py +++ b/homeassistant/components/efergy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Efergy integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/efergy/entity.py b/homeassistant/components/efergy/entity.py index 4cbe44d1c10..796c77a6452 100644 --- a/homeassistant/components/efergy/entity.py +++ b/homeassistant/components/efergy/entity.py @@ -1,7 +1,5 @@ """The Efergy integration.""" -from __future__ import annotations - from pyefergy import Efergy from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6b54e4779a0..99aa711e7e8 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,7 +1,5 @@ """Support for Efergy sensors.""" -from __future__ import annotations - import dataclasses from re import sub from typing import cast diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index 9ebe8c1704e..acaf100fd91 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -1,7 +1,5 @@ """Interfaces with Egardia/Woonveilig alarm control panel.""" -from __future__ import annotations - import logging from pythonegardia.egardiadevice import EgardiaDevice @@ -134,6 +132,7 @@ class EgardiaAlarm(AlarmControlPanelEntity): """Send disarm command.""" try: self._egardiasystem.alarm_disarm() + # pylint: disable-next=home-assistant-action-swallowed-exception except requests.exceptions.RequestException as err: _LOGGER.error( "Egardia device exception occurred when sending disarm command: %s", @@ -144,6 +143,7 @@ class EgardiaAlarm(AlarmControlPanelEntity): """Send arm home command.""" try: self._egardiasystem.alarm_arm_home() + # pylint: disable-next=home-assistant-action-swallowed-exception except requests.exceptions.RequestException as err: _LOGGER.error( "Egardia device exception occurred when sending arm home command: %s", @@ -154,6 +154,7 @@ class EgardiaAlarm(AlarmControlPanelEntity): """Send arm away command.""" try: self._egardiasystem.alarm_arm_away() + # pylint: disable-next=home-assistant-action-swallowed-exception except requests.exceptions.RequestException as err: _LOGGER.error( "Egardia device exception occurred when sending arm away command: %s", diff --git a/homeassistant/components/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py index 3b3e68f51f9..3f41661d0fc 100644 --- a/homeassistant/components/egardia/binary_sensor.py +++ b/homeassistant/components/egardia/binary_sensor.py @@ -1,7 +1,5 @@ """Interfaces with Egardia/Woonveilig alarm control panel.""" -from __future__ import annotations - from pythonegardia.egardiadevice import EgardiaDevice from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/egauge/__init__.py b/homeassistant/components/egauge/__init__.py index 3cbc19ca51e..5e50bb653c8 100644 --- a/homeassistant/components/egauge/__init__.py +++ b/homeassistant/components/egauge/__init__.py @@ -1,7 +1,5 @@ """Integration for eGauge energy monitors.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/egauge/config_flow.py b/homeassistant/components/egauge/config_flow.py index 8d0a8c935dc..9931e5b66d1 100644 --- a/homeassistant/components/egauge/config_flow.py +++ b/homeassistant/components/egauge/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the eGauge integration.""" -from __future__ import annotations - from typing import Any from egauge_async.exceptions import EgaugeAuthenticationError, EgaugePermissionError diff --git a/homeassistant/components/egauge/coordinator.py b/homeassistant/components/egauge/coordinator.py index 2791d828e6d..4552ca0307d 100644 --- a/homeassistant/components/egauge/coordinator.py +++ b/homeassistant/components/egauge/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for eGauge energy monitors.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta @@ -78,7 +76,9 @@ class EgaugeDataCoordinator(DataUpdateCoordinator[EgaugeData]): EgaugePermissionError, EgaugeException, ) as err: - # EgaugeAuthenticationError and EgaugePermissionError will raise ConfigEntryAuthFailed once reauth is implemented + # EgaugeAuthenticationError and + # EgaugePermissionError will raise + # ConfigEntryAuthFailed once reauth is implemented raise ConfigEntryError from err except ConnectError as err: raise UpdateFailed(f"Error fetching device info: {err}") from err diff --git a/homeassistant/components/egauge/entity.py b/homeassistant/components/egauge/entity.py index 3db1fa9ba9a..cc12606907d 100644 --- a/homeassistant/components/egauge/entity.py +++ b/homeassistant/components/egauge/entity.py @@ -1,7 +1,5 @@ """Base entity for the eGauge integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/egauge/sensor.py b/homeassistant/components/egauge/sensor.py index 743bc34a429..6f7dc9a5c92 100644 --- a/homeassistant/components/egauge/sensor.py +++ b/homeassistant/components/egauge/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for eGauge energy monitors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -62,7 +60,7 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_value_fn=lambda data, register: data.measurements[register], available_fn=lambda data, register: register in data.measurements, - supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE, + supported_fn=(lambda register_info: register_info.type == RegisterType.VOLTAGE), ), EgaugeSensorEntityDescription( key="current", @@ -71,7 +69,7 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_value_fn=lambda data, register: data.measurements[register], available_fn=lambda data, register: register in data.measurements, - supported_fn=lambda register_info: register_info.type == RegisterType.CURRENT, + supported_fn=(lambda register_info: register_info.type == RegisterType.CURRENT), ), ) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index dbb672dcb4b..8e1f7b5a4ba 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -1,7 +1,5 @@ """The EHEIM Digital integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry diff --git a/homeassistant/components/eheimdigital/binary_sensor.py b/homeassistant/components/eheimdigital/binary_sensor.py index 82ce8c3f9fc..0e59b206924 100644 --- a/homeassistant/components/eheimdigital/binary_sensor.py +++ b/homeassistant/components/eheimdigital/binary_sensor.py @@ -56,7 +56,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so binary sensors can be added as devices are found.""" + """Set up callbacks to add binary sensors as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 7ac0b897507..8646bc7b9a3 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -35,7 +35,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" + """Set up callbacks to add climate entities as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index af09baea1e3..36241eca999 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -1,7 +1,5 @@ """Config flow for EHEIM Digital.""" -from __future__ import annotations - import asyncio from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index 61c3be363c8..7ad2563701f 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the EHEIM Digital integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py index aa92f8ede58..61b2dbd4fb0 100644 --- a/homeassistant/components/eheimdigital/entity.py +++ b/homeassistant/components/eheimdigital/entity.py @@ -30,7 +30,8 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( """Initialize a EHEIM Digital entity.""" super().__init__(coordinator) if TYPE_CHECKING: - # At this point at least one device is found and so there is always a main device set + # At this point at least one device is found + # and so there is always a main device set assert isinstance(coordinator.hub.main, EheimDigitalDevice) self._attr_device_info = DeviceInfo( configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index 4e148ee5204..e67ef198098 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -33,7 +33,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + """Set up callbacks for the coordinator to add lights as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py index 5c779494ffe..ae2e3d640d0 100644 --- a/homeassistant/components/eheimdigital/number.py +++ b/homeassistant/components/eheimdigital/number.py @@ -201,7 +201,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so numbers can be added as devices are found.""" + """Set up callbacks for the coordinator to add numbers as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py index 47abc924bf0..3967cfca28a 100644 --- a/homeassistant/components/eheimdigital/select.py +++ b/homeassistant/components/eheimdigital/select.py @@ -171,7 +171,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so select entities can be added as devices are found.""" + """Set up callbacks to add select entities as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py index 9d9a50c9b8b..bed960ebb96 100644 --- a/homeassistant/components/eheimdigital/sensor.py +++ b/homeassistant/components/eheimdigital/sensor.py @@ -89,7 +89,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + """Set up callbacks for the coordinator to add sensors as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/switch.py b/homeassistant/components/eheimdigital/switch.py index b25745d2daf..f01006a268e 100644 --- a/homeassistant/components/eheimdigital/switch.py +++ b/homeassistant/components/eheimdigital/switch.py @@ -70,7 +70,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so switches can be added as devices are found.""" + """Set up callbacks for the coordinator to add switches as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py index ba83e191824..fc9c351097c 100644 --- a/homeassistant/components/eheimdigital/time.py +++ b/homeassistant/components/eheimdigital/time.py @@ -99,7 +99,7 @@ async def async_setup_entry( entry: EheimDigitalConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the callbacks for the coordinator so times can be added as devices are found.""" + """Set up callbacks for the coordinator to add times as devices are found.""" coordinator = entry.runtime_data def async_setup_device_entities( diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index cfb2cfba845..629945246b5 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,7 +1,5 @@ """The Eight Sleep integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/ekeybionyx/__init__.py b/homeassistant/components/ekeybionyx/__init__.py index 672824b811a..02749f3b66a 100644 --- a/homeassistant/components/ekeybionyx/__init__.py +++ b/homeassistant/components/ekeybionyx/__init__.py @@ -1,7 +1,5 @@ """The Ekey Bionyx integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py index a4a4f759726..eb666e92a5f 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -24,7 +24,9 @@ from homeassistant.helpers.selector import SelectOptionDict, SelectSelector from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE -# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot +# Valid webhook name: starts with letter or underscore, +# contains letters, digits, spaces, dots, and underscores, +# does not end with space or dot VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") @@ -83,7 +85,7 @@ class OAuth2FlowHandler( return {"scope": SCOPE} async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: - """Start the user facing flow by initializing the API and getting the systems.""" + """Start the user facing flow by initializing the API.""" client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN]) ap = ekey_bionyxpy.BionyxAPI(client) self._data["api"] = ap diff --git a/homeassistant/components/ekeybionyx/event.py b/homeassistant/components/ekeybionyx/event.py index cbf8d553009..80c333e8203 100644 --- a/homeassistant/components/ekeybionyx/event.py +++ b/homeassistant/components/ekeybionyx/event.py @@ -31,6 +31,7 @@ class EkeyEvent(EventEntity): _attr_device_class = EventDeviceClass.BUTTON _attr_event_types = ["event happened"] + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py index 27cebc9aee9..235f56dd787 100644 --- a/homeassistant/components/electrasmart/__init__.py +++ b/homeassistant/components/electrasmart/__init__.py @@ -1,7 +1,5 @@ """The Electra Air Conditioner integration.""" -from __future__ import annotations - from electrasmart.api import ElectraAPI, ElectraApiError from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index bdf94f606db..b7d3d2f2fe6 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -1,7 +1,5 @@ """Support for the Electra climate.""" -from __future__ import annotations - from datetime import timedelta import logging import time @@ -186,7 +184,8 @@ class ElectraClimateEntity(ClimateEntity): self._last_state_update = 0 try: - # skip the first update only as we already got the devices with their current state + # skip the first update only as we already got + # the devices with their current state if self._skip_update: self._skip_update = False else: @@ -196,7 +195,8 @@ class ElectraClimateEntity(ClimateEntity): # show the warning once upon state change if self._was_available: _LOGGER.warning( - "Electra AC %s (%s) is not available, check its status in the Electra Smart mobile app", + "Electra AC %s (%s) is not available, check" + " its status in the Electra Smart mobile app", self.name, self._electra_ac_device.mac, ) @@ -220,7 +220,8 @@ class ElectraClimateEntity(ClimateEntity): except ElectraApiError as exp: self._consecutive_failures += 1 _LOGGER.warning( - "Failed to get %s state: %s (try #%i since last success), keeping old state", + "Failed to get %s state: %s" + " (try #%i since last success), keeping old state", self.name, exp, self._consecutive_failures, @@ -228,7 +229,8 @@ class ElectraClimateEntity(ClimateEntity): if self._consecutive_failures >= CONSECUTIVE_FAILURE_THRESHOLD: raise HomeAssistantError( - f"Failed to get {self.name} state: {exp} for the {self._consecutive_failures} time", + f"Failed to get {self.name} state: {exp}" + f" for the {self._consecutive_failures} time", ) from ElectraApiError self._consecutive_failures = 0 diff --git a/homeassistant/components/electrasmart/config_flow.py b/homeassistant/components/electrasmart/config_flow.py index a2e6889c346..fad9db80baf 100644 --- a/homeassistant/components/electrasmart/config_flow.py +++ b/homeassistant/components/electrasmart/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Electra Air Conditioner integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 825dbc54013..5706e708a57 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -1,7 +1,5 @@ """The Electric Kiwi integration.""" -from __future__ import annotations - import aiohttp from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py index 9f7ff333378..0708a3ccbeb 100644 --- a/homeassistant/components/electric_kiwi/api.py +++ b/homeassistant/components/electric_kiwi/api.py @@ -1,7 +1,5 @@ """API for Electric Kiwi bound to Home Assistant OAuth.""" -from __future__ import annotations - from aiohttp import ClientSession from electrickiwi_api import AbstractAuth @@ -20,7 +18,8 @@ class ConfigEntryElectricKiwiAuth(AbstractAuth): oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Electric Kiwi auth.""" - # add host when ready for production "https://api.electrickiwi.co.nz" defaults to dev + # add host when ready for production + # "https://api.electrickiwi.co.nz" defaults to dev super().__init__(websession, API_BASE_URL) self._oauth_session = oauth_session diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py index b83fd89c4c6..ed3fe2fa75a 100644 --- a/homeassistant/components/electric_kiwi/config_flow.py +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Electric Kiwi.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index c51422a7c72..9c86ccb26ea 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -8,4 +8,25 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" -SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login" +SCOPE_VALUES = ( + "read_customer_details read_connection_detail" + " read_connection read_billing_address" + " get_bill_address read_billing_frequency" + " read_billing_details read_billing_bills" + " read_billing_bill read_billing_bill_id" + " read_billing_bill_file" + " read_account_running_balance" + " read_customer_account_summary" + " read_consumption_summary" + " download_consumption_file" + " read_consumption_averages" + " get_consumption_averages" + " read_hop_intervals_config read_hop_intervals" + " read_hop_connection" + " read_hop_specific_connection" + " save_hop_connection" + " save_hop_specific_connection" + " read_outage_contact" + " get_outage_contact_info_for_icp" + " read_session read_session_data_login" +) diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 635b55b2bc0..bfaf7b491d4 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,7 +1,5 @@ """Electric Kiwi coordinators.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from dataclasses import dataclass diff --git a/homeassistant/components/electric_kiwi/oauth2.py b/homeassistant/components/electric_kiwi/oauth2.py index 9a6c4cd22a5..06efd04aa48 100644 --- a/homeassistant/components/electric_kiwi/oauth2.py +++ b/homeassistant/components/electric_kiwi/oauth2.py @@ -1,7 +1,5 @@ """OAuth2 implementations for Toon.""" -from __future__ import annotations - import base64 from typing import Any, cast diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 2ba2a089557..3da1c58b69f 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -1,7 +1,5 @@ """Support for Electric Kiwi hour of free power.""" -from __future__ import annotations - import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 27f13a82e09..879f1eaf05d 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -1,7 +1,5 @@ """Support for Electric Kiwi sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index ea1cd9d63ac..76d12881807 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -1,7 +1,5 @@ """The ElevenLabs text-to-speech integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -10,7 +8,7 @@ from elevenlabs.core import ApiError from httpx import ConnectError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -19,7 +17,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.httpx_client import get_async_client -from .const import CONF_MODEL, CONF_STT_MODEL +from .const import CONF_STT_MODEL _LOGGER = logging.getLogger(__name__) @@ -108,7 +106,7 @@ async def async_migrate_entry( new_options = {**config_entry.options} if config_entry.minor_version < 2: - # Add defaults only if they’re not already present + # Add defaults only if they're not already present if "stt_auto_language" not in new_options: new_options["stt_auto_language"] = False if "stt_model" not in new_options: diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 6e1baec08ef..5434f64d495 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ElevenLabs text-to-speech integration.""" -from __future__ import annotations - import logging from typing import Any @@ -10,7 +8,7 @@ from elevenlabs.core import ApiError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -22,7 +20,6 @@ from homeassistant.helpers.selector import ( from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, - CONF_MODEL, CONF_SIMILARITY, CONF_STABILITY, CONF_STT_AUTO_LANGUAGE, diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index a13c3e6a3bf..6eeed3d9ca1 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -1,9 +1,6 @@ """Constants for the ElevenLabs text-to-speech integration.""" -ATTR_MODEL = "model" - CONF_VOICE = "voice" -CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" diff --git a/homeassistant/components/elevenlabs/stt.py b/homeassistant/components/elevenlabs/stt.py index 76604b46317..38ac4ca6b7d 100644 --- a/homeassistant/components/elevenlabs/stt.py +++ b/homeassistant/components/elevenlabs/stt.py @@ -1,7 +1,5 @@ """Support for the ElevenLabs speech-to-text service.""" -from __future__ import annotations - from collections.abc import AsyncIterable from io import BytesIO import logging @@ -129,7 +127,9 @@ class ElevenLabsSTTEntity(SpeechToTextEntity): ) -> stt.SpeechResult: """Process an audio stream to STT service.""" _LOGGER.debug( - "Processing audio stream for STT: model=%s, language=%s, format=%s, codec=%s, sample_rate=%s, channels=%s, bit_rate=%s", + "Processing audio stream for STT: model=%s," + " language=%s, format=%s, codec=%s," + " sample_rate=%s, channels=%s, bit_rate=%s", self._stt_model, metadata.language, metadata.format, @@ -150,9 +150,9 @@ class ElevenLabsSTTEntity(SpeechToTextEntity): raw_pcm_compatible = ( metadata.codec == AudioCodecs.PCM - and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000 - and metadata.channel == AudioChannels.CHANNEL_MONO - and metadata.bit_rate == AudioBitRates.BITRATE_16 + and metadata.sample_rate is AudioSampleRates.SAMPLERATE_16000 + and metadata.channel is AudioChannels.CHANNEL_MONO + and metadata.bit_rate is AudioBitRates.BITRATE_16 ) if raw_pcm_compatible: file_format = "pcm_s16le_16" diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b3743fd3270..b8ef857f35a 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -1,7 +1,5 @@ """Support for the ElevenLabs text-to-speech service.""" -from __future__ import annotations - import asyncio from collections import deque from collections.abc import AsyncGenerator, Mapping @@ -22,7 +20,7 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_MODEL, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -30,7 +28,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( - ATTR_MODEL, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, @@ -296,7 +293,9 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): previous_request_ids.append(rid) else: _LOGGER.debug( - "No request-id returned from server; clearing previous requests" + "No request-id returned from" + " server; clearing previous" + " requests" ) previous_request_ids.clear() except ApiError as exc: @@ -308,7 +307,8 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): await _add_sentences_task raise HomeAssistantError(exc) from exc - # Capture and store server request-id for next calls (only when supported) + # Capture and store server request-id for + # next calls (only when supported) _LOGGER.debug("Completed TTS stream for text: %s", text) _LOGGER.debug("Completed TTS stream") diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 23ed65ded33..7d10c1acaff 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -1,12 +1,10 @@ """Support for Elgato button.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from elgato import Elgato, ElgatoError +from elgato import Elgato from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,11 +13,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -80,11 +78,7 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity): f"{coordinator.data.info.serial_number}_{description.key}" ) + @elgato_exception_handler async def async_press(self) -> None: """Trigger button press on the Elgato device.""" - try: - await self.entity_description.press_fn(self.coordinator.client) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while communicating with the Elgato Light" - ) from error + await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index a47f039384c..694297240a0 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Elgato Light integration.""" -from __future__ import annotations - from typing import Any from elgato import Elgato, ElgatoError @@ -12,6 +10,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -23,7 +23,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 host: str - port: int serial_number: str mac: str | None = None @@ -70,6 +69,69 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by zeroconf.""" return self._async_create_entry() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing Elgato device.""" + errors: dict[str, str] = {} + + if user_input is not None: + elgato = Elgato( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + ) + + try: + info = await elgato.info() + except ElgatoError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=self._get_reconfigure_entry().data[CONF_HOST], + ): str, + } + ), + errors=errors, + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery of a known Elgato device. + + Only devices already configured (matched via ``registered_devices``) + reach this step. It is used to keep the stored host in sync with the + current IP address of the device. + """ + mac = format_mac(discovery_info.macaddress) + + for entry in self._async_current_entries(): + if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac( + entry_mac + ) != mac: + continue + if entry.data[CONF_HOST] != discovery_info.ip: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_HOST: discovery_info.ip}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + + return self.async_abort(reason="no_devices_found") + @callback def _async_show_setup_form( self, errors: dict[str, str] | None = None diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index 46af5739fe5..a3da1b7d416 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -1,7 +1,5 @@ """Constants for the Elgato Light integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index 5e1ba0a6494..484b134593c 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -2,7 +2,15 @@ from dataclasses import dataclass -from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State +from elgato import ( + BatteryInfo, + Elgato, + ElgatoConnectionError, + ElgatoError, + Info, + Settings, + State, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -59,4 +67,12 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): state=await self.client.state(), ) except ElgatoConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except ElgatoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index 4e1b9d4cfdd..c9e059da672 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Elgato.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 42920c3d28e..a4c70296b24 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -1,7 +1,5 @@ """Base entity for the Elgato integration.""" -from __future__ import annotations - from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, @@ -28,7 +26,10 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]): manufacturer="Elgato", model=coordinator.data.info.product_name, name=coordinator.data.info.display_name, - sw_version=f"{coordinator.data.info.firmware_version} ({coordinator.data.info.firmware_build_number})", + sw_version=( + f"{coordinator.data.info.firmware_version}" + f" ({coordinator.data.info.firmware_build_number})" + ), hw_version=str(coordinator.data.info.hardware_board_type), ) if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None: diff --git a/homeassistant/components/elgato/helpers.py b/homeassistant/components/elgato/helpers.py new file mode 100644 index 00000000000..d512be0dba6 --- /dev/null +++ b/homeassistant/components/elgato/helpers.py @@ -0,0 +1,41 @@ +"""Helpers for Elgato.""" + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from elgato import ElgatoConnectionError, ElgatoError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import ElgatoEntity + + +def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( + func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Elgato calls to handle Elgato exceptions. + + A decorator that wraps the passed in function, catches Elgato errors, + and raises a translated ``HomeAssistantError``. + """ + + async def handler( + self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except ElgatoConnectionError as error: + self.coordinator.last_update_success = False + self.coordinator.async_update_listeners() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + except ElgatoError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from error + + return handler diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 429f6d1db01..9698bb053d1 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -1,11 +1,7 @@ """Support for Elgato lights.""" -from __future__ import annotations - from typing import Any -from elgato import ElgatoError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -14,12 +10,12 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -94,17 +90,13 @@ class ElgatoLight(ElgatoEntity, LightEntity): """Return the state of the light.""" return self.coordinator.data.state.on + @elgato_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.client.light(on=False) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.coordinator.client.light(on=False) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) @@ -137,26 +129,16 @@ class ElgatoLight(ElgatoEntity, LightEntity): else color_util.color_temperature_kelvin_to_mired(temperature_kelvin) ) - try: - await self.coordinator.client.light( - on=True, - brightness=brightness, - hue=hue, - saturation=saturation, - temperature=temperature, - ) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.coordinator.client.light( + on=True, + brightness=brightness, + hue=hue, + saturation=saturation, + temperature=temperature, + ) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_identify(self) -> None: """Identify the light, will make it blink.""" - try: - await self.coordinator.client.identify() - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while identifying the Elgato Light" - ) from error + await self.coordinator.client.identify() diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 734ad5ec930..3c521810cdf 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -3,9 +3,15 @@ "name": "Elgato Light", "codeowners": ["@frenck"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/elgato", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["elgato==5.1.2"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 531f0447f70..6a8847026a3 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -10,7 +10,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -25,8 +25,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -39,23 +39,15 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: | - The integration doesn't update the device info based on DHCP discovery - of known existing devices. + discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: - status: todo - comment: | - Device are documented, but some are missing. For example, the their pro - strip is supported as well. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -64,9 +56,9 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 02dbc2aeef6..c19f5e3f06f 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -1,7 +1,5 @@ """Support for Elgato sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/elgato/services.py b/homeassistant/components/elgato/services.py index b7bf8282e18..b188cb647f4 100644 --- a/homeassistant/components/elgato/services.py +++ b/homeassistant/components/elgato/services.py @@ -1,7 +1,5 @@ """Support for Elgato services.""" -from __future__ import annotations - from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import service diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 18bd1568336..dcfeb23d9ac 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -2,13 +2,24 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The configured Elgato device is not the same as the one at this address.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "flow_title": "{serial_number}", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::elgato::config::step::user::data_description::host%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -48,6 +59,14 @@ } } }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Elgato device." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Elgato device." + } + }, "services": { "identify": { "description": "Identifies an Elgato Light. Blinks the light, which can be useful for, e.g., a visual notification.", diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 1b24f621807..445ff3864df 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -1,21 +1,19 @@ """Support for Elgato switches.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from elgato import Elgato, ElgatoError +from elgato import Elgato from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -92,24 +90,14 @@ class ElgatoSwitchEntity(ElgatoEntity, SwitchEntity): """Return state of the switch.""" return self.entity_description.is_on_fn(self.coordinator.data) + @elgato_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - try: - await self.entity_description.set_fn(self.coordinator.client, True) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.entity_description.set_fn(self.coordinator.client, True) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - try: - await self.entity_description.set_fn(self.coordinator.client, False) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.entity_description.set_fn(self.coordinator.client, False) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 1a5490da0a5..d121b4f6c8d 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -1,7 +1,5 @@ """Monitors home energy use for the ELIQ Online service.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 54d6ebcc357..712e5206f41 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -1,7 +1,5 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" -from __future__ import annotations - import asyncio import logging import re @@ -74,9 +72,11 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, + Platform.NUMBER, Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, ] diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 393845f65ff..2ecc5d9401e 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -1,7 +1,5 @@ """Each ElkM1 area will be created as a separate alarm_control_panel.""" -from __future__ import annotations - from typing import Any from elkm1_lib.areas import Area @@ -18,6 +16,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) +from homeassistant.const import SERVICE_ALARM_ARM_VACATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -45,7 +44,6 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA: VolDictType = { } SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message" -SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation" SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant" SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant" SERVICE_ALARM_BYPASS = "alarm_bypass" diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index ba6a375c29b..fb3bd51af6c 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -1,7 +1,5 @@ """Support for control of ElkM1 binary sensors.""" -from __future__ import annotations - from typing import Any from elkm1_lib.const import ZoneLogicalStatus, ZoneType @@ -52,5 +50,5 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity): def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: # Zone in NORMAL state is OFF; any other state is ON self._attr_is_on = bool( - self._element.logical_status != ZoneLogicalStatus.NORMAL + self._element.logical_status is not ZoneLogicalStatus.NORMAL ) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 59d3aa9605a..862c31b0ded 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -1,7 +1,5 @@ """Support for control of Elk-M1 connected thermostats.""" -from __future__ import annotations - from typing import Any from elkm1_lib.const import ThermostatFan, ThermostatMode, ThermostatSetting @@ -106,7 +104,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): ThermostatMode.EMERGENCY_HEAT, ): return self._element.heat_setpoint - if self._element.mode == ThermostatMode.COOL: + if self._element.mode is ThermostatMode.COOL: return self._element.cool_setpoint return None @@ -164,6 +162,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): self._attr_hvac_mode = ELK_TO_HASS_HVAC_MODES[self._element.mode] if ( self._attr_hvac_mode == HVACMode.OFF - and self._element.fan == ThermostatFan.ON + and self._element.fan is ThermostatFan.ON ): self._attr_hvac_mode = HVACMode.FAN_ONLY diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index 7e1a177d4de..0396db8deea 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Elk-M1 Control integration.""" -from __future__ import annotations - import logging from typing import Any, Self @@ -12,6 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ADDRESS, + CONF_DEVICE, CONF_HOST, CONF_PASSWORD, CONF_PREFIX, @@ -34,8 +33,6 @@ from .discovery import ( async_update_entry_from_discovery, ) -CONF_DEVICE = "device" - NON_SECURE_PORT = 2101 SECURE_PORT = 2601 STANDARD_PORTS = {NON_SECURE_PORT, SECURE_PORT} @@ -122,7 +119,11 @@ def _make_url_from_data(data: dict[str, str]) -> str: def _get_protocol_from_url(url: str) -> str: - """Get protocol from URL. Returns the configured protocol from URL or the default secure protocol.""" + """Get protocol from URL. + + Returns the configured protocol from URL or the + default secure protocol. + """ return next( (k for k, v in PROTOCOL_MAP.items() if url.startswith(v)), DEFAULT_SECURE_PROTOCOL, @@ -238,15 +239,18 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception during reconfiguration") errors["base"] = "unknown" else: - # Discover the device at the provided address to obtain its MAC (unique_id) + # Discover the device at the provided address + # to obtain its MAC (unique_id) device = await async_discover_device( self.hass, validate_input_data[CONF_ADDRESS] ) if device is not None and device.mac_address: await self.async_set_unique_id(dr.format_mac(device.mac_address)) - self._abort_if_unique_id_mismatch() # aborts if user tried to switch devices + # aborts if user tried to switch devices + self._abort_if_unique_id_mismatch() else: - # If we cannot confirm identity, keep existing behavior (don't block reconfigure) + # If we cannot confirm identity, keep existing + # behavior (don't block reconfigure) await self.async_set_unique_id(reconfigure_entry.unique_id) return self.async_update_reload_and_abort( diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py index 61d1994b797..70b48ecf0c8 100644 --- a/homeassistant/components/elkm1/const.py +++ b/homeassistant/components/elkm1/const.py @@ -40,6 +40,7 @@ ELK_ELEMENTS = { EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed" +ATTR_DURATION = "duration" ATTR_KEYPAD_ID = "keypad_id" ATTR_KEY = "key" ATTR_KEY_NAME = "key_name" diff --git a/homeassistant/components/elkm1/discovery.py b/homeassistant/components/elkm1/discovery.py index 916e8a8aeac..b685567c37c 100644 --- a/homeassistant/components/elkm1/discovery.py +++ b/homeassistant/components/elkm1/discovery.py @@ -1,7 +1,5 @@ """The elkm1 integration discovery.""" -from __future__ import annotations - import asyncio from dataclasses import asdict import logging diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index ce717578eae..380182912c0 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -1,7 +1,5 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" -from __future__ import annotations - from collections.abc import Iterable from enum import Enum import logging @@ -49,6 +47,23 @@ def create_elk_entities( return entities +def generate_unique_id(prefix: str, element: Element) -> str: + """Generate a unique id.""" + # unique_id starts with elkm1_ iff there is no prefix + # it starts with elkm1m_{prefix} iff there is a prefix + # this is to avoid a conflict between + # prefix=foo, name=bar (which would be elkm1_foo_bar) + # - and - + # prefix="", name="foo bar" (which would be elkm1_foo_bar also) + # we could have used elkm1__foo_bar for the latter, but that + # would have been a breaking change + if prefix != "": + uid_start = f"elkm1m_{prefix}" + else: + uid_start = "elkm1" + return f"{uid_start}_{element.default_name('_')}".lower() + + class ElkEntity(Entity): """Base class for all Elk entities.""" @@ -62,19 +77,7 @@ class ElkEntity(Entity): self._mac = elk_data.mac self._prefix = elk_data.prefix self._temperature_unit: str = elk_data.config["temperature_unit"] - # unique_id starts with elkm1_ iff there is no prefix - # it starts with elkm1m_{prefix} iff there is a prefix - # this is to avoid a conflict between - # prefix=foo, name=bar (which would be elkm1_foo_bar) - # - and - - # prefix="", name="foo bar" (which would be elkm1_foo_bar also) - # we could have used elkm1__foo_bar for the latter, but that - # would have been a breaking change - if self._prefix != "": - uid_start = f"elkm1m_{self._prefix}" - else: - uid_start = "elkm1" - self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() + self._unique_id = generate_unique_id(self._prefix, element) self._attr_name = element.name @property diff --git a/homeassistant/components/elkm1/icons.json b/homeassistant/components/elkm1/icons.json index 566d92cb992..bac27a67121 100644 --- a/homeassistant/components/elkm1/icons.json +++ b/homeassistant/components/elkm1/icons.json @@ -48,6 +48,9 @@ }, "speak_word": { "service": "mdi:message-minus" + }, + "switch_output_turn_on_for": { + "service": "mdi:timer" } } } diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index b5e2f0acacf..42cd44e8523 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -1,7 +1,5 @@ """Support for control of ElkM1 lighting (X10, UPB, etc).""" -from __future__ import annotations - from typing import Any from elkm1_lib.elements import Element diff --git a/homeassistant/components/elkm1/logbook.py b/homeassistant/components/elkm1/logbook.py index b31c537d93f..d6e2f34db04 100644 --- a/homeassistant/components/elkm1/logbook.py +++ b/homeassistant/components/elkm1/logbook.py @@ -1,7 +1,5 @@ """Describe elkm1 logbook events.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 1cc39278f8e..37672654513 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.13"] + "requirements": ["elkm1-lib==2.2.15"] } diff --git a/homeassistant/components/elkm1/models.py b/homeassistant/components/elkm1/models.py index 7dd3313782e..4d34deb0161 100644 --- a/homeassistant/components/elkm1/models.py +++ b/homeassistant/components/elkm1/models.py @@ -1,7 +1,5 @@ """The elkm1 integration models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/elkm1/number.py b/homeassistant/components/elkm1/number.py new file mode 100644 index 00000000000..8025a3861c1 --- /dev/null +++ b/homeassistant/components/elkm1/number.py @@ -0,0 +1,79 @@ +"""Support for ElkM1 number entities.""" + +import logging +from typing import Any, cast + +from elkm1_lib.const import SettingFormat +from elkm1_lib.elements import Element +from elkm1_lib.settings import Setting + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ElkM1ConfigEntry +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities +from .models import ELKM1Data + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ElkM1ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Elk-M1 number platform.""" + elk_data = config_entry.runtime_data + elk = elk_data.elk + entities: list[ElkEntity] = [] + number_settings = [ + setting + for setting in cast(list[Setting], elk.settings) + if setting.value_format in (SettingFormat.NUMBER, SettingFormat.TIMER) + ] + + create_elk_entities( + elk_data, + number_settings, + "setting", + ElkNumberSetting, + entities, + ) + async_add_entities(entities) + + +class ElkNumberSetting(ElkAttachedEntity, NumberEntity): + """Representation of an Elk-M1 Number Setting.""" + + _element: Setting + + _attr_native_min_value = 0 + _attr_native_max_value = 65535 + _attr_native_step = 1 + + def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None: + """Initialize the number setting.""" + super().__init__(element, elk, elk_data) + if element.value_format is SettingFormat.TIMER: + self._attr_device_class = NumberDeviceClass.DURATION + self._attr_native_unit_of_measurement = UnitOfTime.SECONDS + + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: + # Guard against the panel possibly changing the underlying + # type without us knowing about the change + if isinstance(self._element.value, int): + self._attr_native_value = self._element.value + else: + self._attr_available = False + _LOGGER.warning( + "Setting type for '%s' differs between the" + " ElkM1 and the entity. Restart the" + " integration to fix", + self.entity_id, + ) + + async def async_set_native_value(self, value: float) -> None: + """Set the value of the setting.""" + self._element.set(int(value)) diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index 5da240aee2d..8ecd59c3082 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -1,7 +1,5 @@ """Support for control of ElkM1 tasks ("macros").""" -from __future__ import annotations - from typing import Any from elkm1_lib.tasks import Task diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index aaa63a115b6..d2e6a17a168 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -1,8 +1,6 @@ """Support for control of ElkM1 sensors.""" -from __future__ import annotations - -from typing import Any +from typing import Any, cast from elkm1_lib.const import SettingFormat, ZoneType from elkm1_lib.counters import Counter @@ -22,13 +20,19 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfElectricPotential from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import ElkM1ConfigEntry from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA -from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities +from .entity import ( + ElkAttachedEntity, + ElkEntity, + create_elk_entities, + generate_unique_id, +) +from .util import deprecate_entity SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" @@ -60,11 +64,37 @@ async def async_setup_entry( elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] + elk_settings: list[Setting] = [] + create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities) - create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities) create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities) + + entity_registry = er.async_get(hass) + for setting in elk.settings: + setting = cast(Setting, setting) + domain = ( + "time" if setting.value_format is SettingFormat.TIME_OF_DAY else "number" + ) + + orig_unique_id = generate_unique_id(elk_data.prefix, setting) + new_unique_id = orig_unique_id + new_entity_id = f"{domain}.elkm1_{setting.name.replace(' ', '_')}".lower() + + if deprecate_entity( + hass, + entity_registry, + "sensor", + orig_unique_id, + f"deprecated_sensor_{orig_unique_id}", + "deprecated_sensor", + new_unique_id, + new_entity_id, + ): + elk_settings.append(setting) + + create_elk_entities(elk_data, elk_settings, "setting", ElkSetting, entities) async_add_entities(entities) platform = entity_platform.async_get_current_platform() @@ -201,7 +231,9 @@ class ElkSetting(ElkSensor): _element: Setting def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - self._attr_native_value = self._element.value + self._attr_native_value = ( + None if self._element.value is None else str(self._element.value) + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -256,7 +288,7 @@ class ElkZone(ElkSensor): @property def temperature_unit(self) -> str | None: """Return the temperature unit.""" - if self._element.definition == ZoneType.TEMPERATURE: + if self._element.definition is ZoneType.TEMPERATURE: return self._temperature_unit return None @@ -273,18 +305,18 @@ class ElkZone(ElkSensor): @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" - if self._element.definition == ZoneType.TEMPERATURE: + if self._element.definition is ZoneType.TEMPERATURE: return self._temperature_unit - if self._element.definition == ZoneType.ANALOG_ZONE: + if self._element.definition is ZoneType.ANALOG_ZONE: return UnitOfElectricPotential.VOLT return None def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - if self._element.definition == ZoneType.TEMPERATURE: + if self._element.definition is ZoneType.TEMPERATURE: self._attr_native_value = temperature_to_state( self._element.temperature, UNDEFINED_TEMPERATURE ) - elif self._element.definition == ZoneType.ANALOG_ZONE: + elif self._element.definition is ZoneType.ANALOG_ZONE: self._attr_native_value = f"{self._element.voltage}" else: self._attr_native_value = pretty_const(self._element.logical_status.name) diff --git a/homeassistant/components/elkm1/services.py b/homeassistant/components/elkm1/services.py index bfdd968680c..5ad9df46f7f 100644 --- a/homeassistant/components/elkm1/services.py +++ b/homeassistant/components/elkm1/services.py @@ -1,7 +1,5 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" -from __future__ import annotations - from elkm1_lib.elk import Elk, Panel import voluptuous as vol diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml index 1f3bb8ffebb..ae19929aba5 100644 --- a/homeassistant/components/elkm1/services.yaml +++ b/homeassistant/components/elkm1/services.yaml @@ -161,3 +161,15 @@ sensor_zone_trigger: entity: integration: elkm1 domain: sensor + +switch_output_turn_on_for: + target: + entity: + integration: elkm1 + domain: switch + fields: + duration: + example: 42 + required: true + selector: + duration: diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index fb3e09ee2fb..724067cb6c2 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -58,6 +58,16 @@ } } }, + "issues": { + "deprecated_sensor": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "Deprecated sensor detected" + }, + "deprecated_sensor_scripts": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "[%key:component::elkm1::issues::deprecated_sensor::title%]" + } + }, "services": { "alarm_arm_home_instant": { "description": "Arms the Elk-M1 in home instant mode.", @@ -200,6 +210,16 @@ } }, "name": "Speak word" + }, + "switch_output_turn_on_for": { + "description": "Turns on an output for a specified length of time.", + "fields": { + "duration": { + "description": "Length of time to turn the output on for.", + "name": "Duration" + } + }, + "name": "Switch output turn on for" } } } diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index d91d65512a2..6ef1937e2ba 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -1,7 +1,7 @@ """Support for control of ElkM1 outputs (relays).""" -from __future__ import annotations - +from datetime import timedelta +from math import ceil from typing import Any from elkm1_lib.const import ThermostatMode, ThermostatSetting @@ -9,15 +9,29 @@ from elkm1_lib.elements import Element from elkm1_lib.elk import Elk from elkm1_lib.outputs import Output from elkm1_lib.thermostats import Thermostat +import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import VolDictType from . import ElkM1ConfigEntry +from .const import ATTR_DURATION, DOMAIN from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities from .models import ELKM1Data +SERVICE_SWITCH_OUTPUT_TURN_ON_FOR = "switch_output_turn_on_for" + +ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA: VolDictType = { + vol.Required(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(seconds=1), max=timedelta(seconds=65535)), + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -34,6 +48,15 @@ async def async_setup_entry( ) async_add_entities(entities) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SWITCH_OUTPUT_TURN_ON_FOR, + entity_domain=SWITCH_DOMAIN, + schema=ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA, + func="async_switch_output_turn_on_for", + ) + class ElkOutput(ElkAttachedEntity, SwitchEntity): """Elk output as switch.""" @@ -53,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity): """Turn off the output.""" self._element.turn_off() + async def async_switch_output_turn_on_for(self, duration: timedelta) -> None: + """Turn on an output for specified length of time.""" + self._element.turn_on(ceil(duration.total_seconds())) + class ElkThermostatEMHeat(ElkEntity, SwitchEntity): """Elk Thermostat emergency heat as switch.""" @@ -68,7 +95,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity): @property def is_on(self) -> bool: """Get the current emergency heat status.""" - return self._element.mode == ThermostatMode.EMERGENCY_HEAT + return self._element.mode is ThermostatMode.EMERGENCY_HEAT def _elk_set(self, mode: ThermostatMode) -> None: """Set the thermostat mode.""" @@ -81,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the output.""" self._elk_set(ThermostatMode.EMERGENCY_HEAT) + + async def async_switch_output_turn_on_for(self, duration: timedelta) -> None: + """Turn on an output for specified length of time: not supported for thermostat.""" + raise HomeAssistantError("supported only on ElkM1 output switch entities") diff --git a/homeassistant/components/elkm1/time.py b/homeassistant/components/elkm1/time.py new file mode 100644 index 00000000000..0fe9ff1940b --- /dev/null +++ b/homeassistant/components/elkm1/time.py @@ -0,0 +1,68 @@ +"""Support for ElkM1 time entities.""" + +from datetime import time as dt_time +import logging +from typing import Any, cast + +from elkm1_lib.const import SettingFormat +from elkm1_lib.elements import Element +from elkm1_lib.settings import Setting + +from homeassistant.components.time import TimeEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ElkM1ConfigEntry +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ElkM1ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Elk-M1 time platform.""" + elk_data = config_entry.runtime_data + elk = elk_data.elk + entities: list[ElkEntity] = [] + time_settings = [ + setting + for setting in cast(list[Setting], elk.settings) + if setting.value_format is SettingFormat.TIME_OF_DAY + ] + + create_elk_entities( + elk_data, + time_settings, + "setting", + ElkTimeSetting, + entities, + ) + async_add_entities(entities) + + +class ElkTimeSetting(ElkAttachedEntity, TimeEntity): + """Representation of an Elk-M1 Time Setting.""" + + _element: Setting + + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: + value = self._element.value + # Guard against the panel possibly changing the underlying + # type without us knowing about the change + if isinstance(value, tuple): + self._attr_native_value = dt_time(hour=value[0], minute=value[1]) + else: + self._attr_available = False + _LOGGER.warning( + "Setting type for '%s' differs between the" + " ElkM1 and the entity. Restart the" + " integration to fix", + self.entity_id, + ) + + async def async_set_value(self, value: dt_time) -> None: + """Set the time of the setting.""" + self._element.set((value.hour, value.minute)) diff --git a/homeassistant/components/elkm1/util.py b/homeassistant/components/elkm1/util.py new file mode 100644 index 00000000000..50b91e71698 --- /dev/null +++ b/homeassistant/components/elkm1/util.py @@ -0,0 +1,102 @@ +"""Utility helpers for the elkm1 integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + issue_string: str, + replacement_entity_unique_id: str, + replacement_entity_id: str, + version: str = "2026.11.0", +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = get_automations_and_scripts_using_entity(hass, entity_id) + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + "replacement_entity_id": ( + entity_registry.async_get_entity_id( + Platform.NUMBER, DOMAIN, replacement_entity_unique_id + ) + or entity_registry.async_get_entity_id( + Platform.TIME, DOMAIN, replacement_entity_unique_id + ) + or replacement_entity_id + ), + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=version, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + async_delete_issue(hass, DOMAIN, issue_id) + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_registry = er.async_get(hass) + items: list[str] = [] + + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in entities: + if item := entity_registry.async_get(used_entity_id): + items.append( + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index ec293be8273..9cd212abb4a 100644 --- a/homeassistant/components/elmax/__init__.py +++ b/homeassistant/components/elmax/__init__.py @@ -1,7 +1,5 @@ """The elmax-cloud integration.""" -from __future__ import annotations - from elmax_api.exceptions import ElmaxBadLoginError from elmax_api.http import Elmax, ElmaxLocal, GenericElmax from elmax_api.model.panel import PanelEntry @@ -70,8 +68,9 @@ async def _check_cloud_panel_status(client: Elmax, panel_id: str) -> PanelEntry: panels = await client.list_control_panels() panel = next((panel for panel in panels if panel.hash == panel_id), None) - # If the panel is no longer available within the ones associated to that client, raise - # a config error as the user must reconfigure it in order to make it work again + # If the panel is no longer available within the ones + # associated to that client, raise a config error as the + # user must reconfigure it in order to make it work again if not panel: raise ConfigEntryAuthFailed( f"Panel ID {panel_id} is no longer linked to this user account" diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index a90c8f2652c..d0491c959cd 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -1,7 +1,5 @@ """Elmax sensor platform.""" -from __future__ import annotations - from elmax_api.exceptions import ElmaxApiError from elmax_api.model.alarm_status import AlarmArmStatus, AlarmStatus from elmax_api.model.command import AreaCommand @@ -33,7 +31,8 @@ async def async_setup_entry( def _discover_new_devices(): panel_status: PanelStatus = coordinator.data - # In case the panel is offline, its status will be None. In that case, simply do nothing + # In case the panel is offline, its status will be + # None. In that case, simply do nothing if panel_status is None: return @@ -137,7 +136,8 @@ class ElmaxArea(ElmaxEntity, AlarmControlPanelEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - # Just reset the local pending_state so that it no longer overrides the one from coordinator. + # Just reset the local pending_state so that it no + # longer overrides the one from coordinator. self._pending_state = None super()._handle_coordinator_update() diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index d9ec3e75901..e05c9cd2342 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -1,7 +1,5 @@ """Elmax sensor platform.""" -from __future__ import annotations - from elmax_api.model.panel import PanelStatus from homeassistant.components.binary_sensor import ( @@ -26,7 +24,8 @@ async def async_setup_entry( def _discover_new_devices(): panel_status: PanelStatus = coordinator.data - # In case the panel is offline, its status will be None. In that case, simply do nothing + # In case the panel is offline, its status will be + # None. In that case, simply do nothing if panel_status is None: return diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 18350e45efe..ba6a88e8e9e 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -1,7 +1,5 @@ """Elmax integration common classes and utilities.""" -from __future__ import annotations - import ssl from elmax_api.model.panel import PanelEntry diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index f28ee9b7a82..53c75d5b71a 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -1,7 +1,5 @@ """Config flow for elmax-cloud integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -139,12 +137,13 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): return await self._test_direct_and_create_entry() async def _test_direct_and_create_entry(self): - """Test the direct connection to the Elmax panel and create and entry if successful.""" + """Test the direct connection to the Elmax panel and create entry.""" ssl_context = None self._panel_direct_ssl_cert = None if self._panel_direct_use_ssl: # Fetch the remote certificate. - # Local API is exposed via a self-signed SSL that we must add to our trust store. + # Local API is exposed via a self-signed SSL that + # we must add to our trust store. self._panel_direct_ssl_cert = ( await GenericElmax.retrieve_server_certificate( hostname=self._panel_direct_hostname, @@ -155,7 +154,8 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): build_direct_ssl_context, self._panel_direct_ssl_cert ) - # Attempt the connection to make sure the pin works. Also, take the chance to retrieve the panel ID via APIs. + # Attempt the connection to make sure the pin works. + # Also, take the chance to retrieve the panel ID via APIs. client_api_url = get_direct_api_url( host=self._panel_direct_hostname, port=self._panel_direct_port, @@ -289,7 +289,8 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): ) # Otherwise, it means we are handling now the "submission" of the user form. - # In this case, let's try to log in to the Elmax cloud and retrieve the available panels. + # In this case, let's try to log in to the Elmax cloud + # and retrieve the available panels. username = user_input[CONF_ELMAX_USERNAME] password = user_input[CONF_ELMAX_PASSWORD] try: @@ -309,7 +310,8 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "network_error"}, ) - # If the login succeeded, retrieve the list of available panels and filter the online ones + # If the login succeeded, retrieve the list of available + # panels and filter the online ones online_panels = [x for x in await client.list_control_panels() if x.online] # If no online panel was found, we display an error in the next UI. @@ -321,8 +323,9 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): ) # Show the panel selection. - # We want the user to choose the panel using the associated name, we set up a mapping - # dictionary to handle that case. + # We want the user to choose the panel using the + # associated name, we set up a mapping dictionary to + # handle that case. panel_names: dict[str, str] = {} username = client.get_authenticated_username() for panel in online_panels: @@ -411,7 +414,8 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): panel_pin = user_input[CONF_ELMAX_PANEL_PIN] await self.async_set_unique_id(self._reauth_cloud_panelid) - # Handle authentication, make sure the panel we are re-authenticating against is listed among results + # Handle authentication, make sure the panel we are + # re-authenticating against is listed among results # and verify its pin is correct. reauth_entry = self._get_reauth_entry() try: @@ -465,15 +469,20 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): http_port: int, ) -> ConfigFlowResult | None: # Look for another entry with the same PANEL_ID (local or remote). - # If there already is a matching panel, take the change to notify the Coordinator - # so that it uses the newly discovered IP address. This mitigates the issues + # If there already is a matching panel, take the chance + # to notify the Coordinator so that it uses the newly + # discovered IP address. This mitigates the issues # arising with DHCP and IP changes of the panels. for entry in self._async_current_entries(include_ignore=False): if entry.data[CONF_ELMAX_PANEL_ID] in (local_id, remote_id): - # If the discovery finds another entry with the same ID, skip the notification. - # However, if the discovery finds a new host for a panel that was already registered - # for a given host (leave PORT comparison aside as we don't want to get notified twice - # for HTTP and HTTPS), update the entry so that the integration "follows" the DHCP IP. + # If the discovery finds another entry with the + # same ID, skip the notification. However, if the + # discovery finds a new host for a panel that was + # already registered for a given host (leave PORT + # comparison aside as we don't want to get + # notified twice for HTTP and HTTPS), update the + # entry so that the integration "follows" the + # DHCP IP. if ( entry.data.get(CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD) == CONF_ELMAX_MODE_DIRECT @@ -490,7 +499,8 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( entry, unique_id=entry.unique_id, data=new_data ) - # Abort the configuration, as there already is an entry for this PANEL-ID. + # Abort the configuration, as there already + # is an entry for this PANEL-ID. return self.async_abort(reason="already_configured") return None diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py index abcc098359e..16423422ada 100644 --- a/homeassistant/components/elmax/coordinator.py +++ b/homeassistant/components/elmax/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the elmax-cloud integration.""" -from __future__ import annotations - from asyncio import timeout from datetime import timedelta import logging @@ -132,7 +130,8 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # Store a dictionary for fast endpoint state access self._state_by_endpoint = {k.endpoint_id: k for k in status.all_endpoints} - # If panel supports it and a it hasn't been registered yet, register the push notification handler + # If panel supports it and it hasn't been registered yet, + # register the push notification handler if status.push_feature and self._push_notification_handler is None: self._register_push_notification_handler() diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 6993d5e44be..a54b3ace436 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -1,7 +1,5 @@ """Elmax cover platform.""" -from __future__ import annotations - import logging from typing import Any @@ -17,7 +15,7 @@ from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) -_COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover motion status +_COMMAND_BY_MOTION_STATUS = { # Maps the stop command for each cover motion status CoverStatus.DOWN: CoverCommand.DOWN, CoverStatus.UP: CoverCommand.UP, CoverStatus.IDLE: None, @@ -39,7 +37,9 @@ async def async_setup_entry( def _discover_new_devices(): if (panel_status := coordinator.data) is None: - return # In case the panel is offline, its status will be None. In that case, simply do nothing + # In case the panel is offline, its status will be + # None. In that case, simply do nothing + return # Otherwise, add all the entities we found entities = [] @@ -105,8 +105,10 @@ class ElmaxCover(ElmaxEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - # To stop the cover, Elmax requires us to re-issue the same command once again. - # To detect the current motion status, we request an immediate refresh to the coordinator + # To stop the cover, Elmax requires us to re-issue the + # same command once again. To detect the current motion + # status, we request an immediate refresh to the + # coordinator await self.coordinator.async_request_refresh() motion_status = self.coordinator.get_cover_state( self._device.endpoint_id diff --git a/homeassistant/components/elmax/entity.py b/homeassistant/components/elmax/entity.py index a49fdc14c3e..68dbf853e09 100644 --- a/homeassistant/components/elmax/entity.py +++ b/homeassistant/components/elmax/entity.py @@ -1,7 +1,5 @@ """Elmax integration common classes and utilities.""" -from __future__ import annotations - from elmax_api.model.endpoint import DeviceEndpoint from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index 28a97fefd91..031ff260d00 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -28,7 +28,8 @@ async def async_setup_entry( def _discover_new_devices(): panel_status: PanelStatus = coordinator.data - # In case the panel is offline, its status will be None. In that case, simply do nothing + # In case the panel is offline, its status will be + # None. In that case, simply do nothing if panel_status is None: return diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py index c4645dc39b3..3e93970f342 100644 --- a/homeassistant/components/elv/switch.py +++ b/homeassistant/components/elv/switch.py @@ -1,7 +1,5 @@ """Support for PCA 301 smart switch.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/elvia/__init__.py b/homeassistant/components/elvia/__init__.py index f1eafe64079..143141da8aa 100644 --- a/homeassistant/components/elvia/__init__.py +++ b/homeassistant/components/elvia/__init__.py @@ -1,7 +1,5 @@ """The Elvia integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/elvia/config_flow.py b/homeassistant/components/elvia/config_flow.py index 2db6e4bb2b5..4e53ce27b1a 100644 --- a/homeassistant/components/elvia/config_flow.py +++ b/homeassistant/components/elvia/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Elvia integration.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 40795458f66..c663219e585 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -1,7 +1,5 @@ """Importer for the Elvia integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 812e58ecc19..3e601a43e81 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -1,7 +1,5 @@ """Support to interface with the Emby API.""" -from __future__ import annotations - import logging from pyemby import EmbyServer diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index 375077a83d4..0b422f0f24b 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -1,7 +1,5 @@ """Configflow for the emoncms integration.""" -from __future__ import annotations - from typing import Any from pyemoncms import EmoncmsClient diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 185726a663a..6869074b99b 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring emoncms feeds.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py index 4316487352b..13cd71382e2 100644 --- a/homeassistant/components/emonitor/__init__.py +++ b/homeassistant/components/emonitor/__init__.py @@ -1,7 +1,5 @@ """The SiteSage Emonitor integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 3e2f6dcbc8f..3c260bf7a63 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -1,7 +1,5 @@ """Support for a Emonitor channel sensor.""" -from __future__ import annotations - from aioemonitor.monitor import EmonitorChannel, EmonitorStatus from homeassistant.components.sensor import ( diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 556831496c6..014a23cea0a 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -1,7 +1,5 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" -from __future__ import annotations - import logging from aiohttp import web diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 91876d81508..c5cd4a6e76c 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -1,7 +1,5 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" -from __future__ import annotations - from functools import cache import logging diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 9ccb8a64367..103ab28f480 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,7 +1,5 @@ """Support for a Hue API to control Home Assistant.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from functools import lru_cache @@ -650,7 +648,8 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: if cached_state_entry is not None: entry_state, entry_time = cached_state_entry if entry_time is None: - # Handle the case where the entity is listed in config.off_maps_to_on_domains. + # Handle the case where the entity is listed + # in config.off_maps_to_on_domains. cached_state = entry_state elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ STATE_ON @@ -791,7 +790,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: color_temp_supported = is_light and light.color_temp_supported(color_modes) if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) - # Same as Color light, but which supports additional setting of color temperature + # Same as Color light, but supports additional color temperature setting retval["type"] = "Extended color light" retval["modelid"] = "HASS231" json_state.update( @@ -809,7 +808,8 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: json_state[HUE_API_STATE_COLORMODE] = "ct" elif color_supported: # Color light (Zigbee Device ID: 0x0200) - # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) + # Supports on/off, dimming and color control + # (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" retval["modelid"] = "HASS213" json_state.update( diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 4fb0be81814..ac935efcdcc 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,7 +1,5 @@ """Support UPNP discovery method that mimics Hue hubs.""" -from __future__ import annotations - import asyncio from contextlib import suppress import logging @@ -171,7 +169,7 @@ async def async_create_upnp_datagram_endpoint( ssdp_socket.bind(("" if upnp_bind_multicast else host_ip_addr, BROADCAST_PORT)) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() transport_protocol = await loop.create_datagram_endpoint( lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port), diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index 11f4ce80490..7fcf9235c96 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -1,4 +1,4 @@ -"""Support for local power state reporting of entities by emulating TP-Link Kasa smart plugs.""" +"""Support for local power state reporting by emulating TP-Link Kasa plugs.""" import logging diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 2a517aee359..bc7ed9de582 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.14.0"] + "requirements": ["sense-energy==0.14.1"] } diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index 725987418da..e2e942cc6aa 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -47,6 +47,8 @@ class EmulatedRokuFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=default_name): str, vol.Required(CONF_LISTEN_PORT, default=default_port): vol.Coerce( int diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py index fe2d3b0da14..c60c3c64e76 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -1,7 +1,5 @@ """The Energy integration.""" -from __future__ import annotations - from homeassistant.components import frontend from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 7484cc4a504..3194eec124e 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -1,7 +1,5 @@ """Energy data.""" -from __future__ import annotations - import asyncio from collections import Counter from collections.abc import Awaitable, Callable @@ -140,6 +138,9 @@ class GridSourceType(TypedDict): cost_adjustment_day: float + # An optional custom name for display in energy graphs + name: NotRequired[str] + class SolarSourceType(TypedDict): """Dictionary holding the source of energy production.""" @@ -150,6 +151,9 @@ class SolarSourceType(TypedDict): stat_rate: NotRequired[str] config_entry_solar_forecast: list[str] | None + # An optional custom name for display in energy graphs + name: NotRequired[str] + class BatterySourceType(TypedDict): """Dictionary holding the source of battery storage.""" @@ -165,6 +169,12 @@ class BatterySourceType(TypedDict): # User's original power sensor configuration power_config: NotRequired[PowerConfig] + # statistic_id of a sensor (unit %) reporting the battery state of charge + stat_soc: NotRequired[str] + + # An optional custom name for display in energy graphs + name: NotRequired[str] + class GasSourceType(TypedDict): """Dictionary holding the source of gas consumption.""" @@ -185,6 +195,9 @@ class GasSourceType(TypedDict): entity_energy_price: str | None # entity_id of an entity providing price ($/m³) number_energy_price: float | None # Price for energy ($/m³) + # An optional custom name for display in energy graphs + name: NotRequired[str] + class WaterSourceType(TypedDict): """Dictionary holding the source of water consumption.""" @@ -205,6 +218,9 @@ class WaterSourceType(TypedDict): entity_energy_price: str | None # entity_id of an entity providing price ($/m³) number_energy_price: float | None # Price for energy ($/m³) + # An optional custom name for display in energy graphs + name: NotRequired[str] + type SourceType = ( GridSourceType @@ -225,7 +241,7 @@ class DeviceConsumption(TypedDict): stat_rate: NotRequired[str] # An optional custom name for display in energy graphs - name: str | None + name: NotRequired[str] # An optional statistic_id identifying a device # that includes this device's consumption in its total @@ -361,8 +377,9 @@ POWER_CONFIG_SCHEMA = vol.All( GRID_POWER_SOURCE_SCHEMA = vol.All( vol.Schema( { - # stat_rate and power_config are both optional schema keys, but the validator - # requires that at least one is provided; power_config takes precedence + # stat_rate and power_config are both optional + # schema keys, but the validator requires that at + # least one is provided; power_config takes precedence vol.Optional("stat_rate"): str, vol.Optional("power_config"): POWER_CONFIG_SCHEMA, } @@ -424,7 +441,8 @@ def _grid_ensure_at_least_one_stat( and val.get("power_config") is None ): raise vol.Invalid( - "Grid must have at least one of: import meter, export meter, or power sensor" + "Grid must have at least one of: import meter," + " export meter, or power sensor" ) return val @@ -455,6 +473,7 @@ GRID_SOURCE_SCHEMA = vol.All( vol.Optional("stat_rate"): str, vol.Optional("power_config"): POWER_CONFIG_SCHEMA, vol.Required("cost_adjustment_day"): vol.Coerce(float), + vol.Optional("name"): str, } ), _reject_price_for_external_stat(stat_key="stat_energy_from"), @@ -474,6 +493,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema( vol.Required("stat_energy_from"): str, vol.Optional("stat_rate"): str, vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), + vol.Optional("name"): str, } ) BATTERY_SOURCE_SCHEMA = vol.Schema( @@ -485,6 +505,8 @@ BATTERY_SOURCE_SCHEMA = vol.Schema( # If power_config is provided, it takes precedence and stat_rate is overwritten vol.Optional("stat_rate"): str, vol.Optional("power_config"): POWER_CONFIG_SCHEMA, + vol.Optional("stat_soc"): str, + vol.Optional("name"): str, } ) @@ -500,6 +522,7 @@ GAS_SOURCE_SCHEMA = vol.All( vol.Remove("entity_energy_from"): vol.Any(str, None), vol.Optional("entity_energy_price"): vol.Any(str, None), vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + vol.Optional("name"): str, } ), _reject_price_for_external_stat(stat_key="stat_energy_from"), @@ -513,6 +536,7 @@ WATER_SOURCE_SCHEMA = vol.All( vol.Optional("stat_cost"): vol.Any(str, None), vol.Optional("entity_energy_price"): vol.Any(str, None), vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + vol.Optional("name"): str, } ), _reject_price_for_external_stat(stat_key="stat_energy_from"), @@ -605,7 +629,8 @@ def _migrate_legacy_grid_to_unified( Migration pairs arrays by index position: - flow_from[i], flow_to[i], and power[i] combine into grid connection i - If arrays have different lengths, missing entries get None for that field - - The number of grid connections equals max(len(flow_from), len(flow_to), len(power)) + - The number of grid connections equals + max(len(flow_from), len(flow_to), len(power)) """ flow_from = old_grid.get("flow_from", []) flow_to = old_grid.get("flow_to", []) diff --git a/homeassistant/components/energy/helpers.py b/homeassistant/components/energy/helpers.py index f97e598cc04..127e0ef5af9 100644 --- a/homeassistant/components/energy/helpers.py +++ b/homeassistant/components/energy/helpers.py @@ -1,7 +1,5 @@ """Helpers for the Energy integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e228e11d00d..b3880dd3e93 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,7 +1,5 @@ """Helper sensor for calculating utility costs.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Mapping import copy @@ -16,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import +from homeassistant.components.sensor.recorder import ( # pylint: disable=home-assistant-component-root-import reset_detected, ) from homeassistant.const import ( @@ -193,7 +191,8 @@ class SensorManager: to_remove, ) - # Handle grid export compensation (unified format uses different price fields) + # Handle grid export compensation + # (unified format uses different price fields) if energy_source["type"] == "grid": self._process_grid_export_sensor( energy_source, @@ -223,7 +222,8 @@ class SensorManager: if config.get(adapter.total_money_key) is not None: return - # Skip if the energy stat is not configured (e.g., export-only or power-only grids) + # Skip if the energy stat is not configured + # (e.g., export-only or power-only grids) stat_energy = config.get(adapter.stat_energy_key) if not stat_energy: return @@ -309,7 +309,8 @@ class SensorManager: source_type = energy_source.get("type") if source_type in ("battery", "grid"): - # Both battery and grid now use unified format with power_config at top level + # Both battery and grid now use unified format + # with power_config at top level power_config = energy_source.get("power_config") if power_config and self._needs_power_sensor(power_config): self._create_or_keep_power_sensor( @@ -368,10 +369,12 @@ class EnergyCostSensor(SensorEntity): - entity_energy_price: Entity ID providing price per unit (e.g., $/kWh) - number_energy_price: Fixed price per unit - Note: For grid export compensation, the unified format uses different field names - (entity_energy_price_export, number_energy_price_export). The _process_grid_export_sensor - method in SensorManager creates a wrapper config that maps these to the standard - field names (entity_energy_price, number_energy_price) so this class can use them. + Note: For grid export compensation, the unified format uses + different field names (entity_energy_price_export, + number_energy_price_export). The _process_grid_export_sensor + method in SensorManager creates a wrapper config that maps + these to the standard field names (entity_energy_price, + number_energy_price) so this class can use them. """ _attr_entity_registry_visible_default = False @@ -394,8 +397,8 @@ class EnergyCostSensor(SensorEntity): self._attr_state_class = SensorStateClass.TOTAL self._config = config self._last_energy_sensor_state: State | None = None - # add_finished is set when either of async_added_to_hass or add_to_platform_abort - # is called + # add_finished is set when either of async_added_to_hass + # or add_to_platform_abort is called self.add_finished: asyncio.Future[None] = ( asyncio.get_running_loop().create_future() ) @@ -666,6 +669,12 @@ class EnergyPowerSensor(SensorEntity): self._is_inverted = "stat_rate_inverted" in config self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config + # Combined mode always emits Watts because _update_state converts + # heterogeneous source units to W internally. Inverted mode copies + # the source unit in _update_state to track source changes. + if self._is_combined: + self._attr_native_unit_of_measurement = UnitOfPower.WATT + # Determine source sensors if self._is_inverted: self._source_sensors = [config["stat_rate_inverted"]] @@ -715,6 +724,9 @@ class EnergyPowerSensor(SensorEntity): self._attr_native_value = None return + self._attr_native_unit_of_measurement = source_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) self._attr_native_value = value * -1 elif self._is_combined: @@ -763,13 +775,6 @@ class EnergyPowerSensor(SensorEntity): # Check first sensor if source_entry := entity_reg.async_get(self._source_sensors[0]): device_id = source_entry.device_id - # For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit - if self._is_combined: - self._attr_native_unit_of_measurement = UnitOfPower.WATT - else: - self._attr_native_unit_of_measurement = ( - source_entry.unit_of_measurement - ) # Get source name from registry source_name = source_entry.name or source_entry.original_name # Assign power sensor to same device as source sensor(s) diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py index 96b122da839..970bdc2699d 100644 --- a/homeassistant/components/energy/types.py +++ b/homeassistant/components/energy/types.py @@ -1,7 +1,5 @@ """Types for the energy platform.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import Protocol, TypedDict diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 2e4f2715dd8..fe8eee2ba10 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,7 +1,5 @@ """Validate the energy preferences provide valid data.""" -from __future__ import annotations - from collections.abc import Mapping, Sequence import dataclasses import functools diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 3d7bc60c6fb..6d984a49a16 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -1,7 +1,5 @@ """The Energy websocket API.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable, Coroutine diff --git a/homeassistant/components/energyid/__init__.py b/homeassistant/components/energyid/__init__.py index fc7db26f655..740b1e4ee69 100644 --- a/homeassistant/components/energyid/__init__.py +++ b/homeassistant/components/energyid/__init__.py @@ -1,7 +1,5 @@ """The EnergyID integration.""" -from __future__ import annotations - from dataclasses import dataclass import datetime as dt from datetime import timedelta @@ -12,7 +10,7 @@ from aiohttp import ClientError, ClientResponseError from energyid_webhooks.client_v2 import WebhookClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import CONF_DEVICE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -30,7 +28,6 @@ from homeassistant.helpers.event import ( ) from .const import ( - CONF_DEVICE_ID, CONF_DEVICE_NAME, CONF_ENERGYID_KEY, CONF_HA_ENTITY_UUID, @@ -198,7 +195,8 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None: if not hass.states.get(ha_entity_id): # Entity exists in registry but is not present in the state machine _LOGGER.debug( - "Entity %s does not exist in state machine yet, will track when available (mapping to %s)", + "Entity %s does not exist in state machine yet," + " will track when available (mapping to %s)", ha_entity_id, energyid_key, ) @@ -223,6 +221,7 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None: ): try: value = float(current_state.state) + # pylint: disable-next=home-assistant-enforce-utcnow timestamp = current_state.last_updated or dt.datetime.now(dt.UTC) client.get_or_create_sensor(energyid_key).update(value, timestamp) except ValueError, TypeError: @@ -330,7 +329,8 @@ def _async_handle_state_change( "Updating EnergyID sensor %s with value %s", energyid_key, new_state.state ) else: - # Entity not mapped yet - check if it should be (handles late-appearing entities) + # Entity not mapped yet - check if it should be + # (handles late-appearing entities) ent_reg = er.async_get(hass) for subentry in entry.subentries.values(): entity_uuid = subentry.data.get(CONF_HA_ENTITY_UUID) @@ -346,7 +346,8 @@ def _async_handle_state_change( runtime_data.mappings[entity_id] = energyid_key client.get_or_create_sensor(energyid_key) _LOGGER.debug( - "Entity %s now available in state machine, adding to mappings (key: %s)", + "Entity %s now available in state machine," + " adding to mappings (key: %s)", entity_id, energyid_key, ) diff --git a/homeassistant/components/energyid/config_flow.py b/homeassistant/components/energyid/config_flow.py index 8a52ca9175a..15cd8ed5c6f 100644 --- a/homeassistant/components/energyid/config_flow.py +++ b/homeassistant/components/energyid/config_flow.py @@ -15,13 +15,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, ConfigSubentryFlow, ) +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.instance_id import async_get as async_get_instance_id from .const import ( - CONF_DEVICE_ID, CONF_DEVICE_NAME, CONF_PROVISIONING_KEY, CONF_PROVISIONING_SECRET, @@ -130,7 +130,8 @@ class EnergyIDConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: instance_id = await async_get_instance_id(self.hass) - # Note: This device_id is for EnergyID's webhook system, not related to HA's device registry + # Note: This device_id is for EnergyID's webhook + # system, not related to HA's device registry device_suffix = f"{int(asyncio.get_event_loop().time() * 1000)}" device_id = ( f"{ENERGYID_DEVICE_ID_FOR_WEBHOOK_PREFIX}{instance_id}_{device_suffix}" @@ -236,7 +237,8 @@ class EnergyIDConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauthentication upon an API authentication error.""" - # Note: This device_id is for EnergyID's webhook system, not related to HA's device registry + # Note: This device_id is for EnergyID's webhook + # system, not related to HA's device registry self._flow_data = { CONF_DEVICE_ID: entry_data[CONF_DEVICE_ID], CONF_DEVICE_NAME: entry_data[CONF_DEVICE_NAME], diff --git a/homeassistant/components/energyid/const.py b/homeassistant/components/energyid/const.py index 1fe5008fff8..dd272bf48b6 100644 --- a/homeassistant/components/energyid/const.py +++ b/homeassistant/components/energyid/const.py @@ -8,7 +8,6 @@ NAME: Final = "EnergyID" # --- Config Flow and Entry Data --- CONF_PROVISIONING_KEY: Final = "provisioning_key" CONF_PROVISIONING_SECRET: Final = "provisioning_secret" -CONF_DEVICE_ID: Final = "device_id" CONF_DEVICE_NAME: Final = "device_name" # --- Subentry (Mapping) Data --- diff --git a/homeassistant/components/energyid/quality_scale.yaml b/homeassistant/components/energyid/quality_scale.yaml index ff4ed64e2c9..c84f410d2ac 100644 --- a/homeassistant/components/energyid/quality_scale.yaml +++ b/homeassistant/components/energyid/quality_scale.yaml @@ -88,7 +88,7 @@ rules: entity-translations: status: exempt comment: This integration does not create its own entities. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: This integration does not create its own entities. diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index fc2855374dd..d596853c99f 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -1,7 +1,5 @@ """The EnergyZero integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/energyzero/config_flow.py b/homeassistant/components/energyzero/config_flow.py index 72a1e376dcf..39cf70cc3d9 100644 --- a/homeassistant/components/energyzero/config_flow.py +++ b/homeassistant/components/energyzero/config_flow.py @@ -1,7 +1,5 @@ """Config flow for EnergyZero integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index 7079b720f4d..84c114d6779 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -1,7 +1,5 @@ """Constants for the EnergyZero integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 122c2f76deb..c48a712d57d 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -1,7 +1,5 @@ """The Coordinator for EnergyZero.""" -from __future__ import annotations - from datetime import timedelta from typing import NamedTuple diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 0a45d87fee5..2edd922dc44 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for EnergyZero.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 38349b89ff7..c7b1c90faa5 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -1,7 +1,5 @@ """Support for EnergyZero sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -181,7 +179,10 @@ class EnergyZeroSensorEntity( self.entity_id = ( f"{SENSOR_DOMAIN}.{DOMAIN}_{description.service_type}_{description.key}" ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.service_type}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{description.service_type}_{description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index 8609cb745f3..5d1f36f7146 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -1,7 +1,5 @@ """The EnergyZero services.""" -from __future__ import annotations - from datetime import date, datetime from enum import Enum from functools import partial @@ -98,7 +96,7 @@ def __get_coordinator(call: ServiceCall) -> EnergyZeroDataUpdateCoordinator: "config_entry": entry_id, }, ) - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="unloaded_config_entry", @@ -127,7 +125,7 @@ async def __get_prices( data: Electricity | Gas - if price_type == PriceType.GAS: + if price_type is PriceType.GAS: data = await coordinator.energyzero.get_gas_prices_legacy( start_date=start, end_date=end, diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index a3cdd1858ed..704f4332fa3 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,7 +1,5 @@ """Support for Enigma2 media players.""" -from __future__ import annotations - import contextlib from logging import getLogger @@ -70,6 +68,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti async def async_turn_off(self) -> None: """Turn off media player.""" if self.coordinator.device.turn_off_to_deep: + # pylint: disable-next=home-assistant-action-swallowed-exception with contextlib.suppress(ServerDisconnectedError): await self.coordinator.device.set_powerstate(PowerState.DEEP_STANDBY) self._attr_available = False @@ -150,7 +149,9 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti if not self.coordinator.data.in_standby: self._attr_extra_state_attributes = { ATTR_MEDIA_CURRENTLY_RECORDING: self.coordinator.data.is_recording, - ATTR_MEDIA_DESCRIPTION: self.coordinator.data.currservice.fulldescription, + ATTR_MEDIA_DESCRIPTION: ( + self.coordinator.data.currservice.fulldescription + ), ATTR_MEDIA_START_TIME: self.coordinator.data.currservice.begin, ATTR_MEDIA_END_TIME: self.coordinator.data.currservice.end, } diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 5c5dad08f76..e63d5b0bba3 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -1,7 +1,5 @@ """Support for EnOcean binary sensors.""" -from __future__ import annotations - from enocean_async import ERP1Telegram import voluptuous as vol diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py index caf3016758a..0761ad18008 100644 --- a/homeassistant/components/enocean/entity.py +++ b/homeassistant/components/enocean/entity.py @@ -12,7 +12,9 @@ from .const import LOGGER, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE def combine_hex(dev_id: list[int]) -> int: """Combine list of integer values to one big integer. - This function replaces the previously used function from the enocean library and is considered tech debt that will have to be replaced. + This function replaces the previously used function from the + enocean library and is considered tech debt that will have + to be replaced. """ value = 0 for byte in dev_id: @@ -58,7 +60,10 @@ class EnOceanEntity(Entity): def send_command( self, data: list[int], optional: list[int], packet_type: ESP3PacketType ) -> None: - """Send a command via the EnOcean dongle, if data and optional are valid bytes; otherwise, ignore.""" + """Send a command via the EnOcean dongle. + + If data and optional are valid bytes; otherwise, ignore. + """ try: packet = ESP3Packet(packet_type, data=bytes(data), optional=bytes(optional)) dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet) diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 645667c8412..2c870f984c5 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -1,7 +1,5 @@ """Support for EnOcean light sources.""" -from __future__ import annotations - import math from typing import Any diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index b852690d05b..4d177843ea4 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -1,7 +1,5 @@ """Support for EnOcean sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 676ca99eb7e..b23a273dfee 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -1,7 +1,5 @@ """Support for EnOcean switches.""" -from __future__ import annotations - from typing import Any from enocean_async import EEP, EEP_SPECIFICATIONS, EEPHandler, EEPMessage, ERP1Telegram diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 62d276b4224..3af511fa608 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -1,7 +1,5 @@ """The Enphase Envoy integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from pyenphase import Envoy diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 5dcc2f28c7f..6f420e21074 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 9ba11eafa5d..bd6dc9230e0 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Enphase Envoy integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 465b2f9d587..d5f46a66650 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -16,6 +16,9 @@ PLATFORMS = [ INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) +SETUP_RETRY_TIMEOUT = 50 +OPERATIONAL_RETRY_TIMEOUT = 200 + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES = "diagnostics_include_fixtures" OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE = False diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 57ce924733c..288b89883fa 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -1,7 +1,5 @@ """The enphase_envoy component.""" -from __future__ import annotations - import contextlib import datetime from datetime import timedelta @@ -20,7 +18,12 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, INVALID_AUTH_ERRORS +from .const import ( + DOMAIN, + INVALID_AUTH_ERRORS, + OPERATIONAL_RETRY_TIMEOUT, + SETUP_RETRY_TIMEOUT, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -52,6 +55,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.username = entry_data[CONF_USERNAME] self.password = entry_data[CONF_PASSWORD] self._setup_complete = False + self._operational_timeout = False self.envoy_firmware = "" self.interface = None self._cancel_token_refresh: CALLBACK_TYPE | None = None @@ -194,7 +198,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @callback def _async_mark_setup_complete(self) -> None: - """Mark setup as complete and setup firmware checks and token refresh if needed.""" + """Mark setup as complete, setup firmware checks and token refresh.""" self._setup_complete = True self.async_cancel_firmware_refresh() self._cancel_firmware_refresh = async_track_time_interval( @@ -267,10 +271,15 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: if not self._setup_complete: _LOGGER.debug("update on try %s, setup not complete", tries) + self.envoy.set_retry_policy(max_delay=SETUP_RETRY_TIMEOUT) + self._operational_timeout = False await self._async_setup_and_authenticate() self._async_mark_setup_complete() # dump all received data in debug mode to assist troubleshooting envoy_data = await envoy.update() + if not self._operational_timeout: + self.envoy.set_retry_policy(max_delay=OPERATIONAL_RETRY_TIMEOUT) + self._operational_timeout = True except INVALID_AUTH_ERRORS as err: _LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err) if self._setup_complete and tries == 0: @@ -304,7 +313,8 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): new_firmware := envoy.firmware ): _LOGGER.warning( - "Envoy firmware changed from: %s to: %s, reloading enphase envoy integration", + "Envoy firmware changed from: %s to: %s," + " reloading enphase envoy integration", current_firmware, new_firmware, ) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1517d2a1d67..e9a436ec820 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Enphase Envoy.""" -from __future__ import annotations - import copy from datetime import datetime from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 32be5ec8b8b..09432e0f2fd 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -1,7 +1,5 @@ """Support for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index d3180b1f983..76afa7624a3 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.4.6"], + "requirements": ["pyenphase==2.4.8"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 6e8e48d684b..5e031b873d0 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -1,7 +1,5 @@ """Number platform for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from operator import attrgetter diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 358275942ca..233191e3bcb 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,7 +1,5 @@ """Select platform for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index bc82b85eb50..7527b208904 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -1,7 +1,5 @@ """Support for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, replace import datetime @@ -583,7 +581,6 @@ CT_SENSORS = ( EnvoyCTSensorEntityDescription( key=key, translation_key=(translation_key if translation_key != "" else key), - state_class=None, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), @@ -653,7 +650,6 @@ ENCHARGE_INVENTORY_SENSORS = ( EnvoyEnchargeSensorEntityDescription( key=LAST_REPORTED_KEY, translation_key=LAST_REPORTED_KEY, - native_unit_of_measurement=None, device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda encharge: dt_util.utc_from_timestamp(encharge.last_report_date), ), @@ -731,7 +727,6 @@ COLLAR_SENSORS = ( EnvoyCollarSensorEntityDescription( key=LAST_REPORTED_KEY, translation_key=LAST_REPORTED_KEY, - native_unit_of_measurement=None, device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), ), @@ -769,7 +764,6 @@ C6CC_SENSORS = ( EnvoyC6CCSensorEntityDescription( key=LAST_REPORTED_KEY, translation_key=LAST_REPORTED_KEY, - native_unit_of_measurement=None, device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date), ), diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 02736979e66..11e448c25eb 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -1,7 +1,5 @@ """Switch platform for Enphase Envoy solar energy monitor.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 56f7cf34916..c7a520673ae 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -1,7 +1,5 @@ """Real-time information about public transport departures in Norway.""" -from __future__ import annotations - from datetime import datetime, timedelta from random import randint diff --git a/homeassistant/components/envertech_evt800/__init__.py b/homeassistant/components/envertech_evt800/__init__.py new file mode 100644 index 00000000000..192d61c7e99 --- /dev/null +++ b/homeassistant/components/envertech_evt800/__init__.py @@ -0,0 +1,37 @@ +"""Envertech EVT800 integration.""" + +from pyenvertechevt800 import EnvertechEVT800 + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import EnvertechEVT800Coordinator + +type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator] + + +async def async_setup_entry( + hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry +) -> bool: + """Set up Envertech EVT800 from a config entry.""" + evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]) + evt800.start() + + coordinator = EnvertechEVT800Coordinator(hass, evt800, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/envertech_evt800/config_flow.py b/homeassistant/components/envertech_evt800/config_flow.py new file mode 100644 index 00000000000..6e08ca9e0d9 --- /dev/null +++ b/homeassistant/components/envertech_evt800/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the ENVERTECH EVT800 integration.""" + +from typing import Any + +from pyenvertechevt800 import EnvertechEVT800 +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE + +SCHEMA_DEVICE = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Envertech EVT800.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First step in config flow.""" + errors: dict[str, str] = {} + if user_input is not None: + ip_address = user_input[CONF_IP_ADDRESS] + port = user_input[CONF_PORT] + + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: ip_address, + CONF_PORT: port, + } + ) + evt800 = EnvertechEVT800(ip_address, port) + + can_connect = await evt800.test_connection() + + if not can_connect: + errors["base"] = "cannot_connect" + + if not errors: + return self.async_create_entry( + title="Envertech EVT800", + data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input}, + ) + + return self.async_show_form( + step_id="user", + data_schema=SCHEMA_DEVICE, + errors=errors, + ) diff --git a/homeassistant/components/envertech_evt800/const.py b/homeassistant/components/envertech_evt800/const.py new file mode 100644 index 00000000000..17d9187168d --- /dev/null +++ b/homeassistant/components/envertech_evt800/const.py @@ -0,0 +1,11 @@ +"""Constants for the ENVERTECH EVT800 integration.""" + +from homeassistant.const import Platform + +DOMAIN = "envertech_evt800" + +PLATFORMS = [Platform.SENSOR] + +DEFAULT_PORT = 14889 +TYPE_TCP_SERVER_MODE = ["TCP_SERVER"] +DEFAULT_SCAN_INTERVAL = 60 diff --git a/homeassistant/components/envertech_evt800/coordinator.py b/homeassistant/components/envertech_evt800/coordinator.py new file mode 100644 index 00000000000..96d123371e7 --- /dev/null +++ b/homeassistant/components/envertech_evt800/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Envertech EVT800 integration.""" + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +import pyenvertechevt800 + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +if TYPE_CHECKING: + from . import EnvertechEVT800ConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Envertech EVT800.""" + + config_entry: EnvertechEVT800ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: pyenvertechevt800.EnvertechEVT800, + config_entry: EnvertechEVT800ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=config_entry, + ) + self.client = client + client.set_data_listener(self.async_set_updated_data) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return self.client.data diff --git a/homeassistant/components/envertech_evt800/entity.py b/homeassistant/components/envertech_evt800/entity.py new file mode 100644 index 00000000000..a610a6f9b3d --- /dev/null +++ b/homeassistant/components/envertech_evt800/entity.py @@ -0,0 +1,29 @@ +"""Envertech EVT800 entity.""" + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EnvertechEVT800Coordinator + + +class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]): + """Envertech EVT800 entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None: + """Initialize Envertech EVT800 entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/", + manufacturer="Envertech", + model_id="EVT800", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.client.online diff --git a/homeassistant/components/envertech_evt800/manifest.json b/homeassistant/components/envertech_evt800/manifest.json new file mode 100644 index 00000000000..cff3f0c14c9 --- /dev/null +++ b/homeassistant/components/envertech_evt800/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "envertech_evt800", + "name": "ENVERTECH EVT800", + "codeowners": ["@daniel-bergmann-00"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/envertech_evt800", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pyenvertechevt800"], + "quality_scale": "bronze", + "requirements": ["pyenvertechevt800==0.2.4"] +} diff --git a/homeassistant/components/envertech_evt800/quality_scale.yaml b/homeassistant/components/envertech_evt800/quality_scale.yaml new file mode 100644 index 00000000000..5263942a8f7 --- /dev/null +++ b/homeassistant/components/envertech_evt800/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: todo + comment: | + The integration does not have any authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Integration connects to a single device + + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + The integration does not have any own exceptions. + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + The integration does not support repairing issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device per configuration entry. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + No websession is used + strict-typing: todo diff --git a/homeassistant/components/envertech_evt800/sensor.py b/homeassistant/components/envertech_evt800/sensor.py new file mode 100644 index 00000000000..68b3215c14e --- /dev/null +++ b/homeassistant/components/envertech_evt800/sensor.py @@ -0,0 +1,185 @@ +"""Envertech EVT800 sensor.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import EnvertechEVT800ConfigEntry +from .coordinator import EnvertechEVT800Coordinator +from .entity import EnvertechEVT800Entity + +SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="id_1", + entity_registry_enabled_default=False, + translation_key="mppt_id_1", + ), + SensorEntityDescription( + key="id_2", + entity_registry_enabled_default=False, + translation_key="mppt_id_2", + ), + SensorEntityDescription( + key="input_voltage_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + translation_key="input_voltage_1", + ), + SensorEntityDescription( + key="input_voltage_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + translation_key="input_voltage_2", + ), + SensorEntityDescription( + key="power_1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + translation_key="power_1", + ), + SensorEntityDescription( + key="power_2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + translation_key="power_2", + ), + SensorEntityDescription( + key="current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=2, + translation_key="current_1", + ), + SensorEntityDescription( + key="current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=2, + translation_key="current_2", + ), + SensorEntityDescription( + key="ac_frequency_1", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + translation_key="ac_frequency_1", + ), + SensorEntityDescription( + key="ac_frequency_2", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + translation_key="ac_frequency_2", + ), + SensorEntityDescription( + key="ac_voltage_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=0, + translation_key="ac_voltage_1", + ), + SensorEntityDescription( + key="ac_voltage_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=0, + translation_key="ac_voltage_2", + ), + SensorEntityDescription( + key="temperature_1", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + translation_key="temperature_1", + ), + SensorEntityDescription( + key="temperature_2", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + translation_key="temperature_2", + ), + SensorEntityDescription( + key="total_energy_1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + translation_key="total_energy_1", + ), + SensorEntityDescription( + key="total_energy_2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + translation_key="total_energy_2", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: EnvertechEVT800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Envertech EVT800 sensors.""" + coordinator = config_entry.runtime_data + + async_add_entities( + EnvertechEVT800Sensor(coordinator, description) for description in SENSORS + ) + + +class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity): + """Representation of an Envertech EVT800 sensor.""" + + def __init__( + self, + coordinator: EnvertechEVT800Coordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + return self.coordinator.client.data.get(self.entity_description.key) + + @property + def available(self) -> bool: + """Unavailable if evt800 isn't connected.""" + return super().available and self.native_value is not None diff --git a/homeassistant/components/envertech_evt800/strings.json b/homeassistant/components/envertech_evt800/strings.json new file mode 100644 index 00000000000..2b62b18048a --- /dev/null +++ b/homeassistant/components/envertech_evt800/strings.json @@ -0,0 +1,76 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "ip_address": "The IP address of your Envertech EVT800 device.", + "port": "The Port of your Envertech EVT800 device." + }, + "description": "Enter your EVT800 device information.", + "title": "Setup EVT800 device" + } + } + }, + "entity": { + "sensor": { + "ac_frequency_1": { + "name": "AC Frequency MPPT 1" + }, + "ac_frequency_2": { + "name": "AC Frequency MPPT 2" + }, + "ac_voltage_1": { + "name": "AC Voltage MPPT 1" + }, + "ac_voltage_2": { + "name": "AC Voltage MPPT 2" + }, + "current_1": { + "name": "DC Current MPPT 1" + }, + "current_2": { + "name": "DC Current MPPT 2" + }, + "input_voltage_1": { + "name": "DC Voltage MPPT 1" + }, + "input_voltage_2": { + "name": "DC Voltage MPPT 2" + }, + "mppt_id_1": { + "name": "MPPT ID 1" + }, + "mppt_id_2": { + "name": "MPPT ID 2" + }, + "power_1": { + "name": "DC Power MPPT 1" + }, + "power_2": { + "name": "DC Power MPPT 2" + }, + "temperature_1": { + "name": "Temperature MPPT 1" + }, + "temperature_2": { + "name": "Temperature MPPT 2" + }, + "total_energy_1": { + "name": "Total Energy MPPT 1" + }, + "total_energy_2": { + "name": "Total Energy MPPT 2" + } + } + } +} diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index ae2e569bc10..8a5f47beaf6 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from env_canada import ECAirQuality, ECRadar, ECWeather +from env_canada import ECAirQuality, ECMap, ECWeather from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada weather") - radar_data = ECRadar(coordinates=(lat, lon)) + radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False) radar_coordinator = ECDataUpdateCoordinator( hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index dfc7e0c7007..d667b2cd4c6 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,8 +1,6 @@ """Support for the Environment Canada radar imagery.""" -from __future__ import annotations - -from env_canada import ECRadar +from env_canada import ECMap import voluptuous as vol from homeassistant.components.camera import Camera @@ -13,13 +11,20 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import ATTR_OBSERVATION_TIME from .coordinator import ECConfigEntry, ECDataUpdateCoordinator SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { - vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]), + vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]), +} + +_RADAR_TYPE_TO_LAYER: dict[str, str] = { + "Rain": "rain", + "Snow": "snow", + "Precipitation type": "precip_type", } @@ -40,13 +45,13 @@ async def async_setup_entry( ) -class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera): +class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True _attr_translation_key = "radar" - def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None: + def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None: """Initialize the camera.""" super().__init__(coordinator) Camera.__init__(self) @@ -78,6 +83,13 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera async def async_set_radar_type(self, radar_type: str) -> None: """Set the type of radar to retrieve.""" + if radar_type == "Auto": + # Choose rain for months April through October, snow otherwise + layer = "rain" if dt_util.now().month in range(4, 11) else "snow" + else: + layer = _RADAR_TYPE_TO_LAYER[radar_type] + + # Apply new layer and clear cache to force refresh + self.radar_object.layer = layer self.radar_object.clear_cache() - self.radar_object.precip_type = radar_type.lower() - await self.radar_object.update() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index ef798164c62..031589fd65f 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -89,7 +89,8 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_LATITUDE] = info[CONF_LATITUDE] user_input[CONF_LONGITUDE] = info[CONF_LONGITUDE] - # The combination of station and language are unique for all EC weather reporting + # The combination of station and language are + # unique for all EC weather reporting await self.async_set_unique_id( f"{user_input[CONF_STATION]}-{user_input[CONF_LANGUAGE].lower()}" ) diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 89fc92b462e..0262132f89a 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -1,13 +1,11 @@ """Coordinator for the Environment Canada (EC) component.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc +from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,7 +17,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) type ECConfigEntry = ConfigEntry[ECRuntimeData] -type ECDataType = ECAirQuality | ECRadar | ECWeather +type ECDataType = ECAirQuality | ECMap | ECWeather @dataclass @@ -27,7 +25,7 @@ class ECRuntimeData: """Class to hold EC runtime data.""" aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality] - radar_coordinator: ECDataUpdateCoordinator[ECRadar] + radar_coordinator: ECDataUpdateCoordinator[ECMap] weather_coordinator: ECDataUpdateCoordinator[ECWeather] diff --git a/homeassistant/components/environment_canada/diagnostics.py b/homeassistant/components/environment_canada/diagnostics.py index 024cca15f12..c50fa17bf1b 100644 --- a/homeassistant/components/environment_canada/diagnostics.py +++ b/homeassistant/components/environment_canada/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Environment Canada.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 75d60ef16de..51a1357c84c 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -1,7 +1,5 @@ """Support for the Environment Canada weather service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml index 0e33aeec933..7999d14eefb 100644 --- a/homeassistant/components/environment_canada/services.yaml +++ b/homeassistant/components/environment_canada/services.yaml @@ -12,10 +12,11 @@ set_radar_type: fields: radar_type: required: true - example: Snow + example: Rain selector: select: options: - "Auto" - "Rain" - "Snow" + - "Precipitation type" diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index c7d04e4c03d..c88bff37590 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,7 +1,5 @@ """Platform for retrieving meteorological data from Environment Canada.""" -from __future__ import annotations - from typing import Any from env_canada import ECWeather @@ -240,9 +238,9 @@ def get_forecast(ec_data, hourly) -> list[Forecast] | None: ), } - i = 2 if half_days[0]["temperature_class"] == "high" else 1 - forecast_array.append(get_day_forecast(half_days[0:i])) - for i in range(i, len(half_days) - 1, 2): + start = 2 if half_days[0]["temperature_class"] == "high" else 1 + forecast_array.append(get_day_forecast(half_days[0:start])) + for i in range(start, len(half_days) - 1, 2): forecast_array.append(get_day_forecast(half_days[i : i + 2])) # noqa: PERF401 else: diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index ee5468ddd81..f0e8ecd449d 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -236,8 +236,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) - # Zone bypass switches are not currently created due to an issue with some panels. - # These switches will be re-added in the future after some further refactoring of the integration. + # Zone bypass switches are not currently created due + # to an issue with some panels. These switches will be + # re-added in the future after some further refactoring + # of the integration. hass.services.async_register( DOMAIN, SERVICE_CUSTOM_FUNCTION, handle_custom_function, schema=SERVICE_SCHEMA diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index c1cee5198f2..da312f47ea2 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Envisalink-based alarm control panels (Honeywell/DSC).""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index 792fae3947b..ad3954f5f29 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Envisalink zone states- represented as binary sensors.""" -from __future__ import annotations - import datetime import logging from typing import Any diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 42587aa7c2f..05fac7969a8 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["pyenvisalink"], "quality_scale": "legacy", - "requirements": ["pyenvisalink==4.7"] + "requirements": ["pyenvisalink==4.9"] } diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 4c445a76a85..956b6d844b6 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -1,7 +1,5 @@ """Support for Envisalink sensors (shows panel info).""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/envisalink/switch.py b/homeassistant/components/envisalink/switch.py index 3082057f9f3..41443065289 100644 --- a/homeassistant/components/envisalink/switch.py +++ b/homeassistant/components/envisalink/switch.py @@ -1,7 +1,5 @@ """Support for Envisalink zone bypass switches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 85b21da1dd5..af64e36b199 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -1,7 +1,5 @@ """Support for the EPH Controls Ember themostats.""" -from __future__ import annotations - from datetime import timedelta from enum import IntEnum import logging @@ -198,4 +196,6 @@ class EphEmberThermostat(ClimateEntity): @staticmethod def map_mode_eph_hass(operation_mode): """Map from eph mode to Home Assistant mode.""" + if operation_mode is None: + return HVACMode.HEAT_COOL return EPH_TO_HA_STATE.get(operation_mode.name, HVACMode.HEAT_COOL) diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py index d9fb3bee529..30f314fdf45 100644 --- a/homeassistant/components/epic_games_store/__init__.py +++ b/homeassistant/components/epic_games_store/__init__.py @@ -1,7 +1,5 @@ """The Epic Games Store integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py index 41edb5e31a7..e4ff4362142 100644 --- a/homeassistant/components/epic_games_store/calendar.py +++ b/homeassistant/components/epic_games_store/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for a Epic Games Store.""" -from __future__ import annotations - from collections import namedtuple from datetime import datetime from typing import Any diff --git a/homeassistant/components/epic_games_store/config_flow.py b/homeassistant/components/epic_games_store/config_flow.py index 9e65c93c334..a22d5991015 100644 --- a/homeassistant/components/epic_games_store/config_flow.py +++ b/homeassistant/components/epic_games_store/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Epic Games Store integration.""" -from __future__ import annotations - import logging from typing import Any @@ -87,7 +85,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title=f"Epic Games Store - Free Games ({user_input[CONF_LANGUAGE]}-{user_input[CONF_COUNTRY]})", + title=( + "Epic Games Store - Free Games" + f" ({user_input[CONF_LANGUAGE]}" + f"-{user_input[CONF_COUNTRY]})" + ), data=user_input, ) diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py index cd9f83a71fd..3275af39d35 100644 --- a/homeassistant/components/epic_games_store/coordinator.py +++ b/homeassistant/components/epic_games_store/coordinator.py @@ -1,7 +1,5 @@ """The Epic Games Store integration data coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/epic_games_store/manifest.json b/homeassistant/components/epic_games_store/manifest.json index 665eaec6668..ea4e0c2f928 100644 --- a/homeassistant/components/epic_games_store/manifest.json +++ b/homeassistant/components/epic_games_store/manifest.json @@ -1,7 +1,7 @@ { "domain": "epic_games_store", "name": "Epic Games Store", - "codeowners": ["@hacf-fr", "@Quentame"], + "codeowners": ["@Quentame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/epic_games_store", "integration_type": "service", diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py index c04c77f760d..cd58d44adca 100644 --- a/homeassistant/components/epion/__init__.py +++ b/homeassistant/components/epion/__init__.py @@ -1,7 +1,5 @@ """The Epion integration.""" -from __future__ import annotations - from epion import Epion from homeassistant.const import CONF_API_KEY, Platform diff --git a/homeassistant/components/epion/config_flow.py b/homeassistant/components/epion/config_flow.py index ce9a733ffbf..6c9e3802183 100644 --- a/homeassistant/components/epion/config_flow.py +++ b/homeassistant/components/epion/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Epion.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py index 9eb31331097..2641e443bb9 100644 --- a/homeassistant/components/epion/coordinator.py +++ b/homeassistant/components/epion/coordinator.py @@ -34,7 +34,7 @@ class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.epion_api = epion_api async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from Epion API and construct a dictionary with device IDs as keys.""" + """Fetch data from Epion API, construct a dict with device IDs as keys.""" try: response = await self.hass.async_add_executor_job( self.epion_api.get_current diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 360c1f1d8a7..c44d3d3adf2 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -1,7 +1,5 @@ """Support for Epion API.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( @@ -99,7 +97,11 @@ class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): @property def native_value(self) -> float | None: - """Return the value reported by the sensor, or None if the relevant sensor can't produce a current measurement.""" + """Return the value reported by the sensor. + + Returns None if the relevant sensor can't produce a + current measurement. + """ return self.device.get(self.entity_description.key) @property @@ -109,5 +111,9 @@ class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): @property def device(self) -> dict[str, Any]: - """Get the device record from the current coordinator data, or None if there is no data being returned for this device ID anymore.""" + """Get the device record from the current coordinator data. + + Returns None if there is no data being returned for + this device ID anymore. + """ return self.coordinator.data[self._epion_device_id] diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index 077b9cc31f7..5c9e26026fe 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -23,6 +23,8 @@ DATA_SCHEMA = vol.Schema( ) ), vol.Required(CONF_HOST): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=DOMAIN): str, } ) diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 1517fab1026..07c8b3f0257 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -1,7 +1,5 @@ """Support for Epson projector.""" -from __future__ import annotations - import logging from epson_projector import Projector, ProjectorUnavailableError diff --git a/homeassistant/components/epson/services.py b/homeassistant/components/epson/services.py index 1ebb8b62eb1..ac6277c5120 100644 --- a/homeassistant/components/epson/services.py +++ b/homeassistant/components/epson/services.py @@ -1,7 +1,5 @@ """Support for Epson projector.""" -from __future__ import annotations - from epson_projector.const import CMODE_LIST_SET import voluptuous as vol diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 957d17a55d4..9472ea7008a 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -8,13 +8,14 @@ from eq3btsmart import Thermostat from eq3btsmart.exceptions import Eq3Exception from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED +from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ @@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: if device is None: raise ConfigEntryNotReady( - f"[{eq3_config.mac_address}] Device could not be found" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "mac_address": eq3_config.mac_address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + mac_address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) thermostat = Thermostat(device) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index c11328c7ec3..f2ccda95a65 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -180,7 +180,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity): await self.async_set_hvac_mode(mode) else: raise ServiceValidationError( - f"[{self._eq3_config.mac_address}] Can't change HVAC mode to off while changing temperature", + f"[{self._eq3_config.mac_address}]" + " Can't change HVAC mode to off while" + " changing temperature", ) temperature: float | None @@ -194,6 +196,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): try: await self._thermostat.async_set_temperature(temperature) + # pylint: disable-next=home-assistant-action-swallowed-exception except Eq3Exception: _LOGGER.error( "[%s] Failed setting temperature", self._eq3_config.mac_address @@ -211,6 +214,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): try: await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) + # pylint: disable-next=home-assistant-action-swallowed-exception except Eq3Exception: _LOGGER.error("[%s] Failed setting HVAC mode", self._eq3_config.mac_address) diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index c98a47b2d5c..d7ed397dcd5 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -61,5 +61,10 @@ "name": "Lock" } } + }, + "exceptions": { + "device_not_found": { + "message": "[{mac_address}] Device could not be found: {reason}" + } } } diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index a1ac83844a2..49d4dbc37d7 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -1,7 +1,5 @@ """Support for the Escea Fireplace.""" -from __future__ import annotations - from collections.abc import Coroutine import logging from typing import Any diff --git a/homeassistant/components/escea/discovery.py b/homeassistant/components/escea/discovery.py index cbdc77536d7..fe3be72408d 100644 --- a/homeassistant/components/escea/discovery.py +++ b/homeassistant/components/escea/discovery.py @@ -1,7 +1,5 @@ """Internal discovery service for Escea Fireplace.""" -from __future__ import annotations - from pescea import ( AbstractDiscoveryService, Controller, diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 26814ae18a3..5d329b61974 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,25 +1,30 @@ """Support for esphome devices.""" -from __future__ import annotations - import logging from aioesphomeapi import APIClient, APIConnectionError from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner +from homeassistant.components.usb import ( + SerialDevice, + USBDevice, + async_register_serial_port_scanner, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, __version__ as ha_version, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify -from . import assist_satellite, dashboard, ffmpeg_proxy +from . import assist_satellite, dashboard, ffmpeg_proxy, serial_proxy from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData from .encryption_key_storage import async_get_encryption_key_storage @@ -34,12 +39,51 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CLIENT_INFO = f"Home Assistant {ha_version}" +@callback +def _async_scan_serial_ports( + hass: HomeAssistant, +) -> list[USBDevice | SerialDevice]: + """Return serial-proxy ports exposed by connected ESPHome devices.""" + ports: list[USBDevice | SerialDevice] = [] + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + entry_data = entry.runtime_data + if not entry_data.available: + continue + + device_info = entry_data.device_info + if device_info is None: + continue + + ports.extend( + SerialDevice( + device=str(serial_proxy.build_url(entry.entry_id, proxy.name)), + serial_number=( + device_info.mac_address.replace(":", "") + "-" + slugify(proxy.name) + ), + manufacturer=device_info.manufacturer, + description=f"{device_info.model} ({proxy.name})", + ) + for proxy in device_info.serial_proxies + ) + + return ports + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" ffmpeg_proxy.async_setup(hass) await assist_satellite.async_setup(hass) await dashboard.async_setup(hass) async_setup_websocket_api(hass) + + if "usb" in hass.config.components: + async_register_serial_port_scanner(hass, _async_scan_serial_ports) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + serial_proxy.register_serialx_transport(hass.loop), + ) + return True @@ -137,13 +181,15 @@ async def _async_clear_dynamic_encryption_key( # Clear the encryption key on the device by passing an empty key if not await cli.noise_encryption_set_key(b""): _LOGGER.debug( - "Could not clear dynamic encryption key for ESPHome device %s: Device rejected key removal", + "Could not clear dynamic encryption key for" + " ESPHome device %s: Device rejected key removal", entry.unique_id, ) return except APIConnectionError as exc: _LOGGER.debug( - "Could not connect to ESPHome device %s to clear dynamic encryption key: %s", + "Could not connect to ESPHome device %s to clear" + " dynamic encryption key: %s", entry.unique_id, exc, ) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 70756c31f0f..de62ae44bf2 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -1,15 +1,13 @@ """Support for ESPHome Alarm Control Panel.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import ( AlarmControlPanelCommand, + AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature, AlarmControlPanelEntityState as ESPHomeAlarmControlPanelEntityState, AlarmControlPanelInfo, AlarmControlPanelState as ESPHomeAlarmControlPanelState, - APIIntEnum, EntityInfo, ) @@ -39,8 +37,12 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ ESPHomeAlarmControlPanelState.ARMED_HOME: AlarmControlPanelState.ARMED_HOME, ESPHomeAlarmControlPanelState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY, ESPHomeAlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT, - ESPHomeAlarmControlPanelState.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION, - ESPHomeAlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + ESPHomeAlarmControlPanelState.ARMED_VACATION: ( + AlarmControlPanelState.ARMED_VACATION + ), + ESPHomeAlarmControlPanelState.ARMED_CUSTOM_BYPASS: ( + AlarmControlPanelState.ARMED_CUSTOM_BYPASS + ), ESPHomeAlarmControlPanelState.PENDING: AlarmControlPanelState.PENDING, ESPHomeAlarmControlPanelState.ARMING: AlarmControlPanelState.ARMING, ESPHomeAlarmControlPanelState.DISARMING: AlarmControlPanelState.DISARMING, @@ -48,16 +50,28 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ } ) - -class EspHomeACPFeatures(APIIntEnum): - """ESPHome AlarmControlPanel feature numbers.""" - - ARM_HOME = 1 - ARM_AWAY = 2 - ARM_NIGHT = 4 - TRIGGER = 8 - ARM_CUSTOM_BYPASS = 16 - ARM_VACATION = 32 +_FEATURES: dict[ + ESPHomeAlarmControlPanelEntityFeature, AlarmControlPanelEntityFeature +] = { + ESPHomeAlarmControlPanelEntityFeature.ARM_HOME: ( + AlarmControlPanelEntityFeature.ARM_HOME + ), + ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY: ( + AlarmControlPanelEntityFeature.ARM_AWAY + ), + ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT: ( + AlarmControlPanelEntityFeature.ARM_NIGHT + ), + ESPHomeAlarmControlPanelEntityFeature.TRIGGER: ( + AlarmControlPanelEntityFeature.TRIGGER + ), + ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: ( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ), + ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION: ( + AlarmControlPanelEntityFeature.ARM_VACATION + ), +} class EsphomeAlarmControlPanel( @@ -71,20 +85,14 @@ class EsphomeAlarmControlPanel( """Set attrs from static info.""" super()._on_static_info_update(static_info) static_info = self._static_info - feature = 0 - if static_info.supported_features & EspHomeACPFeatures.ARM_HOME: - feature |= AlarmControlPanelEntityFeature.ARM_HOME - if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: - feature |= AlarmControlPanelEntityFeature.ARM_AWAY - if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: - feature |= AlarmControlPanelEntityFeature.ARM_NIGHT - if static_info.supported_features & EspHomeACPFeatures.TRIGGER: - feature |= AlarmControlPanelEntityFeature.TRIGGER - if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: - feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: - feature |= AlarmControlPanelEntityFeature.ARM_VACATION - self._attr_supported_features = AlarmControlPanelEntityFeature(feature) + esp_flags = ESPHomeAlarmControlPanelEntityFeature( + static_info.supported_features + ) + flags = AlarmControlPanelEntityFeature(0) + for esp_flag in esp_flags: + if (flag := _FEATURES.get(esp_flag)) is not None: + flags |= flag + self._attr_supported_features = flags self._attr_code_format = ( CodeFormat.NUMBER if static_info.requires_code else None ) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 945b0714cd4..87ed768c01a 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -1,7 +1,5 @@ """Support for assist satellites in ESPHome.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterable from functools import partial @@ -69,25 +67,49 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: PipelineEventType.INTENT_PROGRESS, - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, - VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, - VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: ( + PipelineEventType.INTENT_START + ), + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: ( + PipelineEventType.INTENT_PROGRESS + ), + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: ( + PipelineEventType.INTENT_END + ), + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: ( + PipelineEventType.TTS_START + ), + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: (PipelineEventType.TTS_END), + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: ( + PipelineEventType.WAKE_WORD_START + ), + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: ( + PipelineEventType.WAKE_WORD_END + ), + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: ( + PipelineEventType.STT_VAD_START + ), + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: ( + PipelineEventType.STT_VAD_END + ), } ) _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = ( EsphomeEnumMapper( { - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: ( + TimerEventType.STARTED + ), + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: ( + TimerEventType.UPDATED + ), + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: ( + TimerEventType.CANCELLED + ), + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: ( + TimerEventType.FINISHED + ), } ) ) @@ -148,6 +170,8 @@ class EsphomeAssistSatellite( ) self._active_pipeline_index = 0 + self._active_audio_channel = 0 + self._has_multi_channel_audio = False def _get_entity_id(self, suffix: str) -> str | None: """Return the entity id for pipeline select, etc.""" @@ -163,7 +187,7 @@ class EsphomeAssistSatellite( @property def pipeline_entity_id(self) -> str | None: - """Return the entity ID of the primary pipeline to use for the next conversation.""" + """Return the entity ID of the pipeline to use for the next conversation.""" return self.get_pipeline_entity(self._active_pipeline_index) def get_pipeline_entity(self, index: int) -> str | None: @@ -178,7 +202,7 @@ class EsphomeAssistSatellite( @property def vad_sensitivity_entity_id(self) -> str | None: - """Return the entity ID of the VAD sensitivity to use for the next conversation.""" + """Return the entity ID of the VAD sensitivity for the next conversation.""" return self._get_entity_id("vad_sensitivity") @callback @@ -293,6 +317,9 @@ class EsphomeAssistSatellite( assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) + if feature_flags & VoiceAssistantFeature.MULTI_CHANNEL_AUDIO: + self._has_multi_channel_audio = True + # Update wake word select when config is updated self.async_on_remove( self._entry_data.async_register_assist_satellite_set_wake_words_callback( @@ -317,6 +344,18 @@ class EsphomeAssistSatellite( data_to_send: dict[str, Any] = {} if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: + if ( + self._has_multi_channel_audio + and event.data + and (audio_processing := event.data.get("audio_processing")) + ): + # Settings come from stt SpeechAudioProcessing + if (audio_processing.get("prefers_auto_gain_enabled") is False) and ( + audio_processing.get("prefers_noise_reduction_enabled") is False + ): + # Use non-enhanced audio + self._active_audio_channel = 1 + self._entry_data.async_set_assist_pipeline_state(True) elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None @@ -535,6 +574,10 @@ class EsphomeAssistSatellite( # Try next wake word select maybe_pipeline_index += 1 + # Default to audio channel 0 (enhanced) + # May be changed when STT_START event arrives. + self._active_audio_channel = 0 + _LOGGER.debug( "Running pipeline %s from %s to %s", self._active_pipeline_index + 1, @@ -557,9 +600,20 @@ class EsphomeAssistSatellite( return port - async def handle_audio(self, data: bytes) -> None: + async def handle_audio(self, data: bytes, data2: bytes | None = None) -> None: """Handle incoming audio chunk from API.""" - self._audio_queue.put_nowait(data) + # Default to enhanced audio (channel 0) + active_data = data + + if ( + self._has_multi_channel_audio + and (data2 is not None) + and (self._active_audio_channel == 1) + ): + # Non-enhanced audio (channel 1) + active_data = data2 + + self._audio_queue.put_nowait(active_data) async def handle_pipeline_stop(self, abort: bool) -> None: """Handle request for pipeline to stop.""" @@ -710,7 +764,7 @@ class EsphomeAssistSatellite( yield chunk def _stop_pipeline(self) -> None: - """Request pipeline to be stopped by ending the audio stream and continue processing.""" + """Request pipeline to be stopped by ending the audio stream.""" self._audio_queue.put_nowait(None) _LOGGER.debug("Requested pipeline stop") diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index deccb6cc7da..4c78a4e8fa3 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,7 +1,5 @@ """Support for ESPHome binary sensors.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 27abb19909f..c550c6e597f 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -1,18 +1,33 @@ """Bluetooth support for esphome.""" -from __future__ import annotations - from functools import partial +import logging from typing import TYPE_CHECKING -from aioesphomeapi import APIClient, DeviceInfo +from aioesphomeapi import ( + APIClient, + APIVersion, + BluetoothProxyFeature, + BluetoothScannerMode, + BluetoothScannerStateResponse, + DeviceInfo, +) from bleak_esphome import connect_scanner -from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + async_register_scanner, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from .const import DOMAIN -from .entry_data import RuntimeEntryData +from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData + +if TYPE_CHECKING: + from bleak_esphome.backend.scanner import ESPHomeScanner + +_LOGGER = logging.getLogger(__name__) +_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode} @hass_callback @@ -25,6 +40,7 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: @hass_callback def async_connect_scanner( hass: HomeAssistant, + entry: ESPHomeConfigEntry, entry_data: RuntimeEntryData, cli: APIClient, device_info: DeviceInfo, @@ -37,17 +53,75 @@ def async_connect_scanner( scanner = client_data.scanner if TYPE_CHECKING: assert scanner is not None - return partial( - _async_unload, - [ - async_register_scanner( - hass, - scanner, - source_domain=DOMAIN, - source_model=device_info.model, - source_config_entry_id=entry_data.entry_id, - source_device_id=device_id, - ), - scanner.async_setup(), - ], - ) + api_version = cli.api_version or APIVersion() + feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version) + state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE) + # Pin mode before async_register_scanner so habluetooth spawns the AUTO worker. + deferred_migration: CALLBACK_TYPE | None = None + if state_and_mode: + deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli) + callbacks: list[CALLBACK_TYPE] = [ + async_register_scanner( + hass, + scanner, + source_domain=DOMAIN, + source_model=device_info.model, + source_config_entry_id=entry_data.entry_id, + source_device_id=device_id, + ), + scanner.async_setup(), + ] + if deferred_migration is not None: + callbacks.append(deferred_migration) + return partial(_async_unload, callbacks) + + +@hass_callback +def _async_apply_scanning_mode( + hass: HomeAssistant, + entry: ESPHomeConfigEntry, + scanner: ESPHomeScanner, + cli: APIClient, +) -> CALLBACK_TYPE | None: + """Apply saved scanning mode synchronously; migrate from configured_mode later.""" + saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE) + if saved is not None and saved not in _VALID_SCANNING_MODES: + _LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved) + saved = None + initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE + scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value)) + if saved is not None: + return None + + unsub_holder: list[CALLBACK_TYPE] = [] + + @hass_callback + def _migrate(state: BluetoothScannerStateResponse) -> None: + # proto3 unset enums decode to None; wait for a real value. + if (configured_pb := state.configured_mode) is None: + return + if unsub_holder: + unsub_holder.pop()() + if configured_pb is BluetoothScannerMode.PASSIVE: + new_mode = BluetoothScanningMode.PASSIVE + else: + new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE) + hass.config_entries.async_update_entry( + entry, + options={ + **entry.options, + CONF_BLUETOOTH_SCANNING_MODE: new_mode.value, + }, + ) + # AUTO -> AUTO is already pinned; only re-apply on a downgrade. + if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE): + scanner.async_set_scanning_mode(new_mode) + + unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate)) + + @hass_callback + def _unsubscribe() -> None: + if unsub_holder: + unsub_holder.pop()() + + return _unsubscribe diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 795a4bc4ed8..678d38af605 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -1,7 +1,5 @@ """Support for ESPHome buttons.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import ButtonInfo, EntityInfo, EntityState diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index e2213153092..7f382efa703 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -1,7 +1,5 @@ """Support for ESPHome cameras.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from functools import partial diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 1de8db61f4b..353481348b4 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -1,7 +1,5 @@ """Support for ESPHome climate devices.""" -from __future__ import annotations - from functools import partial from math import isfinite from typing import Any, cast diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index fd6803db85c..356622c110d 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure esphome component.""" -from __future__ import annotations - from collections import OrderedDict from collections.abc import Mapping import json @@ -11,6 +9,7 @@ from typing import Any, cast from aioesphomeapi import ( APIClient, APIConnectionError, + BluetoothProxyFeature, DeviceInfo, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -22,6 +21,7 @@ import aiohttp import voluptuous as vol from homeassistant.components import zeroconf +from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.config_entries import ( SOURCE_ESPHOME, SOURCE_IGNORE, @@ -40,6 +40,11 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.helpers import discovery_flow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.importlib import async_import_module +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -49,10 +54,12 @@ from homeassistant.util.json import json_loads_object from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_SCANNING_MODE, CONF_DEVICE_NAME, CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, + DEFAULT_BLUETOOTH_SCANNING_MODE, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DEFAULT_PORT, DOMAIN, @@ -70,6 +77,18 @@ _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" DEFAULT_NAME = "ESPHome" +_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[ + BluetoothScanningMode.AUTO.value, + BluetoothScanningMode.ACTIVE.value, + BluetoothScanningMode.PASSIVE.value, + ], + translation_key="bluetooth_scanning_mode", + mode=SelectSelectorMode.DROPDOWN, + ) +) + class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a esphome config flow.""" @@ -158,7 +177,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): expected_mac = format_mac(self._reauth_entry.unique_id) actual_mac = format_mac(self._device_mac) if expected_mac != actual_mac: - # Different device at the same IP - do not offer to remove encryption + # Different device at the same IP - + # do not offer to remove encryption return self._async_abort_wrong_device( self._reauth_entry, expected_mac, actual_mac ) @@ -332,7 +352,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ble_mac = wifi_mac_to_bluetooth_mac(mac_address) improv_ble.async_register_next_flow(self.hass, ble_mac, self.flow_id) _LOGGER.debug( - "Notified Improv BLE of flow %s for BLE MAC %s (derived from WiFi MAC %s)", + "Notified Improv BLE of flow %s for BLE MAC %s" + " (derived from WiFi MAC %s)", self.flow_id, ble_mac, mac_address, @@ -533,7 +554,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): unique_id=self.unique_id, data=self._async_make_config_data(), options={ - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_ALLOW_SERVICE_CALLS: ( + DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + ), }, ) await self.hass.config_entries.async_remove( @@ -934,18 +957,44 @@ class OptionsFlowHandler(OptionsFlowWithReload): if user_input is not None: return self.async_create_entry(title="", data=user_input) - data_schema = vol.Schema( - { + options = self.config_entry.options + schema: dict[Any, Any] = { + vol.Required( + CONF_ALLOW_SERVICE_CALLS, + default=options.get( + CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS + ), + ): bool, + vol.Required( + CONF_SUBSCRIBE_LOGS, + default=options.get(CONF_SUBSCRIBE_LOGS, False), + ): bool, + } + if _entry_has_bluetooth_scanner(self.config_entry): + schema[ vol.Required( - CONF_ALLOW_SERVICE_CALLS, - default=self.config_entry.options.get( - CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS + CONF_BLUETOOTH_SCANNING_MODE, + default=options.get( + CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE ), - ): bool, - vol.Required( - CONF_SUBSCRIBE_LOGS, - default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False), - ): bool, - } + ) + ] = _BLUETOOTH_SCANNING_MODE_SELECTOR + return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) + + +@callback +def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool: + """Return True if the entry exposes a bluetooth proxy scanner or has one saved.""" + # Keep showing the option if it was previously saved, even when the + # device is offline or stops advertising the feature flag, so the + # saved value isn't silently dropped on the next options save. + if CONF_BLUETOOTH_SCANNING_MODE in entry.options: + return True + if entry.state is ConfigEntryState.LOADED and ( + device_info := entry.runtime_data.device_info + ): + flags = device_info.bluetooth_proxy_feature_flags_compat( + entry.runtime_data.api_version ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE) + return False diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 32ba27ded8e..b10995ac27c 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,23 +1,33 @@ """ESPHome constants.""" -from typing import Final +from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .domain_data import DomainData + DOMAIN = "esphome" +ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) + CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" +CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False +DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value DEFAULT_PORT: Final = 6053 -STABLE_BLE_VERSION_STR = "2025.11.0" +STABLE_BLE_VERSION_STR = "2026.5.1" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index 99ae6d38a9d..0a41d541521 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -1,7 +1,5 @@ """Coordinator to interact with an ESPHome dashboard.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index f9ff944809a..215f2d15907 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -1,7 +1,5 @@ """Support for ESPHome covers.""" -from __future__ import annotations - from functools import partial from typing import Any diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index a12af89aca2..b618e012d98 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -1,7 +1,5 @@ """Files to interact with an ESPHome dashboard.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -65,9 +63,14 @@ class ESPHomeDashboardManager: if is_hassio(self._hass): from homeassistant.components.hassio import get_addons_info # noqa: PLC0415 - if (addons := get_addons_info(self._hass)) is not None and info[ - "addon_slug" - ] not in addons: + # This may raise HassioNotReadyError if Supervisor was unreachable + # during setup of the Supervisor integration. That will fail setup + # of this integration. However there is no better option at this time + # since we need to know if the addon is installed from Supervisor to + # correctly setup this integration and we can't raise ConfigEntryNotReady + # to trigger a retry from async_setup. + addons = get_addons_info(self._hass) + if info["addon_slug"] not in addons: # The addon is not installed anymore, but it make come back # so we don't want to remove the dashboard, but for now # we don't want to use it. @@ -121,7 +124,8 @@ class ESPHomeDashboardManager: hass.config_entries.async_reload(entry.entry_id) for entry in hass.config_entries.async_loaded_entries(DOMAIN) ] - # Re-auth flows will check the dashboard for encryption key when the form is requested + # Re-auth flows will check the dashboard for encryption + # key when the form is requested # but we only trigger reauth if the dashboard is available. if dashboard.last_update_success: reauths = [ diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index fc125067553..1208f4d62db 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -1,7 +1,5 @@ """Support for esphome dates.""" -from __future__ import annotations - from datetime import date from functools import partial diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 46c5c2da2d8..aba40f25b0a 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -1,7 +1,5 @@ """Support for esphome datetimes.""" -from __future__ import annotations - from datetime import datetime from functools import partial diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index c59fca26b90..7ec405d8705 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for ESPHome.""" -from __future__ import annotations - from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 2a323d47a06..6da4efed15b 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -1,15 +1,12 @@ """Support for esphome domain data.""" -from __future__ import annotations - from dataclasses import dataclass, field from functools import cache -from typing import Self from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from .const import DOMAIN +from .const import ESPHOME_DATA from .entry_data import ESPHomeConfigEntry, ESPHomeStorage, RuntimeEntryData STORAGE_VERSION = 1 @@ -36,9 +33,9 @@ class DomainData: ), ) - @classmethod + @staticmethod @cache - def get(cls, hass: HomeAssistant) -> Self: + def get(hass: HomeAssistant) -> DomainData: """Get the global DomainData instance stored in hass.data.""" - ret = hass.data[DOMAIN] = cls() + ret = hass.data[ESPHOME_DATA] = DomainData() return ret diff --git a/homeassistant/components/esphome/encryption_key_storage.py b/homeassistant/components/esphome/encryption_key_storage.py index e4b5ef41c2e..04071bc6c1a 100644 --- a/homeassistant/components/esphome/encryption_key_storage.py +++ b/homeassistant/components/esphome/encryption_key_storage.py @@ -1,7 +1,5 @@ """Encryption key storage for ESPHome devices.""" -from __future__ import annotations - import logging from typing import TypedDict diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index d37fda3396e..20d44aa7cbf 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -1,7 +1,5 @@ """Support for esphome entities.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine import functools import logging @@ -55,7 +53,7 @@ def async_static_info_updated( platform: entity_platform.EntityPlatform, async_add_entities: AddEntitiesCallback, info_type: type[_InfoT], - entity_type: type[_EntityT], + entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT], state_type: type[_StateT], infos: list[EntityInfo], ) -> None: @@ -98,7 +96,8 @@ def async_static_info_updated( if old_info.device_id == info.device_id: continue - # Entity has switched devices, need to migrate unique_id and handle state subscriptions + # Entity has switched devices, need to migrate unique_id + # and handle state subscriptions old_unique_id = build_device_unique_id(device_info.mac_address, old_info) entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id) @@ -142,9 +141,11 @@ def async_static_info_updated( if updates: ent_reg.async_update_entity(entity_id, **updates) - # IMPORTANT: The entity's device assignment in Home Assistant is only read when the entity - # is first added. Updating the registry alone won't move the entity to the new device - # in the UI. Additionally, the entity's state subscription is tied to the old device_id, + # IMPORTANT: The entity's device assignment in Home + # Assistant is only read when the entity is first added. + # Updating the registry alone won't move the entity to + # the new device in the UI. Additionally, the entity's + # state subscription is tied to the old device_id, # so it won't receive state updates for the new device_id. # # We must remove the old entity and re-add it to ensure: @@ -187,7 +188,7 @@ async def platform_async_setup_entry( async_add_entities: AddEntitiesCallback, *, info_type: type[_InfoT], - entity_type: type[_EntityT], + entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT], state_type: type[_StateT], info_filter: Callable[[_InfoT], bool] | None = None, ) -> None: @@ -195,6 +196,11 @@ async def platform_async_setup_entry( This method is in charge of receiving, distributing and storing info and state updates. + + `entity_type` is any callable that builds an entity from + `(entry_data, info, state_type)`. A regular entity class satisfies this, + and platforms with multiple entity classes can pass a factory function + that picks the class per static info. """ entry_data = entry.runtime_data entry_data.info[info_type] = {} @@ -331,7 +337,7 @@ class EsphomeBaseEntity(Entity): device_entry: dr.DeviceEntry -class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): +class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # noqa: UP046 """Define an esphome entity.""" _static_info: _InfoT @@ -355,7 +361,8 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._state_type = state_type self._on_static_info_update(entity_info) - # Determine the device connection based on whether this entity belongs to a sub device + # Determine the device connection based on whether this + # entity belongs to a sub device if entity_info.device_id: # Entity belongs to a sub device self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294..8d3e2ff1547 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -1,7 +1,5 @@ """Runtime entry data for ESPHome stored in hass.data.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable, Iterable @@ -35,6 +33,7 @@ from aioesphomeapi import ( MediaPlayerInfo, MediaPlayerSupportedFormat, NumberInfo, + RadioFrequencyInfo, SelectInfo, SensorInfo, SensorState, @@ -88,6 +87,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { FanInfo: Platform.FAN, InfraredInfo: Platform.INFRARED, LightInfo: Platform.LIGHT, + RadioFrequencyInfo: Platform.RADIO_FREQUENCY, LockInfo: Platform.LOCK, MediaPlayerInfo: Platform.MEDIA_PLAYER, NumberInfo: Platform.NUMBER, @@ -212,7 +212,7 @@ class RuntimeEntryData: entity_info_type: type[EntityInfo], callback_: Callable[[list[EntityInfo]], None], ) -> CALLBACK_TYPE: - """Register to receive callbacks when static info changes for an EntityInfo type.""" + """Register to receive callbacks when static info changes.""" callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks.append(callback_) return partial(callbacks.remove, callback_) @@ -223,7 +223,7 @@ class RuntimeEntryData: static_info: EntityInfo, callback_: Callable[[EntityInfo], None], ) -> CALLBACK_TYPE: - """Register to receive callbacks when static info is updated for a specific key.""" + """Register callbacks when static info is updated for a specific key.""" callback_key = (type(static_info), static_info.device_id, static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) @@ -534,7 +534,7 @@ class RuntimeEntryData: self, callback_: Callable[[AssistSatelliteConfiguration], None], ) -> CALLBACK_TYPE: - """Register to receive callbacks when the Assist satellite's configuration is updated.""" + """Register callbacks when the Assist satellite's configuration is updated.""" self.assist_satellite_config_update_callbacks.append(callback_) return partial(self.assist_satellite_config_update_callbacks.remove, callback_) @@ -551,7 +551,7 @@ class RuntimeEntryData: self, callback_: Callable[[list[str]], None], ) -> CALLBACK_TYPE: - """Register to receive callbacks when the Assist satellite's wake word is set.""" + """Register callbacks when the Assist satellite's wake word is set.""" self.assist_satellite_set_wake_words_callbacks.append(callback_) return partial(self.assist_satellite_set_wake_words_callbacks.remove, callback_) diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py index 4437292c5b4..2c9ceb33aa5 100644 --- a/homeassistant/components/esphome/event.py +++ b/homeassistant/components/esphome/event.py @@ -1,7 +1,5 @@ """Support for ESPHome event components.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import EntityInfo, Event, EventInfo diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 882cf3606e2..b1b314ae680 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -1,7 +1,5 @@ """Support for ESPHome fans.""" -from __future__ import annotations - from functools import partial import math from typing import Any diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py index 580831f4aec..6221058752d 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -1,28 +1,34 @@ """Infrared platform for ESPHome.""" -from __future__ import annotations - -from functools import partial +import functools import logging +from typing import TYPE_CHECKING -from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo +from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo +from aioesphomeapi.client import InfraredRFReceiveEventModel -from homeassistant.components.infrared import InfraredCommand, InfraredEntity -from homeassistant.core import callback +from homeassistant.components.infrared import ( + InfraredCommand, + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) +from homeassistant.core import CALLBACK_TYPE, callback from .entity import ( EsphomeEntity, convert_api_error_ha_error, platform_async_setup_entry, ) +from .entry_data import RuntimeEntryData _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity): - """ESPHome infrared entity using native API.""" +class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]): + """Common base for ESPHome infrared entities.""" @callback def _on_device_update(self) -> None: @@ -32,14 +38,14 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn # Infrared entities should go available as soon as the device comes online self.async_write_ha_state() + +class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity): + """ESPHome infrared emitter entity using native API.""" + @convert_api_error_ha_error async def async_send_command(self, command: InfraredCommand) -> None: """Send an IR command.""" - timings = [ - interval - for timing in command.get_raw_timings() - for interval in (timing.high_us, -timing.low_us) - ] + timings = command.get_raw_timings() _LOGGER.debug("Sending command: %s", timings) self._client.infrared_rf_transmit_raw_timings( @@ -50,10 +56,77 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn ) -async_setup_entry = partial( +class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity): + """ESPHome infrared receiver entity using native API.""" + + _unsub_receive: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Register callbacks including IR receive subscription.""" + await super().async_added_to_hass() + self._async_subscribe_receive() + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from the device on entity removal.""" + await super().async_will_remove_from_hass() + if self._unsub_receive is not None: + self._unsub_receive() + self._unsub_receive = None + + @callback + def _async_subscribe_receive(self) -> None: + """Subscribe to IR receive events if the device is connected.""" + # Subscribing requires an active API connection; defer to + # _on_device_update when the device is not (yet) available. + if self._unsub_receive is not None or not self._entry_data.available: + return + self._unsub_receive = self._client.subscribe_infrared_rf_receive( + self._on_infrared_rf_receive + ) + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + self._async_subscribe_receive() + elif self._unsub_receive is not None: + self._unsub_receive = None + + @callback + def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None: + """Handle a received IR signal from the device.""" + if ( + event.key != self._static_info.key + or event.device_id != self._static_info.device_id + ): + return + self._handle_received_signal(InfraredReceivedSignal(timings=event.timings)) + + +def _make_infrared_entity( + entry_data: RuntimeEntryData, + info: EntityInfo, + state_type: type[EntityState], +) -> _EsphomeInfraredEntity: + """Build the right infrared entity based on the InfraredInfo capabilities.""" + if TYPE_CHECKING: + assert isinstance(info, InfraredInfo) + cls = ( + EsphomeInfraredReceiverEntity + if info.capabilities & InfraredCapability.RECEIVER + else EsphomeInfraredEmitterEntity + ) + return cls(entry_data, info, state_type) + + +async_setup_entry = functools.partial( platform_async_setup_entry, info_type=InfraredInfo, - entity_type=EsphomeInfraredEntity, + entity_type=_make_infrared_entity, state_type=EntityState, - info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER), + info_filter=lambda info: bool( + info.capabilities + & (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER) + ), ) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 8fc52d2477d..19efe3153e6 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,7 +1,5 @@ """Support for ESPHome lights.""" -from __future__ import annotations - from functools import lru_cache, partial from operator import methodcaller from typing import TYPE_CHECKING, Any, cast @@ -259,15 +257,18 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: # Do not use kelvin_to_mired here to prevent precision loss color_temp_mired = 1_000_000.0 / color_temp_k + data["color_temperature"] = color_temp_mired if color_temp_modes := _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE ): - data["color_temperature"] = color_temp_mired color_modes = color_temp_modes else: - # Convert color temperature to explicit cold/warm white - # values to avoid ESPHome applying brightness to both - # master brightness and white channels (b² effect). + # Also send explicit cold/warm white values to avoid + # ESPHome applying brightness to both master brightness + # and white channels (b² effect). The firmware skips + # deriving cwww from color_temperature when the channels + # are already set explicitly, but still stores + # color_temperature so HA can read it back. data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm( color_temp_mired ) diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 958dcde9f30..f0cd03bfa09 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -1,7 +1,5 @@ """Support for ESPHome locks.""" -from __future__ import annotations - from functools import partial from typing import Any diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 87b7ec3361e..2a6d3847172 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1,7 +1,5 @@ """Manager for esphome devices.""" -from __future__ import annotations - import base64 from functools import partial import logging @@ -344,7 +342,7 @@ class ESPHomeManager: call_id: int, response_template: str | None = None, ) -> None: - """Handle service call that expects a response and send response back to ESPHome.""" + """Handle service call with response and send it back to ESPHome.""" try: # Call the service with response capture enabled action_response = await self.hass.services.async_call( @@ -366,6 +364,7 @@ class ESPHomeManager: response_dict = {"response": response} except TemplateError as ex: + # pylint: disable-next=home-assistant-exception-not-translated raise HomeAssistantError( f"Error rendering response template: {ex}" ) from ex @@ -670,7 +669,7 @@ class ESPHomeManager: if device_info.bluetooth_proxy_feature_flags_compat(api_version): entry_data.disconnect_callbacks.add( async_connect_scanner( - hass, entry_data, cli, device_info, self.device_id + hass, self.entry, entry_data, cli, device_info, self.device_id ) ) else: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f642dfb5694..4cfa1cf5796 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,7 +1,7 @@ { "domain": "esphome", "name": "ESPHome", - "after_dependencies": ["hassio", "zeroconf", "tag"], + "after_dependencies": ["hassio", "tag", "usb", "zeroconf"], "codeowners": ["@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], @@ -17,9 +17,9 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==44.6.2", + "aioesphomeapi==45.3.1", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.7.1" + "bleak-esphome==3.9.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index a35d93c9fe1..89fe8890b86 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -1,7 +1,5 @@ """Support for ESPHome media players.""" -from __future__ import annotations - from functools import partial import logging from typing import Any, cast @@ -72,7 +70,9 @@ _FEATURES = { EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST, EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY, EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET, - EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: MediaPlayerEntityFeature.SELECT_SOUND_MODE, + EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ), EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA, EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET, EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING, diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 59788eb6e1f..60d3a7817c8 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -1,7 +1,5 @@ """Support for esphome numbers.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import ( diff --git a/homeassistant/components/esphome/radio_frequency.py b/homeassistant/components/esphome/radio_frequency.py new file mode 100644 index 00000000000..89b8473a97d --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -0,0 +1,77 @@ +"""Radio Frequency platform for ESPHome.""" + +from functools import partial +import logging + +from aioesphomeapi import ( + EntityState, + RadioFrequencyCapability, + RadioFrequencyInfo, + RadioFrequencyModulation, +) +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.core import callback + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + platform_async_setup_entry, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = { + ModulationType.OOK: RadioFrequencyModulation.OOK, +} + + +class EsphomeRadioFrequencyEntity( + EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity +): + """ESPHome radio frequency entity using native API.""" + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges from device info.""" + return [(self._static_info.frequency_min, self._static_info.frequency_max)] + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + self.async_write_ha_state() + + @convert_api_error_ha_error + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command.""" + timings = command.get_raw_timings() + _LOGGER.debug("Sending RF command: %s", timings) + + self._client.radio_frequency_transmit_raw_timings( + self._static_info.key, + frequency=command.frequency, + timings=timings, + modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation], + # In ESPHome, repeat_count is total number of + # times to send the command, while in rf_protocols + # it's the number of additional times to send it, + # so we need to add 1 here. + repeat_count=command.repeat_count + 1, + device_id=self._static_info.device_id, + ) + + +async_setup_entry = partial( + platform_async_setup_entry, + info_type=RadioFrequencyInfo, + entity_type=EsphomeRadioFrequencyEntity, + state_type=EntityState, + info_filter=lambda info: bool( + info.capabilities & RadioFrequencyCapability.TRANSMITTER + ), +) diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py index d40a68dde1a..753eda3ad3b 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -1,13 +1,10 @@ """Repairs implementation for the esphome integration.""" -from __future__ import annotations - from typing import cast import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant from .manager import async_replace_device @@ -45,7 +42,7 @@ class DeviceConflictRepair(ESPHomeRepair): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( step_id="init", @@ -54,7 +51,7 @@ class DeviceConflictRepair(ESPHomeRepair): async def async_step_migrate( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the migrate step of a fix flow.""" if user_input is None: return self.async_show_form( @@ -68,7 +65,7 @@ class DeviceConflictRepair(ESPHomeRepair): async def async_step_manual( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the manual step of a fix flow.""" if user_input is None: return self.async_show_form( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index df5a923c8b3..9ba482349c7 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -1,7 +1,5 @@ """Support for esphome selects.""" -from __future__ import annotations - from dataclasses import replace from aioesphomeapi import EntityInfo, SelectInfo, SelectState diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index ded2e280c01..2925ef0060c 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,7 +1,5 @@ """Support for esphome sensors.""" -from __future__ import annotations - from datetime import date, datetime import math @@ -63,7 +61,9 @@ _STATE_CLASSES: EsphomeEnumMapper[EsphomeSensorStateClass, SensorStateClass | No EsphomeSensorStateClass.MEASUREMENT: SensorStateClass.MEASUREMENT, EsphomeSensorStateClass.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, EsphomeSensorStateClass.TOTAL: SensorStateClass.TOTAL, - EsphomeSensorStateClass.MEASUREMENT_ANGLE: SensorStateClass.MEASUREMENT_ANGLE, + EsphomeSensorStateClass.MEASUREMENT_ANGLE: ( + SensorStateClass.MEASUREMENT_ANGLE + ), } ) ) diff --git a/homeassistant/components/esphome/serial_proxy.py b/homeassistant/components/esphome/serial_proxy.py new file mode 100644 index 00000000000..019ca405227 --- /dev/null +++ b/homeassistant/components/esphome/serial_proxy.py @@ -0,0 +1,119 @@ +"""Home Assistant-aware ESPHome serial proxy URI handler for serialx.""" + +import asyncio +from collections.abc import Callable +from typing import cast + +from aioesphomeapi import APIClient +from serialx import register_uri_handler +from serialx.platforms.serial_esphome import ( + ESPHomeSerial, + ESPHomeSerialTransport, + InvalidSettingsError, +) +from yarl import URL + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import Event, HomeAssistant, async_get_hass, callback + +from .const import DOMAIN +from .entry_data import ESPHomeConfigEntry + +# This is required so that serialx can safely query Core for an instance of an +# aioesphomeapi client. We cannot make any assumptions here, some packages run separate +# asyncio event loops in dedicated threads. +_HASS_LOOP: asyncio.AbstractEventLoop | None = None + + +def build_url(entry_id: str, port_name: str) -> URL: + """Build a canonical `esphome-hass://` URL.""" + return URL.build( + scheme="esphome-hass", + host="esphome", + path=f"/{entry_id}", + query={"port_name": port_name}, + ) + + +async def _resolve_client(entry_id: str) -> APIClient: + """Look up the `APIClient` for a specific config entry.""" + + # This function is async specifically so that we can get a reference to the Home + # Assistant Core instance from its own thread + hass: HomeAssistant = async_get_hass() + entry = cast(ESPHomeConfigEntry, hass.config_entries.async_get_entry(entry_id)) + + if entry is None or entry.domain != DOMAIN: + raise InvalidSettingsError(f"No ESPHome config entry with id {entry_id!r}") + + if entry.state is not ConfigEntryState.LOADED: + raise InvalidSettingsError(f"ESPHome config entry {entry_id!r} is not loaded") + + return entry.runtime_data.client + + +class HassESPHomeSerial(ESPHomeSerial): + """ESPHomeSerial that resolves an HA config entry's APIClient from the URL.""" + + _api: APIClient | None + _path: str | None + + async def _async_open(self) -> None: + """Resolve the HA config entry's APIClient, then open the proxy.""" + if self._api is None and self._path is not None: + parsed = URL(str(self._path)) + + entry_id = parsed.path.lstrip("/") + if not entry_id: + raise InvalidSettingsError( + f"No ESPHome config entry id in URL {self._path!r}" + ) + + if "port_name" not in parsed.query: + raise InvalidSettingsError("Port name is required") + + self._port_name = parsed.query["port_name"] + + hass_loop = _HASS_LOOP + if hass_loop is None: + raise InvalidSettingsError( + "ESPHome integration has not registered its event loop" + ) + + # Fetch the `APIClient` from the Core via the appropriate event loop + self._api = await asyncio.wrap_future( + asyncio.run_coroutine_threadsafe(_resolve_client(entry_id), hass_loop) + ) + self._client_loop = self._api._loop # noqa: SLF001 + + await super()._async_open() + + +class HassESPHomeSerialTransport(ESPHomeSerialTransport): + """Transport variant that constructs :class:`HassESPHomeSerial`.""" + + transport_name = "esphome-hass" + _serial_cls = HassESPHomeSerial + + +def register_serialx_transport( + loop: asyncio.AbstractEventLoop, +) -> Callable[[Event], None]: + """Register the ESPHome URI handler.""" + global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement + _HASS_LOOP = loop + + unregister = register_uri_handler( + scheme="esphome-hass://", + unique_scheme="esphome-hass-internal://", # The unique scheme must differ + sync_cls=HassESPHomeSerial, + async_transport_cls=HassESPHomeSerialTransport, + ) + + @callback + def _unregister(event: Event) -> None: + global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement + unregister() + _HASS_LOOP = None + + return _unregister diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 2ef93c2a820..0d0c6f6dbb4 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -209,13 +209,24 @@ "init": { "data": { "allow_service_calls": "Allow the device to perform Home Assistant actions.", + "bluetooth_scanning_mode": "Bluetooth scanning mode", "subscribe_logs": "Subscribe to logs from the device." }, "data_description": { "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.", + "bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.", "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } + }, + "selector": { + "bluetooth_scanning_mode": { + "options": { + "active": "Active (uses more device battery, fastest updates)", + "auto": "Auto (recommended, saves device battery)", + "passive": "Passive (lowest device battery use, some details may be missing)" + } + } } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 7e5223ae548..ee932ab428e 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -1,7 +1,5 @@ """Support for ESPHome switches.""" -from __future__ import annotations - from functools import partial from typing import Any diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index 5ffc07ce08d..d743209b05d 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -1,7 +1,5 @@ """Support for esphome texts.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index a416bb17a31..a39e22d2392 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -1,7 +1,5 @@ """Support for esphome times.""" -from __future__ import annotations - from datetime import time from functools import partial diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index a6d053e1c4c..f5cf7951970 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -1,7 +1,5 @@ """Update platform for ESPHome.""" -from __future__ import annotations - import asyncio from typing import Any @@ -75,7 +73,8 @@ async def async_setup_entry( if not entry_data.available or not dashboard.last_update_success: return - # Do not add Dashboard Entity if this device is not known to the ESPHome dashboard. + # Do not add Dashboard Entity if this device is not + # known to the ESPHome dashboard. if dashboard.data is None or dashboard.data.get(device_name) is None: return @@ -285,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): UpdateDeviceClass, static_info.device_class ) + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version. + + ESPHome project versions can carry a build suffix (e.g. + 2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping + it the base comparison raises and the entity is forced on for every + build mismatch. Drop the suffix so the versions compare cleanly and we + only report genuinely newer firmware. + """ + return super().version_is_newer( + latest_version.partition("_")[0], installed_version.partition("_")[0] + ) + @property @esphome_state_property def installed_version(self) -> str: diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index 0fe9151a5a6..54759836842 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -1,7 +1,5 @@ """Support for ESPHome valves.""" -from __future__ import annotations - from functools import partial from typing import Any diff --git a/homeassistant/components/esphome/water_heater.py b/homeassistant/components/esphome/water_heater.py index 2f80d018150..d98da22cd6e 100644 --- a/homeassistant/components/esphome/water_heater.py +++ b/homeassistant/components/esphome/water_heater.py @@ -1,7 +1,5 @@ """Support for ESPHome water heaters.""" -from __future__ import annotations - from functools import partial from typing import Any @@ -11,6 +9,7 @@ from aioesphomeapi import ( WaterHeaterInfo, WaterHeaterMode, WaterHeaterState, + WaterHeaterStateFlag, ) from homeassistant.components.water_heater import ( @@ -72,6 +71,8 @@ class EsphomeWaterHeater( self._attr_operation_list = None if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF: features |= WaterHeaterEntityFeature.ON_OFF + if static_info.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE: + features |= WaterHeaterEntityFeature.AWAY_MODE self._attr_supported_features = features @property @@ -92,6 +93,12 @@ class EsphomeWaterHeater( """Return current operation mode.""" return _WATER_HEATER_MODES.from_esphome(self._state.mode) + @property + @esphome_state_property + def is_away_mode_on(self) -> bool | None: + """Return true if away mode is on.""" + return bool(self._state.state & WaterHeaterStateFlag.AWAY) + @convert_api_error_ha_error async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -128,6 +135,24 @@ class EsphomeWaterHeater( device_id=self._static_info.device_id, ) + @convert_api_error_ha_error + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + self._client.water_heater_command( + key=self._key, + away=True, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + self._client.water_heater_command( + key=self._key, + away=False, + device_id=self._static_info.device_id, + ) + async_setup_entry = partial( platform_async_setup_entry, diff --git a/homeassistant/components/essent/__init__.py b/homeassistant/components/essent/__init__.py index 00da3d8cd23..b31178fedbb 100644 --- a/homeassistant/components/essent/__init__.py +++ b/homeassistant/components/essent/__init__.py @@ -1,7 +1,5 @@ """The Essent integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/essent/config_flow.py b/homeassistant/components/essent/config_flow.py index c07f4e3d354..f799219ac75 100644 --- a/homeassistant/components/essent/config_flow.py +++ b/homeassistant/components/essent/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Essent integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/essent/const.py b/homeassistant/components/essent/const.py index 4b505e21136..c1ccf13c41c 100644 --- a/homeassistant/components/essent/const.py +++ b/homeassistant/components/essent/const.py @@ -1,7 +1,5 @@ """Constants for the Essent integration.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum from typing import Final diff --git a/homeassistant/components/essent/coordinator.py b/homeassistant/components/essent/coordinator.py index 533e5b3b806..9476c6764e3 100644 --- a/homeassistant/components/essent/coordinator.py +++ b/homeassistant/components/essent/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Essent integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index 1ef3d5706e8..3ac770abbfb 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Essent integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 3e48307e8bf..52037251034 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -1,7 +1,5 @@ """Support for Etherscan sensors.""" -from __future__ import annotations - from datetime import timedelta from pyetherscan import get_balance diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 48ba97c01df..58c1adc426a 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -1,7 +1,5 @@ """Support for EufyHome lights.""" -from __future__ import annotations - from typing import Any import lakeside diff --git a/homeassistant/components/eufy/switch.py b/homeassistant/components/eufy/switch.py index 2f3e5931e61..b9ae6a0171a 100644 --- a/homeassistant/components/eufy/switch.py +++ b/homeassistant/components/eufy/switch.py @@ -1,7 +1,5 @@ """Support for EufyHome switches.""" -from __future__ import annotations - from typing import Any import lakeside diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index 8a58c50c8e4..336028e968f 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -1,7 +1,5 @@ """The EufyLife integration.""" -from __future__ import annotations - from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth diff --git a/homeassistant/components/eufylife_ble/config_flow.py b/homeassistant/components/eufylife_ble/config_flow.py index 767b544f853..d918df9433f 100644 --- a/homeassistant/components/eufylife_ble/config_flow.py +++ b/homeassistant/components/eufylife_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for the EufyLife integration.""" -from __future__ import annotations - from typing import Any from eufylife_ble_client import MODEL_TO_NAME import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -77,6 +76,7 @@ class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_MODEL: model}, ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/eufylife_ble/models.py b/homeassistant/components/eufylife_ble/models.py index 26154a74fac..0d71bba40c8 100644 --- a/homeassistant/components/eufylife_ble/models.py +++ b/homeassistant/components/eufylife_ble/models.py @@ -1,7 +1,5 @@ """Models for the EufyLife integration.""" -from __future__ import annotations - from dataclasses import dataclass from eufylife_ble_client import EufyLifeBLEDevice diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 7172ba59d5a..cc8c3c9d422 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -1,7 +1,5 @@ """Support for EufyLife sensors.""" -from __future__ import annotations - from typing import Any from eufylife_ble_client import MODEL_TO_NAME @@ -63,10 +61,12 @@ class EufyLifeSensorEntity(SensorEntity): def available(self) -> bool: """Determine if the entity is available.""" if self._data.client.advertisement_data_contains_state: - # If the device only uses advertisement data, just check if the address is present. + # If the device only uses advertisement data, + # just check if the address is present. return async_address_present(self.hass, self._data.address) - # If the device needs an active connection, availability is based on whether it is connected. + # If the device needs an active connection, + # availability is based on whether it is connected. return self._data.client.is_connected @callback diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py new file mode 100644 index 00000000000..1ef3d6ccf45 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -0,0 +1,81 @@ +"""Comet Blue Bluetooth integration.""" + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) -> bool: + """Set up Eurotronic Comet Blue from a config entry.""" + + address = entry.data[CONF_ADDRESS] + + ble_device = async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) + + if not ble_device: + raise ConfigEntryNotReady( + f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}" + ) + + cometblue_device = AsyncCometBlue( + device=ble_device, + pin=int(entry.data[CONF_PIN]), + ) + try: + async with cometblue_device: + ble_device_info = await cometblue_device.get_device_info_async() + try: + # Device only returns battery level if PIN is correct + await cometblue_device.get_battery_async() + except TimeoutError as ex: + # This likely means PIN was incorrect on Linux and ESPHome backends + raise ConfigEntryError( + "Failed to read battery level, likely due to incorrect PIN" + ) from ex + except BleakError as ex: + raise ConfigEntryNotReady( + f"Failed to get device info from '{cometblue_device.device.address}'" + ) from ex + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, address)}, + name=f"{ble_device_info['model']} {cometblue_device.device.address}", + manufacturer=ble_device_info["manufacturer"], + model=ble_device_info["model"], + sw_version=ble_device_info["version"], + ) + + coordinator = CometBlueDataUpdateCoordinator( + hass, + entry, + cometblue_device, + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eurotronic_cometblue/button.py b/homeassistant/components/eurotronic_cometblue/button.py new file mode 100644 index 00000000000..6ba8233d15a --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/button.py @@ -0,0 +1,59 @@ +"""Comet Blue button platform.""" + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 + +DESCRIPTIONS = [ + ButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + + async_add_entities( + [ + CometBlueButtonEntity(coordinator, description) + for description in DESCRIPTIONS + ] + ) + + +class CometBlueButtonEntity(CometBlueBluetoothEntity, ButtonEntity): + """Representation of a button.""" + + def __init__( + self, + coordinator: CometBlueDataUpdateCoordinator, + description: ButtonEntityDescription, + ) -> None: + """Initialize CometBlueButtonEntity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + if self.entity_description.key == "sync_time": + await self.coordinator.send_command( + self.coordinator.device.set_datetime_async, {"date": dt_util.now()} + ) diff --git a/homeassistant/components/eurotronic_cometblue/climate.py b/homeassistant/components/eurotronic_cometblue/climate.py new file mode 100644 index 00000000000..18d9576107d --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/climate.py @@ -0,0 +1,190 @@ +"""Comet Blue climate integration.""" + +from typing import Any + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 +MIN_TEMP = 7.5 +MAX_TEMP = 28.5 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + async_add_entities([CometBlueClimateEntity(coordinator)]) + + +class CometBlueClimateEntity(CometBlueBluetoothEntity, ClimateEntity): + """A Comet Blue Climate climate entity.""" + + _attr_min_temp = MIN_TEMP + _attr_max_temp = MAX_TEMP + _attr_name = None + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = [ + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_AWAY, + PRESET_NONE, + ] + _attr_supported_features: ClimateEntityFeature = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None: + """Initialize CometBlueClimateEntity.""" + + super().__init__(coordinator) + self._attr_unique_id = coordinator.address + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.coordinator.data.temperatures["currentTemp"] + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + return self.coordinator.data.temperatures["manualTemp"] + + @property + def _device_comfort_setpoint(self) -> float | None: + """Return the comfort setpoint temperature. + + Internally used for preset selection. + """ + return self.coordinator.data.temperatures["targetTempHigh"] + + @property + def _device_eco_setpoint(self) -> float | None: + """Return the eco setpoint temperature. + + Internally used for preset selection. + """ + return self.coordinator.data.temperatures["targetTempLow"] + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation mode.""" + if self.target_temperature == MIN_TEMP: + return HVACMode.OFF + if self.target_temperature == MAX_TEMP: + return HVACMode.HEAT + return HVACMode.AUTO + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + # presets have an order in which they are displayed on TRV: + # away, boost, comfort, eco, none (manual) + if ( + self.coordinator.data.holiday.get("start") is None + and self.coordinator.data.holiday.get("end") is not None + and self.target_temperature + == self.coordinator.data.holiday.get("temperature") + ): + return PRESET_AWAY + if self.target_temperature == MAX_TEMP: + return PRESET_BOOST + if self.target_temperature == self._device_comfort_setpoint: + return PRESET_COMFORT + if self.target_temperature == self._device_eco_setpoint: + return PRESET_ECO + return PRESET_NONE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + + if self.preset_mode == PRESET_AWAY: + raise ServiceValidationError( + "Cannot adjust TRV remotely, manually" + " disable 'holiday' mode on TRV first" + ) + + await self.coordinator.send_command( + self.coordinator.device.set_temperature_async, + { + "values": { + # manual temperature always needs to be set, + # otherwise TRV will turn OFF + "manualTemp": kwargs.get(ATTR_TEMPERATURE) + or self.target_temperature, + # other temperatures can be left unchanged by setting them to None + "targetTempLow": kwargs.get(ATTR_TARGET_TEMP_LOW), + "targetTempHigh": kwargs.get(ATTR_TARGET_TEMP_HIGH), + } + }, + ) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if self.preset_modes and preset_mode not in self.preset_modes: + raise ServiceValidationError(f"Unsupported preset_mode '{preset_mode}'") + if preset_mode in [PRESET_NONE, PRESET_AWAY]: + raise ServiceValidationError( + f"Unable to set preset '{preset_mode}', display only." + ) + if preset_mode == PRESET_ECO: + return await self.async_set_temperature( + temperature=self._device_eco_setpoint + ) + if preset_mode == PRESET_COMFORT: + return await self.async_set_temperature( + temperature=self._device_comfort_setpoint + ) + if preset_mode == PRESET_BOOST: + return await self.async_set_temperature(temperature=MAX_TEMP) + return None + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + if hvac_mode == HVACMode.OFF: + return await self.async_set_temperature(temperature=MIN_TEMP) + if hvac_mode == HVACMode.HEAT: + return await self.async_set_temperature(temperature=MAX_TEMP) + if hvac_mode == HVACMode.AUTO: + return await self.async_set_temperature( + temperature=self._device_eco_setpoint + ) + raise ServiceValidationError(f"Unknown HVAC mode '{hvac_mode}'") + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/eurotronic_cometblue/config_flow.py b/homeassistant/components/eurotronic_cometblue/config_flow.py new file mode 100644 index 00000000000..f79600c81bb --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/config_flow.py @@ -0,0 +1,184 @@ +"""Config flow for CometBlue.""" + +import logging +from typing import Any + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue +from eurotronic_cometblue_ha.const import SERVICE +from habluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_PIN +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PIN, default="000000"): vol.All( + TextSelector(TextSelectorConfig(type=TextSelectorType.NUMBER)), + vol.Length(min=6, max=6), + ), + } +) + + +def name_from_discovery(discovery: BluetoothServiceInfoBleak | None) -> str: + """Get the name from a discovery.""" + if discovery is None: + return "Comet Blue" + if discovery.name == str(discovery.address): + return discovery.address + return f"{discovery.name} {discovery.address}" + + +class CometBlueConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for CometBlue.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def _try_connect(self, user_input: dict[str, Any]) -> dict[str, str]: + """Verify connection to the device with the provided PIN.""" + device_address = self._discovery_info.address if self._discovery_info else "" + try: + ble_device = async_ble_device_from_address(self.hass, device_address) + LOGGER.info("Testing connection for device at address %s", device_address) + if not ble_device: + return {"base": "cannot_connect"} + + cometblue_device = AsyncCometBlue( + device=ble_device, + pin=int(user_input[CONF_PIN]), + ) + + async with cometblue_device: + try: + # Device only returns battery level if PIN is correct + await cometblue_device.get_battery_async() + except TimeoutError: + # This likely means PIN was incorrect on Linux and ESPHome backends + LOGGER.debug( + "Failed to read battery level, likely due to incorrect PIN", + exc_info=True, + ) + return {"base": "invalid_pin"} + except TimeoutError: + LOGGER.debug("Connection to device timed out", exc_info=True) + return {"base": "timeout_connect"} + except BleakError: + LOGGER.debug("Failed to connect to device", exc_info=True) + return {"base": "cannot_connect"} + except Exception: # noqa: BLE001 + LOGGER.debug("Unknown error", exc_info=True) + return {"base": "unknown"} + return {} + + def _create_entry( + self, + pin: str, + ) -> ConfigFlowResult: + """Create an entry for a discovered device.""" + + entry_data = { + CONF_ADDRESS: self._discovery_info.address + if self._discovery_info + else None, + CONF_PIN: pin, + } + + return self.async_create_entry( + title=name_from_discovery(self._discovery_info), data=entry_data + ) + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user-confirmation of discovered device.""" + + errors: dict[str, str] = {} + + if user_input is not None: + errors = await self._try_connect(user_input) + if not errors: + return self._create_entry(user_input[CONF_PIN]) + + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle a flow initialized by Bluetooth discovery.""" + address = discovery_info.address + + await self.async_set_unique_id(format_mac(address)) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address}) + + self._discovery_info = discovery_info + + self.context["title_placeholders"] = { + "name": name_from_discovery(self._discovery_info) + } + return await self.async_step_bluetooth_confirm() + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to pick discovered device.""" + + current_addresses = self._async_current_ids() + self._discovered_devices = { + discovery_info.address: discovery_info + for discovery_info in async_discovered_service_info( + self.hass, connectable=True + ) + if SERVICE in discovery_info.service_uuids + and discovery_info.address not in current_addresses + } + + if user_input is not None: + address = user_input[CONF_ADDRESS] + + await self.async_set_unique_id(format_mac(address)) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices.get(address) + return await self.async_step_bluetooth_confirm() + # Check if there is at least one device + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(list(self._discovered_devices))} + ), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + return await self.async_step_pick_device() diff --git a/homeassistant/components/eurotronic_cometblue/const.py b/homeassistant/components/eurotronic_cometblue/const.py new file mode 100644 index 00000000000..352baa83b38 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/const.py @@ -0,0 +1,7 @@ +"""Constants for Cometblue BLE thermostats.""" + +from typing import Final + +DOMAIN: Final = "eurotronic_cometblue" + +MAX_RETRIES: Final = 3 diff --git a/homeassistant/components/eurotronic_cometblue/coordinator.py b/homeassistant/components/eurotronic_cometblue/coordinator.py new file mode 100644 index 00000000000..d38f1e4e2f5 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/coordinator.py @@ -0,0 +1,139 @@ +"""Provides the DataUpdateCoordinator for Comet Blue.""" + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import timedelta +import logging +from typing import Any + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue, InvalidByteValueError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MAX_RETRIES + +SCAN_INTERVAL = timedelta(minutes=5) +LOGGER = logging.getLogger(__name__) +COMMAND_RETRY_INTERVAL = 2.5 + +type CometBlueConfigEntry = ConfigEntry[CometBlueDataUpdateCoordinator] + + +@dataclass +class CometBlueCoordinatorData: + """Data stored by the coordinator.""" + + temperatures: dict[str, float | int] = field(default_factory=dict) + holiday: dict = field(default_factory=dict) + battery: int | None = None + + +class CometBlueDataUpdateCoordinator(DataUpdateCoordinator[CometBlueCoordinatorData]): + """Class to manage fetching data.""" + + def __init__( + self, + hass: HomeAssistant, + entry: CometBlueConfigEntry, + cometblue: AsyncCometBlue, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + config_entry=entry, + logger=LOGGER, + name=f"Comet Blue {cometblue.client.address}", + update_interval=SCAN_INTERVAL, + ) + self.device = cometblue + self.address = cometblue.client.address + self.data = CometBlueCoordinatorData() + + async def send_command( + self, + function: Callable[..., Awaitable[dict[str, Any] | None]], + payload: dict[str, Any], + ) -> dict[str, Any] | None: + """Send command to device.""" + + LOGGER.debug("Updating device %s with '%s'", self.name, payload) + retry_count = 0 + while retry_count < MAX_RETRIES: + retry_count += 1 + try: + async with self.device: + return await function(**payload) + except (InvalidByteValueError, TimeoutError, BleakError) as ex: + if retry_count >= MAX_RETRIES: + raise HomeAssistantError( + f"Error sending command to '{self.name}': {ex}" + ) from ex + LOGGER.info( + "Retry sending command to %s after %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + await asyncio.sleep(COMMAND_RETRY_INTERVAL) + except ValueError as ex: + raise ServiceValidationError( + f"Invalid payload '{payload}' for '{self.name}': {ex}" + ) from ex + return None + + async def _async_update_data(self) -> CometBlueCoordinatorData: + """Poll the device.""" + data = CometBlueCoordinatorData() + + retry_count = 0 + + while retry_count < MAX_RETRIES and not data.temperatures: + try: + retry_count += 1 + async with self.device: + # temperatures are required and must trigger + # a retry if not available + if not data.temperatures: + data.temperatures = await self.device.get_temperature_async() + # holiday and battery are optional and should not trigger a retry + try: + if not data.holiday: + data.holiday = await self.device.get_holiday_async(1) or {} + if not data.battery: + data.battery = await self.device.get_battery_async() + except InvalidByteValueError as ex: + LOGGER.warning( + "Failed to retrieve optional data for %s: %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + except (InvalidByteValueError, TimeoutError, BleakError) as ex: + if retry_count >= MAX_RETRIES: + raise UpdateFailed( + f"Error retrieving data: {ex}", retry_after=30 + ) from ex + LOGGER.info( + "Retry updating %s after error: %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + await asyncio.sleep(COMMAND_RETRY_INTERVAL) + except Exception as ex: + raise UpdateFailed( + f"({type(ex).__name__}) {ex}", retry_after=30 + ) from ex + + # If one value was not retrieved correctly, keep the old value + if not data.holiday: + data.holiday = self.data.holiday + if not data.battery: + data.battery = self.data.battery + LOGGER.debug("Received data for %s: %s", self.name, data) + return data diff --git a/homeassistant/components/eurotronic_cometblue/entity.py b/homeassistant/components/eurotronic_cometblue/entity.py new file mode 100644 index 00000000000..9390fc0976a --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/entity.py @@ -0,0 +1,38 @@ +"""Coordinator entity base class for CometBlue.""" + +from homeassistant.components import bluetooth +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN +from .coordinator import CometBlueDataUpdateCoordinator + + +class CometBlueBluetoothEntity(CoordinatorEntity[CometBlueDataUpdateCoordinator]): + """Coordinator entity for CometBlue.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + # Full DeviceInfo is added to DeviceRegistry in __init__.py, so we only + # set identifiers here to link the entity to the device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.address)}, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + # As long the device is currently connectable via + # Bluetooth it is available, even if the last update + # failed. This is because Bluetooth connectivity can be + # intermittent and a failed update doesn't necessarily + # mean the device is unavailable. The BluetoothManager + # will check every 300s (same interval as + # DataUpdateCoordinator) if the device is still present + # and connectable. + return bluetooth.async_address_present( + self.hass, address=self.coordinator.address, connectable=True + ) diff --git a/homeassistant/components/eurotronic_cometblue/icons.json b/homeassistant/components/eurotronic_cometblue/icons.json new file mode 100644 index 00000000000..5b418f4b2db --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "button": { + "sync_time": { + "default": "mdi:calendar-clock" + } + }, + "number": { + "comfort_setpoint": { + "default": "mdi:thermometer-chevron-up" + }, + "eco_setpoint": { + "default": "mdi:thermometer-chevron-down" + }, + "offset": { + "default": "mdi:thermometer-check" + } + } + } +} diff --git a/homeassistant/components/eurotronic_cometblue/manifest.json b/homeassistant/components/eurotronic_cometblue/manifest.json new file mode 100644 index 00000000000..1d39f1f8bc5 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "eurotronic_cometblue", + "name": "Eurotronic Comet Blue", + "bluetooth": [ + { + "connectable": true, + "service_uuid": "47e9ee00-47e9-11e4-8939-164230d1df67" + } + ], + "codeowners": ["@rikroe"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/eurotronic_cometblue", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["eurotronic_cometblue_ha"], + "quality_scale": "bronze", + "requirements": ["eurotronic-cometblue-ha==1.4.0"] +} diff --git a/homeassistant/components/eurotronic_cometblue/number.py b/homeassistant/components/eurotronic_cometblue/number.py new file mode 100644 index 00000000000..6280257496a --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/number.py @@ -0,0 +1,126 @@ +"""Comet Blue number integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from eurotronic_cometblue_ha import AsyncCometBlue + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .climate import MAX_TEMP, MIN_TEMP +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class CometBlueNumberEntityDescription(NumberEntityDescription): + """Describes a Comet Blue number entity.""" + + cometblue_key: str + set_fn: Callable[[AsyncCometBlue], Any] + + +DESCRIPTIONS = [ + CometBlueNumberEntityDescription( + key="offset", + cometblue_key="tempOffset", + translation_key="offset", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=-5.0, + native_max_value=5.0, + native_step=PRECISION_HALVES, + entity_registry_enabled_default=False, + ), + CometBlueNumberEntityDescription( + key="eco_setpoint", + cometblue_key="targetTempLow", + translation_key="eco_setpoint", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_step=PRECISION_HALVES, + ), + CometBlueNumberEntityDescription( + key="comfort_setpoint", + cometblue_key="targetTempHigh", + translation_key="comfort_setpoint", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_step=PRECISION_HALVES, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + entities: list[CometBlueNumberEntity] = [ + CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS + ] + + async_add_entities(entities) + + +class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity): + """Representation of a number.""" + + entity_description: CometBlueNumberEntityDescription + + def __init__( + self, + coordinator: CometBlueDataUpdateCoordinator, + description: CometBlueNumberEntityDescription, + ) -> None: + """Initialize CometBlueNumberEntity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.coordinator.data.temperatures.get( + self.entity_description.cometblue_key + ) + + async def async_set_native_value(self, value: float) -> None: + """Update to the device.""" + + await self.coordinator.send_command( + self.entity_description.set_fn(self.coordinator.device), + { + "values": { + # manual temperature always needs to be set, + # otherwise TRV will turn OFF + "manualTemp": self.coordinator.data.temperatures["manualTemp"], + self.entity_description.cometblue_key: value, + } + }, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/eurotronic_cometblue/quality_scale.yaml b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml new file mode 100644 index 00000000000..7ebb9bc0559 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not subscribe to any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not login to any device or service. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration relies on MAC-based BLE connections. + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: done + entity-category: + status: exempt + comment: This integration only provides one primary entity. + entity-device-class: + status: exempt + comment: This integration does not provide sensors. + entity-disabled-by-default: + status: exempt + comment: This integration only provides one primary entity. + entity-translations: + status: exempt + comment: This integration only provides one primary entity. + exception-translations: todo + icon-translations: + status: exempt + comment: Not required. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Not required. + stale-devices: + status: exempt + comment: Only single device per config entry. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not make any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/eurotronic_cometblue/sensor.py b/homeassistant/components/eurotronic_cometblue/sensor.py new file mode 100644 index 00000000000..47d0a6a5ac7 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/sensor.py @@ -0,0 +1,51 @@ +"""Comet Blue sensor integration.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + entities = [CometBlueBatterySensorEntity(coordinator)] + + async_add_entities(entities) + + +class CometBlueBatterySensorEntity(CometBlueBluetoothEntity, SensorEntity): + """Representation of a sensor.""" + + def __init__( + self, + coordinator: CometBlueDataUpdateCoordinator, + ) -> None: + """Initialize CometBlueSensorEntity.""" + + super().__init__(coordinator) + self.entity_description = SensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + ) + self._attr_unique_id = f"{coordinator.address}-{self.entity_description.key}" + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.coordinator.data.battery diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json new file mode 100644 index 00000000000..9722914a088 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "No Comet Blue Bluetooth TRVs discovered.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_pin": "Invalid device PIN", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "6-digit device PIN" + } + }, + "pick_device": { + "data": { + "address": "Discovered devices" + }, + "data_description": { + "address": "Select device to continue." + } + } + } + }, + "entity": { + "button": { + "sync_time": { + "name": "Sync time" + } + }, + "number": { + "comfort_setpoint": { + "name": "Comfort setpoint" + }, + "eco_setpoint": { + "name": "Eco setpoint" + }, + "offset": { + "name": "Setpoint offset" + } + } + } +} diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 4ed5a0f1378..1558b5055ca 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -1,7 +1,5 @@ """Component for handling incoming events as a platform.""" -from __future__ import annotations - from dataclasses import asdict, dataclass from datetime import datetime, timedelta from enum import StrEnum @@ -20,7 +18,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey -from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN +from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN) @@ -44,6 +42,7 @@ __all__ = [ "DOMAIN", "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", + "DoorbellEventType", "EventDeviceClass", "EventEntity", "EventEntityDescription", @@ -189,6 +188,21 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) async def async_internal_added_to_hass(self) -> None: """Call when the event entity is added to hass.""" await super().async_internal_added_to_hass() + + if ( + self.device_class == EventDeviceClass.DOORBELL + and DoorbellEventType.RING not in self.event_types + ): + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s is a doorbell event entity but does not support " + "the '%s' event type. This will stop working in " + "Home Assistant 2027.4, please %s", + self.entity_id, + DoorbellEventType.RING, + report_issue, + ) + if ( (state := await self.async_get_last_state()) and state.state is not None diff --git a/homeassistant/components/event/const.py b/homeassistant/components/event/const.py index cd6a8b96f7a..5bab5875052 100644 --- a/homeassistant/components/event/const.py +++ b/homeassistant/components/event/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for the component.""" +from enum import StrEnum + DOMAIN = "event" ATTR_EVENT_TYPE = "event_type" ATTR_EVENT_TYPES = "event_types" + + +class DoorbellEventType(StrEnum): + """Standard event types for doorbell device class.""" + + RING = "ring" diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index bdf9144761c..1b5e349b8f3 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -15,7 +15,14 @@ "name": "Button" }, "doorbell": { - "name": "Doorbell" + "name": "Doorbell", + "state_attributes": { + "event_type": { + "state": { + "ring": "Ring" + } + } + } }, "motion": { "name": "Motion" diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py index aeff81988ba..81739d24288 100644 --- a/homeassistant/components/event/trigger.py +++ b/homeassistant/components/event/trigger.py @@ -2,13 +2,13 @@ import voluptuous as vol -from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import CONF_OPTIONS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, + StatelessEntityTriggerBase, Trigger, TriggerConfig, ) @@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) -class EventReceivedTrigger(EntityTriggerBase): +class EventReceivedTrigger(StatelessEntityTriggerBase): """Trigger for event entity when it receives a matching event.""" _domain_specs = {DOMAIN: DomainSpec()} @@ -39,22 +39,9 @@ class EventReceivedTrigger(EntityTriggerBase): super().__init__(hass, config) self._event_types = set(self._options[CONF_EVENT_TYPE]) - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the event is received - # would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - def is_valid_state(self, state: State) -> bool: - """Check if the event type is valid and matches one of the configured types.""" - return ( - state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types - ) + """Check if the event type matches one of the configured types.""" + return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index c153f01e83c..4a4dcfc9e71 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -1,7 +1,5 @@ """Support for EverLights lights.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, cast diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 7fb7430a044..4161328cab1 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -1,7 +1,5 @@ """The Evil Genius Labs integration.""" -from __future__ import annotations - import pyevilgenius from homeassistant.const import Platform diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index 67bbd7faf54..1bc373cec88 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Evil Genius Labs integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py index 202dcaf6ba7..694b9bc0c9f 100644 --- a/homeassistant/components/evil_genius_labs/coordinator.py +++ b/homeassistant/components/evil_genius_labs/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Evil Genius Labs integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py index 371e0c85b35..9f9936934d0 100644 --- a/homeassistant/components/evil_genius_labs/diagnostics.py +++ b/homeassistant/components/evil_genius_labs/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Evil Genius Labs.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/evil_genius_labs/entity.py b/homeassistant/components/evil_genius_labs/entity.py index a690b385c56..f7f8eaa74df 100644 --- a/homeassistant/components/evil_genius_labs/entity.py +++ b/homeassistant/components/evil_genius_labs/entity.py @@ -1,7 +1,5 @@ """The Evil Genius Labs integration.""" -from __future__ import annotations - from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 3dd9b763ae1..b9466500f03 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -1,7 +1,5 @@ """Light platform for Evil Genius Light.""" -from __future__ import annotations - import asyncio from typing import Any, cast diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index 1182cab3e8b..80c06803e6d 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -1,7 +1,5 @@ """Utilities for Evil Genius Labs.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c2d2e6aad0a..70933f3470e 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -6,8 +6,6 @@ others. Note that the API used by this integration's client does not support cooling. """ -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Final @@ -104,6 +102,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task( async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) ) + hass.async_create_task( + async_load_platform(hass, Platform.BUTTON, DOMAIN, {}, config) + ) if coordinator.tcs.hotwater: hass.async_create_task( async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py new file mode 100644 index 00000000000..be0a5c0ac52 --- /dev/null +++ b/homeassistant/components/evohome/button.py @@ -0,0 +1,115 @@ +"""Support for Button entities of the Evohome integration.""" + +import evohomeasync2 as evo + +from homeassistant.components.button import ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import EVOHOME_DATA +from .coordinator import EvoDataUpdateCoordinator +from .entity import is_valid_zone, unique_zone_id + + +async def async_setup_platform( + hass: HomeAssistant, + _: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the button platform for Evohome.""" + + if discovery_info is None: + return + + coordinator = hass.data[EVOHOME_DATA].coordinator + tcs = hass.data[EVOHOME_DATA].tcs + + entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)] + + entities.extend( + [EvoResetZoneButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)] + ) + + if tcs.hotwater: + entities.append(EvoResetDhwButton(coordinator, tcs.hotwater)) + + async_add_entities(entities) + + +class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEntity): + """Base for Evohome's Button entities.""" + + _attr_entity_category = EntityCategory.CONFIG + + _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, + ) -> None: + """Initialize an Evohome reset button entity.""" + super().__init__(coordinator, context=evo_device.id) + self._evo_device = evo_device + + async def async_press(self) -> None: + """Reset the Evohome entity to its base operating mode.""" + await self.coordinator.call_client_api(self._evo_device.reset()) + + +class EvoResetSystemButton(EvoResetButtonBase): + """Button entity for system reset.""" + + _evo_device: evo.ControlSystem + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.ControlSystem, + ) -> None: + """Initialize the system reset button.""" + super().__init__(coordinator, evo_device) + + self._attr_unique_id = f"{evo_device.id}_reset" + self._attr_name = f"Reset {evo_device.location.name}" + + +class EvoResetDhwButton(EvoResetButtonBase): + """Button entity for DHW override reset.""" + + _evo_device: evo.HotWater + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.HotWater, + ) -> None: + """Initialize the DHW reset button.""" + super().__init__(coordinator, evo_device) + + self._attr_unique_id = f"{evo_device.id}_reset" + self._attr_name = f"Reset {evo_device.name}" + + +class EvoResetZoneButton(EvoResetButtonBase): + """Button entity for zone override reset.""" + + _evo_device: evo.Zone + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.Zone, + ) -> None: + """Initialize the zone reset button.""" + super().__init__(coordinator, evo_device) + self._attr_unique_id = f"{unique_zone_id(evo_device)}_reset" + + @property + def name(self) -> str: + """Return the name, dynamically following any zone rename.""" + return f"Reset {self._evo_device.name}" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 21b03844a2d..418fd187b8a 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,7 +1,5 @@ """Support for Climate entities of the Evohome integration.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import Any @@ -16,8 +14,6 @@ from evohomeasync2.const import ( from evohomeasync2.schemas.const import ( SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, - ZoneModelType as EvoZoneModelType, - ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -37,13 +33,22 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService +from .const import ( + ATTR_DURATION, + ATTR_PERIOD, + DOMAIN, + EVOHOME_DATA, + RESET_BREAKS_IN_HA_VERSION, + EvoService, +) from .coordinator import EvoDataUpdateCoordinator -from .entity import EvoChild, EvoEntity +from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id +from .helpers import async_create_deprecation_issue_once _LOGGER = logging.getLogger(__name__) @@ -70,16 +75,16 @@ HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} async def async_setup_platform( hass: HomeAssistant, - config: ConfigType, + _: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Create the evohome Controller, and its Zones, if any.""" + """Set up the climate platform for Evohome.""" + if discovery_info is None: return coordinator = hass.data[EVOHOME_DATA].coordinator - loc_idx = hass.data[EVOHOME_DATA].loc_idx tcs = hass.data[EVOHOME_DATA].tcs _LOGGER.debug( @@ -87,16 +92,13 @@ async def async_setup_platform( tcs.model, tcs.id, tcs.location.name, - loc_idx, + coordinator.loc_idx, ) entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] for zone in tcs.zones: - if ( - zone.model == EvoZoneModelType.HEATING_ZONE - or zone.type == EvoZoneType.THERMOSTAT - ): + if is_valid_zone(zone): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", zone.type, @@ -166,13 +168,8 @@ class EvoZone(EvoChild, EvoClimateEntity): """Initialize an evohome-compatible heating zone.""" super().__init__(coordinator, evo_device) - self._evo_id = evo_device.id - if evo_device.id == evo_device.tcs.id: - # this system does not have a distinct ID for the zone - self._attr_unique_id = f"{evo_device.id}z" - else: - self._attr_unique_id = evo_device.id + self._attr_unique_id = unique_zone_id(evo_device) if coordinator.client_v1: self._attr_precision = PRECISION_TENTHS @@ -189,33 +186,38 @@ class EvoZone(EvoChild, EvoClimateEntity): ) async def async_clear_zone_override(self) -> None: - """Clear the zone's override, if any.""" + """Clear the zone override (if any) and return to following its schedule.""" + async_create_deprecation_issue_once( + self.hass, + "deprecated_clear_zone_override_service", + RESET_BREAKS_IN_HA_VERSION, + ) await self.coordinator.call_client_api(self._evo_device.reset()) async def async_set_zone_override( self, setpoint: float, duration: timedelta | None = None ) -> None: - """Set the zone's override (mode/setpoint).""" + """Override the zone's setpoint, either permanently or for a duration.""" temperature = max(min(setpoint, self.max_temp), self.min_temp) - if duration is not None: - if duration.total_seconds() == 0: - await self._update_schedule() - until = self.setpoints.get("next_sp_from") - else: - until = dt_util.now() + duration - else: + if duration is None: until = None # indefinitely + elif duration.total_seconds() == 0: + await self._update_schedule() + until = self.setpoints.get("next_sp_from") + else: + until = dt_util.now() + duration until = dt_util.as_utc(until) if until else None + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) @property - def name(self) -> str | None: + def name(self) -> str: """Return the name of the evohome entity.""" - return self._evo_device.name # zones can be easily renamed + return self._evo_device.name # zones can be renamed @property def hvac_mode(self) -> HVACMode | None: @@ -330,7 +332,7 @@ class EvoController(EvoClimateEntity): It is assumed there is only one TCS per location, and they are thus synonymous. """ - _attr_icon = "mdi:thermostat" + _attr_icon = "mdi:thermostat-box" _attr_precision = PRECISION_TENTHS _evo_device: evo.ControlSystem @@ -343,7 +345,6 @@ class EvoController(EvoClimateEntity): """Initialize an evohome-compatible controller.""" super().__init__(coordinator, evo_device) - self._evo_id = evo_device.id self._attr_unique_id = evo_device.id self._attr_name = evo_device.location.name @@ -358,11 +359,26 @@ class EvoController(EvoClimateEntity): ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + async_dispatcher_connect(self.hass, DOMAIN, self.process_signal) + + async def process_signal(self, payload: dict | None = None) -> None: + """Process any signals.""" + + if payload is None: + raise NotImplementedError + if payload["unique_id"] != self._attr_unique_id: + return + await self.async_tcs_svc_request(payload["service"], payload["data"]) + async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (system mode) for a controller. - Data validation is not required here; it is performed upstream by the service - handler (service schema plus runtime checks). + Data validation must be performed upstream in the service handler, before the + dispatcher call, so a ServiceValidationError can be seen, if raised. """ if service == EvoService.RESET_SYSTEM: @@ -452,6 +468,13 @@ class EvoController(EvoClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to 'Auto' mode.""" + if preset_mode == PRESET_RESET: + async_create_deprecation_issue_once( + self.hass, + "deprecated_preset_reset", + RESET_BREAKS_IN_HA_VERSION, + ) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EvoSystemMode.AUTO)) @callback diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index f601ebbfecb..06baf09cfc4 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -1,7 +1,5 @@ """The constants of the Evohome integration.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum, unique from typing import TYPE_CHECKING, Final @@ -19,16 +17,20 @@ STORAGE_KEY: Final = DOMAIN CONF_LOCATION_IDX: Final = "location_idx" -USER_DATA: Final = "user_data" - SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_PERIOD: Final = "period" # number of days ATTR_DURATION: Final = "duration" # number of minutes, <24h - +ATTR_PERIOD: Final = "period" # number of days ATTR_SETPOINT: Final = "setpoint" +# Support for the refresh_system service is being deprecated +REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0" +# Support for the reset service calls/presets is being deprecated +RESET_BREAKS_IN_HA_VERSION: Final = "2026.11.0" +# Support for untargeted service calls to controllers is being deprecated +SERVICE_BREAKS_IN_HA_VERSION: Final = "2026.11.0" + @unique class EvoService(StrEnum): @@ -39,3 +41,4 @@ class EvoService(StrEnum): RESET_SYSTEM = "reset_system" SET_ZONE_OVERRIDE = "set_zone_override" CLEAR_ZONE_OVERRIDE = "clear_zone_override" + SET_DHW_OVERRIDE = "set_dhw_override" diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 98e2b3b97df..bb1393021df 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -1,7 +1,5 @@ """Support for (EMEA/EU-based) Honeywell TCC systems.""" -from __future__ import annotations - from collections.abc import Awaitable from datetime import timedelta from http import HTTPStatus diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 0879fe739bc..9c6c8c642eb 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,29 +1,46 @@ -"""Base for evohome entity.""" +"""Support for entities of the Evohome integration.""" from collections.abc import Mapping -from datetime import UTC, datetime import logging from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.const import ( + ZoneModelType as EvoZoneModelType, + ZoneType as EvoZoneType, +) from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util -from .const import DOMAIN from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): - """Base for any evohome-compatible entity (controller, DHW, zone). +def is_valid_zone(zone: evo.Zone) -> bool: + """Check if an Evohome zone should have climate and button entities.""" + return ( + zone.model == EvoZoneModelType.HEATING_ZONE + or zone.type == EvoZoneType.THERMOSTAT + ) - This includes the controller, (1 to 12) heating zones and (optionally) a - DHW controller. + +def unique_zone_id(evo_device: evo.Zone) -> str: + """Return a unique identifier for a zone-based entity. + + Some systems assign the zone the same ID as its parent TCS; in that case + we append 'z' so the zone entity doesn't collide with the controller entity. """ + if evo_device.id == evo_device.tcs.id: + return f"{evo_device.id}z" + return evo_device.id + + +class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): + """Base for Evohome's Climate & WaterHeater entities.""" _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone _evo_id_attr: str @@ -40,30 +57,11 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): self._device_state_attrs: dict[str, Any] = {} - async def process_signal(self, payload: dict | None = None) -> None: - """Process any signals.""" - - if payload is None: - raise NotImplementedError - if payload["unique_id"] != self._attr_unique_id: - return - await self.async_tcs_svc_request(payload["service"], payload["data"]) - - async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: - """Process a service request (system mode) for a controller.""" - raise NotImplementedError - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" return {"status": self._device_state_attrs} - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - - async_dispatcher_connect(self.hass, DOMAIN, self.process_signal) - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -75,6 +73,10 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): super()._handle_coordinator_update() + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + self._handle_coordinator_update() + class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). @@ -91,6 +93,7 @@ class EvoChild(EvoEntity): """Initialize an evohome-compatible child entity (DHW, zone).""" super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id self._evo_tcs = evo_device.tcs self._schedule: list[DayOfWeekDhwT] | None = None @@ -158,7 +161,7 @@ class EvoChild(EvoEntity): or self._schedule is None or ( (until := self._setpoints.get("next_sp_from")) is not None - and until < datetime.now(UTC) + and until < dt_util.utcnow() ) ): # must use self._setpoints, not self.setpoints await get_schedule() @@ -179,4 +182,4 @@ class EvoChild(EvoEntity): async def update_attrs(self) -> None: """Update the entity's extra state attrs.""" await self._update_schedule() - self._handle_coordinator_update() + await super().update_attrs() diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py new file mode 100644 index 00000000000..11dcf346d90 --- /dev/null +++ b/homeassistant/components/evohome/helpers.py @@ -0,0 +1,34 @@ +"""Helpers for the Evohome integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + + +@callback +def async_create_deprecation_issue_once( + hass: HomeAssistant, + issue_id: str, + breaks_in_ha_version: str, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, +) -> None: + """Create or update a deprecation issue entry.""" + + placeholders = { + **(translation_placeholders or {}), + "breaks_in_ha_version": breaks_in_ha_version, + } + + ir.async_get(hass).async_get_or_create( + DOMAIN, + issue_id, + breaks_in_ha_version=breaks_in_ha_version, + is_fixable=False, + is_persistent=True, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key or issue_id, + translation_placeholders=placeholders, + ) diff --git a/homeassistant/components/evohome/icons.json b/homeassistant/components/evohome/icons.json index 440595932f2..19ab3f7951d 100644 --- a/homeassistant/components/evohome/icons.json +++ b/homeassistant/components/evohome/icons.json @@ -1,4 +1,17 @@ { + "entity": { + "button": { + "clear_dhw_override": { + "default": "mdi:water-boiler-auto" + }, + "clear_zone_override": { + "default": "mdi:thermostat-auto" + }, + "reset_system_mode": { + "default": "mdi:thermostat-box-auto" + } + } + }, "services": { "clear_zone_override": { "service": "mdi:motion-sensor-off" @@ -9,6 +22,9 @@ "reset_system": { "service": "mdi:refresh" }, + "set_dhw_override": { + "service": "mdi:water-boiler" + }, "set_system_mode": { "service": "mdi:pencil" }, diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index b117ca6e4d7..6e465338ee7 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -1,7 +1,5 @@ """Service handlers for the Evohome integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any, Final @@ -14,20 +12,35 @@ from evohomeasync2.schemas.const import ( import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.const import ATTR_MODE +from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + service, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control -from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService +from .const import ( + ATTR_DURATION, + ATTR_PERIOD, + ATTR_SETPOINT, + DOMAIN, + REFRESH_BREAKS_IN_HA_VERSION, + RESET_BREAKS_IN_HA_VERSION, + SERVICE_BREAKS_IN_HA_VERSION, + EvoService, +) from .coordinator import EvoDataUpdateCoordinator +from .helpers import async_create_deprecation_issue_once # System service schemas (registered as domain services) SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # unsupported modes are rejected at runtime with ServiceValidationError - vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode) + vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here vol.Exclusive(ATTR_DURATION, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), @@ -36,6 +49,7 @@ SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, } # Zone service schemas (registered as entity services) @@ -49,6 +63,15 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { ), } +# DHW service schemas (registered as entity services) +SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { + vol.Required(ATTR_STATE): cv.boolean, + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(days=0), max=timedelta(days=1)), + ), +} + def _register_zone_entity_services(hass: HomeAssistant) -> None: """Register entity-level services for zones.""" @@ -71,6 +94,63 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None: ) +def _resolve_ctl_unique_id( + hass: HomeAssistant, + call: ServiceCall, + tcs_id: str, +) -> str: + """Resolve the target controller unique_id from an optional entity_id. + + During the deprecation window, advise users to switch to targeting the controller. + """ + + if (entity_id := call.data.get(ATTR_ENTITY_ID)) is None: + async_create_deprecation_issue_once( + hass, + f"deprecated_{call.service}_service", + SERVICE_BREAKS_IN_HA_VERSION, + translation_key="deprecated_controller_service", + translation_placeholders={"service": call.service}, + ) + return tcs_id + + entry = er.async_get(hass).async_get(entity_id) + + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ATTR_ENTITY_ID: entity_id}, + ) + + # currently, evohome supports only 1 controller + if ( + entry.domain != CLIMATE_DOMAIN + or entry.platform != DOMAIN + or entry.unique_id != tcs_id + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="controller_only_service", + translation_placeholders={"service": call.service}, + ) + + return tcs_id + + +def _register_dhw_entity_services(hass: HomeAssistant) -> None: + """Register entity-level services for DHW zones.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + EvoService.SET_DHW_OVERRIDE, + entity_domain=WATER_HEATER_DOMAIN, + schema=SET_DHW_OVERRIDE_SCHEMA, + func="async_set_dhw_override", + ) + + def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None: """Validate that a set_system_mode service call is properly formed.""" @@ -125,21 +205,43 @@ def setup_service_functions( @verify_domain_control(DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" + async_create_deprecation_issue_once( + hass, + "deprecated_refresh_system_service", + REFRESH_BREAKS_IN_HA_VERSION, + ) await coordinator.async_refresh() @verify_domain_control(DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the Evohome system mode or reset the system.""" + # We can rely upon coordinator.tcs being non-None here, since: + # - services are registered only if coordinator.async_first_refresh() succeeds + # - without config flow, the controller entity will never be de-registered + + assert coordinator.tcs is not None # mypy + # No additional validation for RESET_SYSTEM here, as the library method invoked # via that service call may be able to emulate the reset even if the system # doesn't support AutoWithReset natively + if call.service == EvoService.RESET_SYSTEM: + async_create_deprecation_issue_once( + hass, + "deprecated_reset_system_service", + RESET_BREAKS_IN_HA_VERSION, + ) + if call.service == EvoService.SET_SYSTEM_MODE: _validate_set_system_mode_params(coordinator.tcs, call.data) + unique_id = _resolve_ctl_unique_id(hass, call, coordinator.tcs.id) + else: + # this service call to be deprecated, so no need to _resolve_ctl_unique_id + unique_id = coordinator.tcs.id payload = { - "unique_id": coordinator.tcs.id, + "unique_id": unique_id, "service": call.service, "data": call.data, } @@ -156,3 +258,4 @@ def setup_service_functions( ) _register_zone_entity_services(hass) + _register_dhw_entity_services(hass) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index cbf39f9c215..5acb9610674 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -3,7 +3,14 @@ set_system_mode: fields: + entity_id: + selector: + entity: + integration: evohome + domain: climate mode: + required: true + default: Auto example: Away selector: select: @@ -19,9 +26,10 @@ set_system_mode: selector: object: duration: - example: '{"hours": 18}' + example: "18:00" selector: - object: + duration: + enable_second: false reset_system: @@ -32,6 +40,8 @@ set_zone_override: entity: integration: evohome domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE fields: setpoint: required: true @@ -41,12 +51,31 @@ set_zone_override: max: 35.0 step: 0.1 duration: - example: '{"minutes": 135}' + example: "02:15" selector: - object: + duration: + enable_second: false clear_zone_override: target: entity: integration: evohome domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE + +set_dhw_override: + target: + entity: + integration: evohome + domain: water_heater + fields: + state: + required: true + selector: + boolean: + duration: + example: "02:15" + selector: + duration: + enable_second: false diff --git a/homeassistant/components/evohome/storage.py b/homeassistant/components/evohome/storage.py index b078c33b305..da3eff9dc45 100644 --- a/homeassistant/components/evohome/storage.py +++ b/homeassistant/components/evohome/storage.py @@ -1,8 +1,6 @@ """Support for (EMEA/EU-based) Honeywell TCC systems.""" -from __future__ import annotations - -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from typing import Any, NotRequired, TypedDict from evohomeasync.auth import ( @@ -14,6 +12,7 @@ from evohomeasync2.auth import AbstractTokenManager from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util from .const import STORAGE_KEY, STORAGE_VER @@ -93,7 +92,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager): session_id_expires = session.get(SZ_SESSION_ID_EXPIRES) if session_id_expires is None: - self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15) + self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15) else: self._session_id_expires = datetime.fromisoformat(session_id_expires) diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 5f19ff49339..047e961c977 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,16 +1,22 @@ { "exceptions": { + "controller_only_service": { + "message": "Only Evohome controllers support the `{service}` action" + }, + "entity_not_found": { + "message": "The specified entity `{entity_id}` could not be found" + }, "invalid_system_mode": { "message": "The requested system mode is not supported: {error}" }, "mode_cant_be_temporary": { - "message": "The mode `{mode}` does not support `duration` or `period`" + "message": "The mode `{mode}` does not support 'Duration' or 'Period'" }, "mode_cant_have_duration": { - "message": "The mode `{mode}` does not support `duration`; use `period` instead" + "message": "The mode `{mode}` does not support 'Duration'; use 'Period' instead" }, "mode_cant_have_period": { - "message": "The mode `{mode}` does not support `period`; use `duration` instead" + "message": "The mode `{mode}` does not support 'Period'; use 'Duration' instead" }, "mode_not_supported": { "message": "The mode `{mode}` is not supported by this controller" @@ -19,39 +25,79 @@ "message": "Only zones support the `{service}` action" } }, + "issues": { + "deprecated_clear_zone_override_service": { + "description": "The `clear_zone_override` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the zone's Reset button instead.", + "title": "Evohome 'Clear zone override' action is deprecated" + }, + "deprecated_controller_service": { + "description": "The `{service}` action without `entity_id` is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Update any automation or script to include `entity_id`, targeting the controller's climate entity.", + "title": "Untargeted Evohome controller action is deprecated" + }, + "deprecated_preset_reset": { + "description": "Using the `Reset` preset on an Evohome controller is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.", + "title": "Evohome Reset preset is deprecated" + }, + "deprecated_refresh_system_service": { + "description": "The `refresh_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Instead, use the `homeassistant.update_entity` action, targeting the controller's climate entity.", + "title": "Evohome 'Refresh system' action is deprecated" + }, + "deprecated_reset_system_service": { + "description": "The `reset_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.", + "title": "Evohome 'Reset system' action is deprecated" + } + }, "services": { "clear_zone_override": { - "description": "Sets a zone to follow its schedule.", + "description": "Sets a zone to follow its schedule (deprecated).", "name": "Clear zone override" }, "refresh_system": { - "description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update.", + "description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update (deprecated).", "name": "Refresh system" }, "reset_system": { - "description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode).", + "description": "Sets a system's mode to `Auto` mode and resets all its zones to follow their schedules (deprecated). Some older systems may not support this feature.", "name": "Reset system" }, - "set_system_mode": { - "description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.", + "set_dhw_override": { + "description": "Overrides a DHW's state, either indefinitely or for a specified duration, after which it will revert to following its schedule.", "fields": { "duration": { - "description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours).", + "description": "The DHW will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.", "name": "Duration" }, + "state": { + "description": "The DHW state: True (on: heat the water up to the setpoint) or False (off).", + "name": "State" + } + }, + "name": "Set DHW override" + }, + "set_system_mode": { + "description": "Sets a system's mode, either indefinitely or until a specified end time, after which it will revert to `Auto`. Not all systems support all modes.", + "fields": { + "duration": { + "description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).", + "name": "Duration" + }, + "entity_id": { + "description": "The Evohome controller climate entity.", + "name": "Entity" + }, "mode": { "description": "Mode to set the system to.", "name": "[%key:common::config_flow::data::mode%]" }, "period": { - "description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1).", + "description": "A period of time in days; used only with `Away`, `DayOff`, or `Custom` mode. The system will revert to `Auto` mode at midnight (up to 99 days, today is day 1).", "name": "Period" } }, "name": "Set system mode" }, "set_zone_override": { - "description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.", + "description": "Overrides a zone's setpoint, either indefinitely or for a specified duration, after which it will revert to following its schedule.", "fields": { "duration": { "description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.", diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 4da5a826690..fbba6cb6440 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -1,7 +1,6 @@ """Support for WaterHeater entities of the Evohome integration.""" -from __future__ import annotations - +from datetime import timedelta import logging from typing import Any @@ -39,11 +38,12 @@ EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} async def async_setup_platform( hass: HomeAssistant, - config: ConfigType, + _: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Create a DHW controller.""" + """Set up the water heater platform for Evohome.""" + if discovery_info is None: return @@ -68,8 +68,6 @@ async def async_setup_platform( class EvoDHW(EvoChild, WaterHeaterEntity): """Base for any evohome-compatible DHW controller.""" - _attr_name = "DHW controller" - _attr_icon = "mdi:thermometer-lines" _attr_operation_list = list(HA_STATE_TO_EVO) _attr_supported_features = ( WaterHeaterEntityFeature.AWAY_MODE @@ -88,7 +86,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity): """Initialize an evohome-compatible DHW controller.""" super().__init__(coordinator, evo_device) - self._evo_id = evo_device.id self._attr_unique_id = evo_device.id self._attr_name = evo_device.name # is static @@ -97,6 +94,28 @@ class EvoDHW(EvoChild, WaterHeaterEntity): PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE ) + async def async_set_dhw_override( + self, state: bool, duration: timedelta | None = None + ) -> None: + """Override the DHW zone's on/off state permanently or for a duration.""" + + if duration is None: + until = None # indefinitely, aka permanent override + elif duration.total_seconds() == 0: + await self._update_schedule() + until = self.setpoints.get("next_sp_from") + else: + until = dt_util.now() + duration + + until = dt_util.as_utc(until) if until else None + + if state: + await self.coordinator.call_client_api(self._evo_device.set_on(until=until)) + else: + await self.coordinator.call_client_api( + self._evo_device.set_off(until=until) + ) + @property def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 65749871093..1aa8a2ccb28 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,7 +94,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Check EZVIZ cloud account entity is present, reload + # cloud account entities for camera entity change + # to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. if sensor_type == ATTR_TYPE_CAMERA: diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index f945fcf3667..5a445644ba0 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Ezviz alarm.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 5e069e0277a..941506418c2 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -1,7 +1,5 @@ """Support for EZVIZ binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 52e029dca98..d8ba79d07eb 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -1,7 +1,5 @@ """Support for EZVIZ button controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index a968543e5b7..a652cdcd8ad 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,7 +1,5 @@ """Support ezviz camera devices.""" -from __future__ import annotations - import logging from pyezvizapi.exceptions import HTTPError, InvalidHost, PyEzvizError diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 91b43767e4c..e206964bea8 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,7 +1,5 @@ """Config flow for EZVIZ.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -124,17 +122,15 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) - # We need to wake hibernating cameras. - # First create EZVIZ API instance. - await self.hass.async_add_executor_job(ezviz_client.login) + def _login_wake_and_test() -> None: + # Login to create EZVIZ API instance. + ezviz_client.login() + # Wake hibernating camera. + ezviz_client.get_detection_sensibility(data[ATTR_SERIAL]) + # Attempt an authenticated RTSP DESCRIBE request. + _test_camera_rtsp_creds(data) - # Secondly try to wake hybernating camera. - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - # Thirdly attempts an authenticated RTSP DESCRIBE request. - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) + await self.hass.async_add_executor_job(_login_wake_and_test) return self.async_create_entry( title=data[ATTR_SERIAL], diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 0a76871285b..6a80b6117e0 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -1,7 +1,5 @@ """An abstract class common to all EZVIZ entities.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 6ba1eec462c..5433f98b5e9 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -1,7 +1,5 @@ """Support EZVIZ last motion image.""" -from __future__ import annotations - import logging from propcache.api import cached_property @@ -79,7 +77,9 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ) except PyEzvizError: _LOGGER.warning( - "%s: Can't decrypt last alarm picture, looks like it was encrypted with other password", + "%s: Can't decrypt last alarm picture," + " looks like it was encrypted" + " with other password", self.entity_id, ) image_data = response.content diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index 9c9382a4f3e..04109ffd2a0 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -1,7 +1,5 @@ """Support for EZVIZ light entity.""" -from __future__ import annotations - from typing import Any from pyezvizapi.constants import DeviceCatagories, DeviceSwitchType, SupportExt diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 3f29309138c..a953e51fe45 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -1,7 +1,5 @@ """Support for EZVIZ number controls.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 24842f45b68..8a9b17c0f4f 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -1,7 +1,5 @@ """Support for EZVIZ select controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -126,11 +124,11 @@ async def async_setup_entry( for camera in coordinator.data: device_category = coordinator.data[camera].get("device_category") - supportExt = coordinator.data[camera].get("supportExt") + support_ext = coordinator.data[camera].get("supportExt") if ( device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value - and supportExt - and str(SupportExt.SupportBatteryManage.value) in supportExt + and support_ext + and str(SupportExt.SupportBatteryManage.value) in support_ext ): entities.append( EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index c441b34b42d..4680361154d 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -1,7 +1,5 @@ """Support for EZVIZ sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index 1cbc17ba464..2474ff97171 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -1,7 +1,5 @@ """Support for EZVIZ sirens.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index ae8419367c4..a2db31c1ae2 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -1,7 +1,5 @@ """Support for EZVIZ Switch sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index ffd9a260ce9..ed83be0d9b6 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -1,7 +1,5 @@ """Support for EZVIZ sensors.""" -from __future__ import annotations - from typing import Any from pyezvizapi import HTTPError, PyEzvizError diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 6822e2620fd..b449a8312ed 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for FAA Delays sensor component.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index b91b4536267..2efab2bdcc4 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,5 +1,3 @@ """Constants for the FAA Delays integration.""" -from __future__ import annotations - DOMAIN = "faa_delays" diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index ba998e79e3a..87b8bc4dc0b 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -1,7 +1,5 @@ """Facebook platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index aa29f28244b..829124b79f6 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -1,7 +1,5 @@ """Support for displaying IPs banned by fail2ban.""" -from __future__ import annotations - from datetime import timedelta import logging import os diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index 6be13b23568..ab1ab1f5e1a 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,7 +1,5 @@ """Family Hub camera for Samsung Refrigerators.""" -from __future__ import annotations - from pyfamilyhublocal import FamilyHubCam import voluptuous as vol diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index b9e20e8dc91..553919fd441 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with fans.""" -from __future__ import annotations - from datetime import timedelta from enum import IntFlag import functools as ft @@ -25,7 +23,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -88,7 +85,6 @@ class NotValidPresetModeError(ServiceValidationError): ) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the fans are on based on the statemachine.""" entity = hass.states.get(entity_id) diff --git a/homeassistant/components/fan/conditions.yaml b/homeassistant/components/fan/conditions.yaml index 2f7e4fca5b9..e54a077409a 100644 --- a/homeassistant/components/fan/conditions.yaml +++ b/homeassistant/components/fan/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_off: *condition_common is_on: *condition_common diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index b4164f1d1a6..fee4764dcd4 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Fan.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 39f77b7a128..c02120475e8 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -1,7 +1,5 @@ """Provide the device automations for Fan.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 8e1c518d7c7..c3522e84e9e 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Fan.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index 391059a369c..52ceb9ad508 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Fan state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 2beeac71000..4a464059eb6 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -99,14 +99,16 @@ increase_speed: supported_features: - fan.FanEntityFeature.SET_SPEED fields: - percentage_step: - advanced: true - required: false - selector: - number: - min: 0 - max: 100 - unit_of_measurement: "%" + additional_fields: + collapsed: true + fields: + percentage_step: + required: false + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" decrease_speed: target: @@ -115,11 +117,13 @@ decrease_speed: supported_features: - fan.FanEntityFeature.SET_SPEED fields: - percentage_step: - advanced: true - required: false - selector: - number: - min: 0 - max: 100 - unit_of_measurement: "%" + additional_fields: + collapsed: true + fields: + percentage_step: + required: false + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py index d3d346d5f66..540ca1578d5 100644 --- a/homeassistant/components/fan/significant_change.py +++ b/homeassistant/components/fan/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Fan state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 51a05b6bf4c..6780b89d454 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -1,7 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "section_additional_fields_name": "Additional options", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +12,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is off" @@ -18,6 +24,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is on" @@ -85,24 +94,11 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "direction": { "options": { "forward": "Forward", "reverse": "Reverse" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -114,7 +110,12 @@ "name": "Decrement" } }, - "name": "Decrease fan speed" + "name": "Decrease fan speed", + "sections": { + "additional_fields": { + "name": "[%key:component::fan::common::section_additional_fields_name%]" + } + } }, "increase_speed": { "description": "Increases the speed of a fan.", @@ -124,7 +125,12 @@ "name": "Increment" } }, - "name": "Increase fan speed" + "name": "Increase fan speed", + "sections": { + "additional_fields": { + "name": "[%key:component::fan::common::section_additional_fields_name%]" + } + } }, "oscillate": { "description": "Controls the oscillation of a fan.", @@ -196,6 +202,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::trigger_for_name%]" } }, "name": "Fan turned off" @@ -205,6 +214,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::trigger_for_name%]" } }, "name": "Fan turned on" diff --git a/homeassistant/components/fan/triggers.yaml b/homeassistant/components/fan/triggers.yaml index 1f7d9442c42..370aa986468 100644 --- a/homeassistant/components/fan/triggers.yaml +++ b/homeassistant/components/fan/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: turned_on: *trigger_common turned_off: *trigger_common diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 59cb3f984d2..07a418cdab2 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,7 +1,5 @@ """Support for testing internet speed via Fast.com.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntryState @@ -26,12 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) - async def _async_finish_startup(hass: HomeAssistant) -> None: """Run this only when HA has finished its startup.""" - if entry.state == ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.LOADED: await coordinator.async_refresh() else: await coordinator.async_config_entry_first_refresh() - # Don't start a speedtest during startup, this will slow down the overall startup dramatically + # Don't start a speedtest during startup, this will slow + # down the overall startup dramatically async_at_started(hass, _async_finish_startup) return True diff --git a/homeassistant/components/fastdotcom/config_flow.py b/homeassistant/components/fastdotcom/config_flow.py index b84c30cf58d..d6d39c65514 100644 --- a/homeassistant/components/fastdotcom/config_flow.py +++ b/homeassistant/components/fastdotcom/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fast.com integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/fastdotcom/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py index 9748b505fe8..2e0811daa07 100644 --- a/homeassistant/components/fastdotcom/coordinator.py +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Fast.com integration.""" -from __future__ import annotations - from datetime import timedelta from fastdotcom import fast_com diff --git a/homeassistant/components/fastdotcom/diagnostics.py b/homeassistant/components/fastdotcom/diagnostics.py index 42f4e32f49e..a7a1f456977 100644 --- a/homeassistant/components/fastdotcom/diagnostics.py +++ b/homeassistant/components/fastdotcom/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Fast.com.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b4d732947e4..07562ac5c46 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,7 +1,5 @@ """Support for Fast.com internet speed testing sensor.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 11ac553513f..7e4c083859b 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -1,7 +1,5 @@ """Support for RSS/Atom feeds.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 37c627f21ba..4bb19944224 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -1,7 +1,5 @@ """Config flow for RSS/Atom feeds.""" -from __future__ import annotations - import html import logging from typing import Any @@ -25,14 +23,18 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN +from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT LOGGER = logging.getLogger(__name__) async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict: """Fetch the feed.""" - return await hass.async_add_executor_job(feedparser.parse, url) + + def _parse_feed() -> feedparser.FeedParserDict: + return feedparser.parse(url, agent=USER_AGENT) + + return await hass.async_add_executor_job(_parse_feed) class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/feedreader/const.py b/homeassistant/components/feedreader/const.py index efaa0e9d972..8a216e0d3cf 100644 --- a/homeassistant/components/feedreader/const.py +++ b/homeassistant/components/feedreader/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import APPLICATION_NAME, __version__ as ha_version + DOMAIN: Final[str] = "feedreader" CONF_MAX_ENTRIES: Final[str] = "max_entries" @@ -10,3 +12,5 @@ DEFAULT_MAX_ENTRIES: Final[int] = 20 DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1) EVENT_FEEDREADER: Final[str] = "feedreader" + +USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}" diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 9901bd9f1b4..31af12d144b 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for RSS/Atom feeds.""" -from __future__ import annotations - from calendar import timegm from datetime import datetime import html @@ -20,7 +18,13 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER +from .const import ( + CONF_MAX_ENTRIES, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + EVENT_FEEDREADER, + USER_AGENT, +) DELAY_SAVE = 30 STORAGE_VERSION = 1 @@ -76,6 +80,7 @@ class FeedReaderCoordinator( self.url, etag=None if not self._feed else self._feed.get("etag"), modified=None if not self._feed else self._feed.get("modified"), + agent=USER_AGENT, ) feed = await self.hass.async_add_executor_job(_parse_feed) @@ -187,7 +192,8 @@ class FeedReaderCoordinator( firstrun = True # Set last entry timestamp as epoch time if not available self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() - # locally cache self._last_entry_timestamp so that entries published at identical times can be processed + # locally cache self._last_entry_timestamp so that + # entries published at identical times can be processed last_entry_timestamp = self._last_entry_timestamp for entry in self._feed.entries: if firstrun or ( diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index d74550d9fd1..fb6e1596b2f 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -1,7 +1,5 @@ """Event entities for RSS/Atom feeds.""" -from __future__ import annotations - import html import logging @@ -69,8 +67,9 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): if (data := self.coordinator.data) is None or not data: return - # RSS feeds are normally sorted reverse chronologically by published date - # so we always take the first entry in list, since we only care about the latest entry + # RSS feeds are normally sorted reverse chronologically + # by published date so we always take the first entry + # in list, since we only care about the latest entry feed_data: FeedParserDict = data[0] if description := feed_data.get("description"): diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index d4be04deae3..d6c65d65285 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -1,7 +1,5 @@ """Support for FFmpeg.""" -from __future__ import annotations - import asyncio import re @@ -20,7 +18,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.system_info import is_official_image from .const import ( @@ -71,7 +68,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@bind_hass def get_ffmpeg_manager(hass: HomeAssistant) -> FFmpegManager: """Return the FFmpegManager.""" if DATA_FFMPEG not in hass.data: @@ -79,7 +75,6 @@ def get_ffmpeg_manager(hass: HomeAssistant) -> FFmpegManager: return hass.data[DATA_FFMPEG] -@bind_hass async def async_get_image( hass: HomeAssistant, input_source: str, @@ -145,7 +140,7 @@ class FFmpegManager: return CONTENT_TYPE_MULTIPART.format("ffserver") -class FFmpegBase[_HAFFmpegT: HAFFmpeg](Entity): # pylint: disable=hass-enforce-class-module +class FFmpegBase[_HAFFmpegT: HAFFmpeg](Entity): # pylint: disable=home-assistant-enforce-class-module """Interface object for FFmpeg.""" _attr_should_poll = False diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 03566ba162c..2dd1b8a0362 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,7 +1,5 @@ """Support for Cameras with FFmpeg as decoder.""" -from __future__ import annotations - from typing import Any from aiohttp import web diff --git a/homeassistant/components/ffmpeg/services.py b/homeassistant/components/ffmpeg/services.py index 6b522799f4f..512d8981591 100644 --- a/homeassistant/components/ffmpeg/services.py +++ b/homeassistant/components/ffmpeg/services.py @@ -1,7 +1,5 @@ """Support for FFmpeg.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 3adae8441df..e9a00fc1d28 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -1,7 +1,5 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" -from __future__ import annotations - from typing import Any from haffmpeg.core import HAFFmpeg diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index cc6f20cde7f..bd1c9de1011 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -1,7 +1,5 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" -from __future__ import annotations - from typing import Any import haffmpeg.sensor as ffmpeg_sensor @@ -19,7 +17,7 @@ from homeassistant.components.ffmpeg import ( FFmpegManager, get_ffmpeg_manager, ) -from homeassistant.components.ffmpeg_motion.binary_sensor import ( # pylint: disable=hass-component-root-import +from homeassistant.components.ffmpeg_motion.binary_sensor import ( # pylint: disable=home-assistant-component-root-import FFmpegBinarySensor, ) from homeassistant.const import CONF_NAME diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index d56cd113e76..0bba7997597 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -1,7 +1,5 @@ """Support for the Fibaro devices.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Mapping import logging diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 14c8f03f3ec..6ea31820304 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Fibaro binary sensors.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 7a8cc3fd2a9..1f39aacdb15 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -1,7 +1,5 @@ """Support for Fibaro thermostats.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index d941ceab37f..d41071c21d8 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fibaro integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e2027120d43..eaa1b201294 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -1,7 +1,5 @@ """Support for Fibaro cover - curtains, rollershutters etc.""" -from __future__ import annotations - from typing import Any, cast from pyfibaro.fibaro_device import DeviceModel diff --git a/homeassistant/components/fibaro/diagnostics.py b/homeassistant/components/fibaro/diagnostics.py index 2f1f397a69a..b2c41e8ef8f 100644 --- a/homeassistant/components/fibaro/diagnostics.py +++ b/homeassistant/components/fibaro/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for fibaro integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index e8ed5afc500..8b36e54be1c 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -1,7 +1,5 @@ """Support for the Fibaro devices.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index ad44719c8be..92512cafd06 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -1,7 +1,5 @@ """Support for Fibaro event entities.""" -from __future__ import annotations - from pyfibaro.fibaro_device import DeviceModel, SceneEvent from pyfibaro.fibaro_state_resolver import FibaroEvent diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index a82769bf9ee..1d35242308f 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -1,7 +1,5 @@ """Support for Fibaro lights.""" -from __future__ import annotations - from contextlib import suppress from typing import Any diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index a1e76109e2d..f2c308b1a1f 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -1,7 +1,5 @@ """Support for Fibaro locks.""" -from __future__ import annotations - from typing import Any from pyfibaro.fibaro_device import DeviceModel diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 8a594506f27..caad54d6e60 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -1,7 +1,5 @@ """Support for Fibaro scenes.""" -from __future__ import annotations - from typing import Any from pyfibaro.fibaro_scene import SceneModel diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 9034bd7d05e..004c9f087a7 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -1,7 +1,5 @@ """Support for Fibaro sensors.""" -from __future__ import annotations - from contextlib import suppress from pyfibaro.fibaro_device import DeviceModel diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 8d77685c1e7..af2e7969da1 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,7 +1,5 @@ """Support for Fibaro switches.""" -from __future__ import annotations - from typing import Any from pyfibaro.fibaro_device import DeviceModel diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index cbce2efd7c5..d8b0ac13815 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -4,8 +4,6 @@ Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless """ -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 9078a4d115e..69e5e0068e6 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -1,7 +1,5 @@ """Config flow for file integration.""" -from __future__ import annotations - from copy import deepcopy from typing import Any diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 90af1677bce..a9ab4d53345 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -1,7 +1,5 @@ """Support for file notification.""" -from __future__ import annotations - import os from typing import Any, TextIO diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index 6a22222ef0f..5e6238953e3 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -1,7 +1,5 @@ """Support for sensor value(s) stored in local files.""" -from __future__ import annotations - import logging import os diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py index 0cd4aaf9324..9e4033148cf 100644 --- a/homeassistant/components/file/services.py +++ b/homeassistant/components/file/services.py @@ -9,6 +9,7 @@ import yaml from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE @@ -17,7 +18,8 @@ from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE def async_setup_services(hass: HomeAssistant) -> None: """Register services for File integration.""" - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_READ_FILE, read_file, diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index fba514fefa6..7cbd89cd261 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -1,7 +1,5 @@ """The File Upload integration.""" -from __future__ import annotations - import asyncio from collections.abc import Generator from contextlib import contextmanager @@ -76,7 +74,8 @@ class FileUploadData: """Create temporary directory.""" temp_dir = Path(tempfile.gettempdir()) / TEMP_DIR_NAME - # If it exists, it's an old one and Home Assistant didn't shut down correctly. + # If it exists, it's an old one and Home Assistant + # didn't shut down correctly. if temp_dir.exists(): shutil.rmtree(temp_dir) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index b10125de67c..b78f3f16771 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -1,7 +1,5 @@ """The filesize component.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 8ffe3f94353..cb0e55bd211 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -1,7 +1,5 @@ """The filesize config flow.""" -from __future__ import annotations - import logging import pathlib from typing import Any diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 87f59f1a53e..c9cd79c8921 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for monitoring the size of a file.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging import os diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 966e253660d..7230c6a9e92 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -1,7 +1,5 @@ """Sensor for monitoring the size of a file.""" -from __future__ import annotations - from datetime import datetime import logging diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py index f974250b1e8..02d92e57e0b 100644 --- a/homeassistant/components/filter/config_flow.py +++ b/homeassistant/components/filter/config_flow.py @@ -1,7 +1,5 @@ """Config flow for filter.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index eb1337002e4..d62d44999c5 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -1,7 +1,5 @@ """Allows the creation of a sensor that filters state property.""" -from __future__ import annotations - from collections import Counter, deque from copy import copy from dataclasses import dataclass diff --git a/homeassistant/components/fing/__init__.py b/homeassistant/components/fing/__init__.py index 699bc447cf0..f7bcc16d8c6 100644 --- a/homeassistant/components/fing/__init__.py +++ b/homeassistant/components/fing/__init__.py @@ -1,7 +1,5 @@ """The Fing integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform @@ -23,10 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) if coordinator.data.network_id is None: _LOGGER.warning( - "Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest" + "Skip setting up Fing integration; Received an empty" + " NetworkId from the request - Check if the API" + " version is the latest" ) raise ConfigEntryError( - "The Agent's API version is outdated. Please update the agent to the latest version." + "The Agent's API version is outdated." + " Please update the agent to the latest version." ) config_entry.runtime_data = coordinator diff --git a/homeassistant/components/fing/config_flow.py b/homeassistant/components/fing/config_flow.py index 10dd6bbb3f8..f406e9c223b 100644 --- a/homeassistant/components/fing/config_flow.py +++ b/homeassistant/components/fing/config_flow.py @@ -48,7 +48,9 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN): devices_response = await fing_api.get_devices() with suppress(httpx.ConnectError): - # The suppression is needed because the get_agent_info method isn't available for desktop agents + # The suppression is needed because the + # get_agent_info method isn't available + # for desktop agents agent_info_response = await fing_api.get_agent_info() except httpx.NetworkError as _: @@ -57,7 +59,8 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "timeout_connect" except httpx.HTTPStatusError as exception: description_placeholders["message"] = ( - f"{exception.response.status_code} - {exception.response.reason_phrase}" + f"{exception.response.status_code}" + f" - {exception.response.reason_phrase}" ) if exception.response.status_code == 401: errors["base"] = "invalid_api_key" diff --git a/homeassistant/components/fing/coordinator.py b/homeassistant/components/fing/coordinator.py index b2390f77317..4a226326745 100644 --- a/homeassistant/components/fing/coordinator.py +++ b/homeassistant/components/fing/coordinator.py @@ -69,7 +69,8 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]): if err.response.status_code == 401: raise UpdateFailed("Invalid API key") from err raise UpdateFailed( - f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}" + f"Http request failed -> {err.response.status_code}" + f" - {err.response.reason_phrase}" ) from err except httpx.InvalidURL as err: raise UpdateFailed("Invalid hostname or IP address") from err diff --git a/homeassistant/components/fing/utils.py b/homeassistant/components/fing/utils.py index a37733687ad..97e1ae675ff 100644 --- a/homeassistant/components/fing/utils.py +++ b/homeassistant/components/fing/utils.py @@ -16,7 +16,7 @@ class DeviceType(Enum): GAME_CONSOLE = "mdi:nintendo-game-boy" STREAMING_DONGLE = "mdi:cast" LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker" - DISC_PLAYER = "mdi:disk-player" + DISC_PLAYER = "mdi:disc-player" REMOTE_CONTROL = "mdi:remote-tv" RADIO = "mdi:radio" PHOTO_CAMERA = PHOTOS = "mdi:camera" diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index f5188d5bf21..54601c2e9ec 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -1,7 +1,5 @@ """Read the balance of your bank accounts via FinTS.""" -from __future__ import annotations - from collections import namedtuple from datetime import timedelta import logging diff --git a/homeassistant/components/firefly_iii/__init__.py b/homeassistant/components/firefly_iii/__init__.py index 6a778ae8c8a..a79139315ee 100644 --- a/homeassistant/components/firefly_iii/__init__.py +++ b/homeassistant/components/firefly_iii/__init__.py @@ -1,7 +1,5 @@ """The Firefly III integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index 279d56c408f..c569cd26b23 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Firefly III integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py index eab5d82adef..348782b02b0 100644 --- a/homeassistant/components/firefly_iii/coordinator.py +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -1,7 +1,5 @@ """Data Update Coordinator for Firefly III integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/firefly_iii/diagnostics.py b/homeassistant/components/firefly_iii/diagnostics.py index 6b3a6a13940..3bf23fbca83 100644 --- a/homeassistant/components/firefly_iii/diagnostics.py +++ b/homeassistant/components/firefly_iii/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics for the Firefly III integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/firefly_iii/entity.py b/homeassistant/components/firefly_iii/entity.py index 96a774097bf..7c84d33d277 100644 --- a/homeassistant/components/firefly_iii/entity.py +++ b/homeassistant/components/firefly_iii/entity.py @@ -1,7 +1,5 @@ """Base entity for Firefly III integration.""" -from __future__ import annotations - from pyfirefly.models import Account, Budget, Category from yarl import URL diff --git a/homeassistant/components/firefly_iii/sensor.py b/homeassistant/components/firefly_iii/sensor.py index 614fc97b898..8d372ce5b18 100644 --- a/homeassistant/components/firefly_iii/sensor.py +++ b/homeassistant/components/firefly_iii/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Firefly III integration.""" -from __future__ import annotations - from pyfirefly.models import Account, Budget, Category from homeassistant.components.sensor import ( diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json index d3b11743ecf..d367a686993 100644 --- a/homeassistant/components/firefly_iii/strings.json +++ b/homeassistant/components/firefly_iii/strings.json @@ -1,4 +1,9 @@ { + "common": { + "api_key": "Access token", + "api_key_description": "The access token for authenticating with Firefly III", + "verify_ssl_description": "Verify the SSL certificate of the Firefly III instance" + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -14,39 +19,39 @@ "step": { "reauth_confirm": { "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:component::firefly_iii::common::api_key%]" }, "data_description": { - "api_key": "The new API access token for authenticating with Firefly III" + "api_key": "[%key:component::firefly_iii::common::api_key_description%]" }, - "description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + "description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)." }, "reconfigure": { "data": { - "api_key": "[%key:common::config_flow::data::api_key%]", + "api_key": "[%key:component::firefly_iii::common::api_key%]", "url": "[%key:common::config_flow::data::url%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "api_key": "[%key:component::firefly_iii::config::step::user::data_description::api_key%]", + "api_key": "[%key:component::firefly_iii::common::api_key_description%]", "url": "[%key:common::config_flow::data::url%]", - "verify_ssl": "[%key:component::firefly_iii::config::step::user::data_description::verify_ssl%]" + "verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]" }, "description": "Use the following form to reconfigure your Firefly III instance.", "title": "Reconfigure Firefly III Integration" }, "user": { "data": { - "api_key": "[%key:common::config_flow::data::api_key%]", + "api_key": "[%key:component::firefly_iii::common::api_key%]", "url": "[%key:common::config_flow::data::url%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "api_key": "The API key for authenticating with Firefly III", + "api_key": "[%key:component::firefly_iii::common::api_key_description%]", "url": "[%key:common::config_flow::data::url%]", - "verify_ssl": "Verify the SSL certificate of the Firefly III instance" + "verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]" }, - "description": "You can create an API key in the Firefly III UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + "description": "You can create an access token in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)." } } }, diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index 0f30a29cfba..0d5b4a18007 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -1,7 +1,5 @@ """The FireServiceRota integration.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.const import Platform diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index be7add191c0..e5c32632749 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for FireServiceRota integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/homeassistant/components/fireservicerota/config_flow.py b/homeassistant/components/fireservicerota/config_flow.py index 7b7248d44a1..b44b2978c0b 100644 --- a/homeassistant/components/fireservicerota/config_flow.py +++ b/homeassistant/components/fireservicerota/config_flow.py @@ -1,7 +1,5 @@ """Config flow for FireServiceRota.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index 0e108791f4a..d0da2868593 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -1,9 +1,8 @@ """The FireServiceRota integration.""" -from __future__ import annotations - from datetime import timedelta import logging +from typing import Any from pyfireservicerota import ( ExpiredTokenError, @@ -179,9 +178,12 @@ class FireServiceRotaClient: if await self.oauth.async_refresh_tokens(): self.token_refresh_failure = False - await self._hass.async_add_executor_job(self.websocket.start_listener) - return await self._hass.async_add_executor_job(func, *args) + def _restart_and_call() -> Any: + self.websocket.start_listener() + return func(*args) + + return await self._hass.async_add_executor_job(_restart_and_call) async def async_update(self) -> dict | None: """Get the latest availability data.""" diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index f7414d7e1bd..3719dec01b3 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities([IncidentsSensor(entry.runtime_data.client)]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing +# pylint: disable-next=home-assistant-invalid-inheritance # needs fixing class IncidentsSensor(RestoreEntity, SensorEntity): """Representation of FireServiceRota incidents sensor.""" diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index 641a0a74fa7..53792a61625 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -1,7 +1,5 @@ """Code to handle a Firmata board.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Literal diff --git a/homeassistant/components/firmata/entity.py b/homeassistant/components/firmata/entity.py index 60b7c3879ff..9752abbde25 100644 --- a/homeassistant/components/firmata/entity.py +++ b/homeassistant/components/firmata/entity.py @@ -1,7 +1,5 @@ """Entity for Firmata devices.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py index f866ce9dbe5..14beb18a7e7 100644 --- a/homeassistant/components/firmata/light.py +++ b/homeassistant/components/firmata/light.py @@ -1,7 +1,5 @@ """Support for Firmata light output.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py index c27152a8150..11052aab2ee 100644 --- a/homeassistant/components/firmata/pin.py +++ b/homeassistant/components/firmata/pin.py @@ -1,7 +1,5 @@ """Code to handle pins on a Firmata board.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import cast diff --git a/homeassistant/components/fish_audio/__init__.py b/homeassistant/components/fish_audio/__init__.py index 912229cc8bf..cc4fa60adcd 100644 --- a/homeassistant/components/fish_audio/__init__.py +++ b/homeassistant/components/fish_audio/__init__.py @@ -1,17 +1,14 @@ """The Fish Audio integration.""" -from __future__ import annotations - import logging from fishaudio import AsyncFishAudio from fishaudio.exceptions import AuthenticationError, FishAudioError -from homeassistant.const import Platform +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_API_KEY from .types import FishAudioConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fish_audio/config_flow.py b/homeassistant/components/fish_audio/config_flow.py index 17ab9d21505..f4b8463d91e 100644 --- a/homeassistant/components/fish_audio/config_flow.py +++ b/homeassistant/components/fish_audio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Fish Audio integration.""" -from __future__ import annotations - import logging from typing import Any @@ -18,6 +16,7 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( LanguageSelector, @@ -31,11 +30,8 @@ from homeassistant.helpers.selector import ( from .const import ( API_KEYS_URL, BACKEND_MODELS, - CONF_API_KEY, CONF_BACKEND, - CONF_LANGUAGE, CONF_LATENCY, - CONF_NAME, CONF_SELF_ONLY, CONF_SORT_BY, CONF_TITLE, @@ -132,6 +128,8 @@ def get_model_selection_schema( mode=SelectSelectorMode.DROPDOWN, ) ), + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_NAME, default=options.get(CONF_NAME) or vol.UNDEFINED, @@ -284,7 +282,7 @@ class FishAudioSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Manage initial options.""" entry = self._get_entry() - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") self.client = entry.runtime_data diff --git a/homeassistant/components/fish_audio/const.py b/homeassistant/components/fish_audio/const.py index 93f4c244d8c..d61a018634e 100644 --- a/homeassistant/components/fish_audio/const.py +++ b/homeassistant/components/fish_audio/const.py @@ -4,14 +4,10 @@ from typing import Literal DOMAIN = "fish_audio" - -CONF_NAME: Literal["name"] = "name" CONF_USER_ID: Literal["user_id"] = "user_id" -CONF_API_KEY: Literal["api_key"] = "api_key" CONF_VOICE_ID: Literal["voice_id"] = "voice_id" CONF_BACKEND: Literal["backend"] = "backend" CONF_SELF_ONLY: Literal["self_only"] = "self_only" -CONF_LANGUAGE: Literal["language"] = "language" CONF_SORT_BY: Literal["sort_by"] = "sort_by" CONF_LATENCY: Literal["latency"] = "latency" CONF_TITLE: Literal["title"] = "title" diff --git a/homeassistant/components/fish_audio/tts.py b/homeassistant/components/fish_audio/tts.py index 5a355de8fce..73f9e9deaad 100644 --- a/homeassistant/components/fish_audio/tts.py +++ b/homeassistant/components/fish_audio/tts.py @@ -1,7 +1,5 @@ """TTS platform for the Fish Audio integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/fish_audio/types.py b/homeassistant/components/fish_audio/types.py index 9fd0f4d3821..dc37a2129e9 100644 --- a/homeassistant/components/fish_audio/types.py +++ b/homeassistant/components/fish_audio/types.py @@ -1,7 +1,5 @@ """Type definitions for the Fish Audio integration.""" -from __future__ import annotations - from fishaudio import AsyncFishAudio from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 8d44a0b686e..9d00cffdd25 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -117,7 +117,10 @@ class FitbitApi(ABC): return devices async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: - """Return the most recent value from the time series for the specified resource type.""" + """Return the most recent value from the time series. + + Returns the value for the specified resource type. + """ client = await self._async_get_client() # Set request header based on the configured unit system diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index 86794f5a963..d366ab83c47 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -52,7 +52,10 @@ class OAuth2FlowHandler( async def async_step_creation( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Create config entry from external data with Fitbit specific error handling.""" + """Create config entry from external data. + + Handles Fitbit specific errors. + """ try: return await super().async_step_creation() except FitbitAuthException as err: diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index c20854e03cf..9a70a2d9261 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,7 +1,5 @@ """Constants for the Fitbit platform.""" -from __future__ import annotations - from enum import StrEnum from typing import Final @@ -15,7 +13,6 @@ ATTR_LAST_SAVED_AT: Final = "last_saved_at" ATTR_DURATION: Final = "duration" ATTR_DISTANCE: Final = "distance" -ATTR_ELEVATION: Final = "elevation" ATTR_HEIGHT: Final = "height" ATTR_WEIGHT: Final = "weight" ATTR_BODY: Final = "body" @@ -52,7 +49,8 @@ class FitbitUnitSystem(StrEnum): This is used as a header to tell the Fitbit API which type of units to return. https://dev.fitbit.com/build/reference/web-api/developer-guide/application-design/#Units - Prefer to leave unset for newer configurations to use the Home Assistant default units. + Prefer to leave unset for newer configurations to use + the Home Assistant default units. """ LEGACY_DEFAULT = "default" diff --git a/homeassistant/components/fitbit/exceptions.py b/homeassistant/components/fitbit/exceptions.py index 82ac53d5f99..c123bfceaeb 100644 --- a/homeassistant/components/fitbit/exceptions.py +++ b/homeassistant/components/fitbit/exceptions.py @@ -1,6 +1,7 @@ """Exceptions for fitbit API calls. -These exceptions exist to provide common exceptions for the async and sync client libraries. +These exceptions provide common exceptions for the async +and sync client libraries. """ from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index a33610ac84a..6834356647f 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,7 +1,5 @@ """Support for the Fitbit API.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import datetime @@ -511,14 +509,12 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( icon="mdi:battery", scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, - has_entity_name=True, ) FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( key="devices/battery_level", translation_key="battery_level", scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, - has_entity_name=True, device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, ) @@ -656,6 +652,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti entity_description: FitbitSensorEntityDescription _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -715,6 +712,7 @@ class FitbitBatteryLevelSensor( """Implementation of a Fitbit battery level sensor.""" entity_description: FitbitSensorEntityDescription + _attr_has_entity_name = True _attr_attribution = ATTRIBUTION def __init__( diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py index c69a8172272..a9ea946f852 100644 --- a/homeassistant/components/fivem/__init__.py +++ b/homeassistant/components/fivem/__init__.py @@ -1,7 +1,5 @@ """The FiveM integration.""" -from __future__ import annotations - import logging from fivem import FiveMServerOfflineError diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py index d5132627b9d..ef6eba47ffa 100644 --- a/homeassistant/components/fivem/config_flow.py +++ b/homeassistant/components/fivem/config_flow.py @@ -1,7 +1,5 @@ """Config flow for FiveM integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/fivem/coordinator.py b/homeassistant/components/fivem/coordinator.py index 2fcad7e0c98..11c96fd5b79 100644 --- a/homeassistant/components/fivem/coordinator.py +++ b/homeassistant/components/fivem/coordinator.py @@ -1,7 +1,5 @@ """The FiveM update coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py index a7459123fa1..3b42ae6183d 100644 --- a/homeassistant/components/fivem/entity.py +++ b/homeassistant/components/fivem/entity.py @@ -1,7 +1,5 @@ """The FiveM entity.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass import logging diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 3fb241208ad..572297ab599 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -1,7 +1,5 @@ """Currency exchange rate support that comes from fixer.io.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 961be04fd8d..bbabd3a4764 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -1,17 +1,16 @@ """The Fjäråskupan integration.""" -from __future__ import annotations - from collections.abc import Callable import logging -from fjaraskupan import Device +from fjaraskupan import UUID_SERVICE, Device from homeassistant.components.bluetooth import ( BluetoothCallbackMatcher, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, + async_discovered_service_info, async_rediscover_address, async_register_callback, ) @@ -38,6 +37,7 @@ PLATFORMS = [ ] _LOGGER = logging.getLogger(__name__) +_UUID = str(UUID_SERVICE).lower() async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) -> bool: @@ -45,39 +45,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) entry.runtime_data = {} - def detection_callback( - service_info: BluetoothServiceInfoBleak, change: BluetoothChange + def data_callback( + service_info: BluetoothServiceInfoBleak, change_: BluetoothChange ) -> None: - if change != BluetoothChange.ADVERTISEMENT: + if (data := entry.runtime_data.get(service_info.address)) is None: + _LOGGER.debug("Ignoring: %s", service_info) return - if data := entry.runtime_data.get(service_info.address): - _LOGGER.debug("Update: %s", service_info) - data.detection_callback(service_info) - else: - _LOGGER.debug("Detected: %s", service_info) - device = Device(service_info.device.address) - device_info = DeviceInfo( - connections={(dr.CONNECTION_BLUETOOTH, service_info.address)}, - identifiers={(DOMAIN, service_info.address)}, - manufacturer="Fjäråskupan", - name="Fjäråskupan", - ) + _LOGGER.debug("Update: %s", service_info) + data.detection_callback(service_info) - coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator( - hass, entry, device, device_info - ) - coordinator.detection_callback(service_info) + def detect_callback( + service_info: BluetoothServiceInfoBleak, change_: BluetoothChange + ) -> None: + if service_info.address in entry.runtime_data: + return - entry.runtime_data[service_info.address] = coordinator - async_dispatcher_send( - hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator - ) + _LOGGER.debug("Detected: %s", service_info) + device = Device(service_info.device.address) + device_info = DeviceInfo( + connections={(dr.CONNECTION_BLUETOOTH, service_info.address)}, + identifiers={(DOMAIN, service_info.address)}, + manufacturer="Fjäråskupan", + name="Fjäråskupan", + ) + + coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator( + hass, entry, device, device_info + ) + coordinator.detection_callback(service_info) + + entry.runtime_data[service_info.address] = coordinator + async_dispatcher_send( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator + ) entry.async_on_unload( async_register_callback( hass, - detection_callback, + data_callback, BluetoothCallbackMatcher( manufacturer_id=20296, manufacturer_data_start=[79, 68, 70, 74, 65, 82], @@ -87,6 +93,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) ) ) + entry.async_on_unload( + async_register_callback( + hass, + detect_callback, + BluetoothCallbackMatcher( + service_uuid=_UUID, + connectable=False, + ), + BluetoothScanningMode.ACTIVE, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -131,3 +149,17 @@ async def async_unload_entry( async_rediscover_address(hass, conn[1]) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: FjaraskupanConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + for service_info in async_discovered_service_info(hass, False): + if (DOMAIN, service_info.address) in device_entry.identifiers: + return False + + # No matching service info, so allow removal. + return True diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 7364fa85b2e..190b028455e 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -1,7 +1,5 @@ """Support for sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py index d5c287a0cff..e248084bc35 100644 --- a/homeassistant/components/fjaraskupan/config_flow.py +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -1,8 +1,6 @@ """Config flow for Fjäråskupan integration.""" -from __future__ import annotations - -from fjaraskupan import device_filter +from fjaraskupan import UUID_SERVICE from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.core import HomeAssistant @@ -17,7 +15,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: service_infos = async_discovered_service_info(hass) for service_info in service_infos: - if device_filter(service_info.device, service_info.advertisement): + uuids = service_info.service_uuids + if str(UUID_SERVICE) in uuids: return True return False diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py index 6cbe00e2d6b..501486e1dc9 100644 --- a/homeassistant/components/fjaraskupan/coordinator.py +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -1,7 +1,5 @@ """The Fjäråskupan data update coordinator.""" -from __future__ import annotations - from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, contextmanager from datetime import timedelta diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index b35bb728131..6b29a5f17fe 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -1,7 +1,5 @@ """Support for Fjäråskupan fans.""" -from __future__ import annotations - from typing import Any from fjaraskupan import ( diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index c39e3ca4736..842ae497f4d 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -1,7 +1,5 @@ """Support for lights.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index d321dcdccc4..5ab71386f66 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -4,8 +4,7 @@ "bluetooth": [ { "connectable": false, - "manufacturer_data_start": [79, 68, 70, 74, 65, 82], - "manufacturer_id": 20296 + "service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b" } ], "codeowners": ["@elupus"], diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index 93fd31273e9..951022d7cd6 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -1,7 +1,5 @@ """Support for sensors.""" -from __future__ import annotations - from homeassistant.components.number import NumberEntity from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index 039feb5913c..8fa1ed7319d 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -1,7 +1,5 @@ """Support for sensors.""" -from __future__ import annotations - from fjaraskupan import Device from homeassistant.components.sensor import ( diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index 71f6c174dde..340581564e0 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -1,7 +1,5 @@ """Support for FleetGO Platform.""" -from __future__ import annotations - import logging import requests diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index c645c9d08e5..a79b6a3dbc3 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -1,7 +1,5 @@ """Platform for Flexit AC units with CI66 Modbus adapter.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 01e0051f53f..f7832fb6a60 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -1,7 +1,5 @@ """The Flexit Nordic (BACnet) integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 1f580983a49..4b7eb6ecb9f 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -115,7 +115,11 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): await self.device.set_air_temp_setpoint_away(temperature) else: await self.device.set_air_temp_setpoint_home(temperature) - except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + except ( + asyncio.exceptions.TimeoutError, + ConnectionError, + DecodingError, + ) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_temperature", @@ -149,7 +153,11 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): # Set the desired ventilation mode ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] await self.device.set_ventilation_mode(ventilation_mode) - except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + except ( + asyncio.exceptions.TimeoutError, + ConnectionError, + DecodingError, + ) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_preset_mode", @@ -175,7 +183,11 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): await self.device.set_ventilation_mode(VENTILATION_MODE_STOP) else: await self.device.set_ventilation_mode(VENTILATION_MODE_HOME) - except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + except ( + asyncio.exceptions.TimeoutError, + ConnectionError, + DecodingError, + ) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_hvac_mode", diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py index f05a01b4b56..cb36872bad4 100644 --- a/homeassistant/components/flexit_bacnet/config_flow.py +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Flexit Nordic (BACnet) integration.""" -from __future__ import annotations - import asyncio.exceptions import logging from typing import Any diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py index 19de54ddd20..0ebda4b0e2a 100644 --- a/homeassistant/components/flexit_bacnet/const.py +++ b/homeassistant/components/flexit_bacnet/const.py @@ -2,10 +2,12 @@ from flexit_bacnet import ( OPERATION_MODE_AWAY, + OPERATION_MODE_COOKER_HOOD, OPERATION_MODE_FIREPLACE, OPERATION_MODE_HIGH, OPERATION_MODE_HOME, OPERATION_MODE_OFF, + OPERATION_MODE_TEMPORARY_HIGH, VENTILATION_MODE_AWAY, VENTILATION_MODE_HIGH, VENTILATION_MODE_HOME, @@ -28,7 +30,9 @@ OPERATION_TO_PRESET_MODE_MAP = { OPERATION_MODE_AWAY: PRESET_AWAY, OPERATION_MODE_HOME: PRESET_HOME, OPERATION_MODE_HIGH: PRESET_HIGH, + OPERATION_MODE_COOKER_HOOD: PRESET_HIGH, OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE, + OPERATION_MODE_TEMPORARY_HIGH: PRESET_HIGH, } # Map preset to ventilation mode (for setting standard modes) diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index 9148ec87883..2e0e27f0f98 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Flexit Nordic (BACnet) integration..""" -from __future__ import annotations - import asyncio.exceptions from datetime import timedelta import logging diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py index 38efa838c93..6ffa966ba99 100644 --- a/homeassistant/components/flexit_bacnet/entity.py +++ b/homeassistant/components/flexit_bacnet/entity.py @@ -1,7 +1,5 @@ """Base entity for the Flexit Nordic (BACnet) integration.""" -from __future__ import annotations - from flexit_bacnet import FlexitBACnet from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index b8c329bd1d4..10988a2c00c 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -39,7 +39,8 @@ class FlexitNumberEntityDescription(NumberEntityDescription): set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]] -# Setpoints for Away, Home and High are dependent of each other. Fireplace and Cooker Hood +# Setpoints for Away, Home and High are dependent of each +# other. Fireplace and Cooker Hood # have setpoints between 0 (MIN_FAN_SETPOINT) and 100 (MAX_FAN_SETPOINT). # See the table below for all the setpoints. # diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index 281e960f222..5ade4ac41a0 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -1,7 +1,5 @@ """Support to use flic buttons as a binary sensor.""" -from __future__ import annotations - import logging import threading diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 899d045ad86..765178d5c00 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Flipr binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py index 9673a1c5dd4..c1af23c9f15 100644 --- a/homeassistant/components/flipr/config_flow.py +++ b/homeassistant/components/flipr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Flipr integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index 82de5ae34d5..e1ef4d8f6ca 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for flipr integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index f96edbc0f71..7be1dc1c3bf 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Flipr's pool_sensor.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index 5025006c294..31c8313699d 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Flo Water Monitor binary sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index c1e9560ba81..fdb16e85d3c 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -1,7 +1,5 @@ """Flo device object.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index c9717b16059..595228dbda7 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -1,7 +1,5 @@ """Base entity class for Flo entities.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -12,7 +10,6 @@ from .coordinator import FloDeviceDataUpdateCoordinator class FloEntity(Entity): """A base class for Flo entities.""" - _attr_force_update = False _attr_has_entity_name = True _attr_should_poll = False diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index ca763839b87..8b6b95b9e3f 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -1,7 +1,5 @@ """Support for Flo Water Monitor sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 12e242db5c8..7d8be89a62c 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -1,7 +1,5 @@ """Switch representing the shutoff valve for the Flo by Moen integration.""" -from __future__ import annotations - from typing import Any from aioflo.location import SLEEP_MINUTE_OPTIONS, SYSTEM_MODE_HOME, SYSTEM_REVERT_MODES diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index d4e8f864ee8..ce886649299 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -1,7 +1,5 @@ """Flock platform for notify component.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging @@ -65,5 +63,6 @@ class FlockNotificationService(BaseNotificationService): response.status, result, ) + # pylint: disable-next=home-assistant-action-swallowed-exception except TimeoutError: _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index d229665ca62..048728bafd0 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -1,7 +1,5 @@ """The flume integration.""" -from __future__ import annotations - from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException @@ -111,12 +109,13 @@ def setup_service(hass: HomeAssistant) -> None: entry: FlumeConfigEntry | None = hass.config_entries.async_get_entry(entry_id) if not entry: raise ValueError(f"Invalid config entry: {entry_id}") - if not entry.state == ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise ValueError(f"Config entry not loaded: {entry_id}") return { "notifications": entry.runtime_data.notifications_coordinator.notifications # type: ignore[dict-item] } + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_LIST_NOTIFICATIONS, diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 2c2dc285036..db3ef08799f 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -1,7 +1,5 @@ """Flume binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index bdd4eb4cf51..3c683700360 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,7 +1,5 @@ """Config flow for flume integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import os diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index a8fe21f4b06..8e7d2e65531 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -1,7 +1,5 @@ """The Flume component.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 1dabf5726b2..f6c7addaba4 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -1,7 +1,5 @@ """The IntelliFire integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/flume/entity.py b/homeassistant/components/flume/entity.py index 2698a319220..8f0dc7b2748 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -1,7 +1,5 @@ """Platform for shared base classes for sensors.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 0f0213ec984..70ffee8973f 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfVolume +from homeassistant.const import UnitOfVolume, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -34,7 +34,8 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( key="current_interval", translation_key="current_interval", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -65,14 +66,16 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( key="last_60_min", translation_key="last_60_min", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_24_hrs", translation_key="last_24_hrs", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_DAY, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/flume/util.py b/homeassistant/components/flume/util.py index 58b3920c9be..03c15319f44 100644 --- a/homeassistant/components/flume/util.py +++ b/homeassistant/components/flume/util.py @@ -1,7 +1,5 @@ """Utilities for Flume.""" -from __future__ import annotations - from typing import Any from pyflume import FlumeDeviceList diff --git a/homeassistant/components/fluss/__init__.py b/homeassistant/components/fluss/__init__.py index c3d4b347ff5..d2661ec029e 100644 --- a/homeassistant/components/fluss/__init__.py +++ b/homeassistant/components/fluss/__init__.py @@ -1,17 +1,11 @@ """The Fluss+ integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from .coordinator import FlussDataUpdateCoordinator +from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BUTTON] - - -type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER] async def async_setup_entry( diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py index bc8a90e66c0..ab238396eb7 100644 --- a/homeassistant/components/fluss/button.py +++ b/homeassistant/components/fluss/button.py @@ -1,16 +1,13 @@ """Support for Fluss Devices.""" from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator +from .coordinator import FlussApiClientError, FlussConfigEntry from .entity import FlussEntity -type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, @@ -32,6 +29,11 @@ class FlussButton(FlussEntity, ButtonEntity): _attr_name = None + @property + def available(self) -> bool: + """Return True only when the device is online.""" + return super().available and self.device["internetConnected"] + async def async_press(self) -> None: """Handle the button press.""" try: diff --git a/homeassistant/components/fluss/config_flow.py b/homeassistant/components/fluss/config_flow.py index 09c7da62973..202cb91bde2 100644 --- a/homeassistant/components/fluss/config_flow.py +++ b/homeassistant/components/fluss/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fluss+ integration.""" -from __future__ import annotations - from typing import Any from fluss_api import ( diff --git a/homeassistant/components/fluss/const.py b/homeassistant/components/fluss/const.py index b66ae736106..33bf1842d42 100644 --- a/homeassistant/components/fluss/const.py +++ b/homeassistant/components/fluss/const.py @@ -5,5 +5,5 @@ import logging DOMAIN = "fluss" LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = 60 # seconds -UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL) +UPDATE_INTERVAL = timedelta(minutes=30) +COMMAND_REFRESH_COOLDOWN = 10 diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index 6f0bc20e30f..36df9298eb7 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -1,28 +1,29 @@ """DataUpdateCoordinator for Fluss+ integration.""" -from __future__ import annotations - +import asyncio from typing import Any from fluss_api import ( FlussApiClient, FlussApiClientAuthenticationError, FlussApiClientError, + FlussDeviceOfflineError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA +from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] -class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Manages fetching Fluss device data on a schedule.""" def __init__( @@ -35,11 +36,27 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): LOGGER, name=f"Fluss+ ({slugify(api_key[:8])})", config_entry=config_entry, - update_interval=UPDATE_INTERVAL_TIMEDELTA, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + LOGGER, + cooldown=COMMAND_REFRESH_COOLDOWN, + immediate=False, + ), ) + async def _async_get_status(self, device_id: str) -> dict[str, Any]: + """Return per-device status, treating an offline device as disconnected.""" + try: + response = await self.api.async_get_device_status(device_id) + except FlussDeviceOfflineError: + return {"internetConnected": False} + except FlussApiClientError as err: + raise UpdateFailed(f"Error fetching Fluss device status: {err}") from err + return response["status"] + async def _async_update_data(self) -> dict[str, dict[str, Any]]: - """Fetch data from the Fluss API and return as a dictionary keyed by deviceId.""" + """Fetch Fluss+ devices and merge per-device status.""" try: devices = await self.api.async_get_devices() except FlussApiClientAuthenticationError as err: @@ -47,4 +64,16 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except FlussApiClientError as err: raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err - return {device["deviceId"]: device for device in devices.get("devices", [])} + device_list = [ + device + for device in devices["devices"] + if device["userPermissions"]["canUseWiFi"] + ] + + statuses = await asyncio.gather( + *(self._async_get_status(d["deviceId"]) for d in device_list) + ) + return { + device["deviceId"]: {**device, **status} + for device, status in zip(device_list, statuses, strict=False) + } diff --git a/homeassistant/components/fluss/cover.py b/homeassistant/components/fluss/cover.py new file mode 100644 index 00000000000..541dc48ada5 --- /dev/null +++ b/homeassistant/components/fluss/cover.py @@ -0,0 +1,89 @@ +"""Cover platform for Fluss+ devices that report an open/closed status.""" + +from typing import Any + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import FlussApiClientError, FlussConfigEntry +from .entity import FlussEntity + +PARALLEL_UPDATES = 0 + +STATUS_OPEN = "Open" +STATUS_CLOSED = "Closed" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FlussConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fluss covers for devices that report an open/closed status.""" + coordinator = entry.runtime_data + added_device_ids: set[str] = set() + + def _async_add_new_entities() -> None: + new_entities = [ + FlussCover(coordinator, device_id, device) + for device_id, device in coordinator.data.items() + if "openCloseStatus" in device and device_id not in added_device_ids + ] + if not new_entities: + return + + added_device_ids.update(entity.device_id for entity in new_entities) + async_add_entities(new_entities) + + _async_add_new_entities() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities)) + + +class FlussCover(FlussEntity, CoverEntity): + """Representation of a Fluss+ cover.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_name = None + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + @property + def available(self) -> bool: + """Return True only when the device is online.""" + return super().available and self.device["internetConnected"] + + @property + def is_closed(self) -> bool | None: + """Return whether the cover is closed.""" + status = self.device.get("openCloseStatus") + if status == STATUS_CLOSED: + return True + if status == STATUS_OPEN: + return False + return None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + try: + await self.coordinator.api.async_open_device(self.device_id) + except FlussApiClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from err + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + try: + await self.coordinator.api.async_close_device(self.device_id) + except FlussApiClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from err + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index fcd7867ed1a..d420a0b82a4 100644 --- a/homeassistant/components/fluss/manifest.json +++ b/homeassistant/components/fluss/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["fluss-api"], "quality_scale": "bronze", - "requirements": ["fluss-api==0.1.9.20"] + "requirements": ["fluss-api==0.2.5"] } diff --git a/homeassistant/components/fluss/strings.json b/homeassistant/components/fluss/strings.json index cf63c7ff91a..adc19193b68 100644 --- a/homeassistant/components/fluss/strings.json +++ b/homeassistant/components/fluss/strings.json @@ -19,5 +19,10 @@ "description": "Your Fluss API key, available in the profile page of the Fluss+ app" } } + }, + "exceptions": { + "command_failed": { + "message": "Failed to send command to Fluss+ device" + } } } diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 53b90c82bef..13c0d55a617 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -3,8 +3,6 @@ The idea was taken from https://github.com/KpaBap/hue-flux/ """ -from __future__ import annotations - import datetime import logging from typing import Any diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 7515b6b8dfc..d5d5b707c7b 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -1,7 +1,5 @@ """The Flux LED/MagicLight integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, Final, cast @@ -87,8 +85,7 @@ def async_wifi_bulb_for_host( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the flux_led component.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[FLUX_LED_DISCOVERY] = [] + hass.data[FLUX_LED_DISCOVERY] = [] @callback def _async_start_background_discovery(*_: Any) -> None: @@ -193,7 +190,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: FluxLedConfigEntry) -> b if entry.unique_id and discovery.get(ATTR_ID): mac = dr.format_mac(cast(str, discovery[ATTR_ID])) if not mac_matches_by_one(mac, entry.unique_id): - # The device is offline and another flux_led device is now using the ip address + # The device is offline and another flux_led device + # is now using the ip address raise ConfigEntryNotReady( f"Unexpected device found at {host}; Expected {entry.unique_id}, found" f" {mac}" diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py index c4a7ff6569c..3f11d656af7 100644 --- a/homeassistant/components/flux_led/button.py +++ b/homeassistant/components/flux_led/button.py @@ -1,7 +1,5 @@ """Support for Magic home button.""" -from __future__ import annotations - from flux_led.aio import AIOWifiLedBulb from flux_led.protocol import RemoteConfig diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 754ed0525b9..f953a12e733 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Flux LED/MagicLight.""" -from __future__ import annotations - import contextlib from typing import Any, Self, cast @@ -138,7 +136,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): ConfigEntryState.SETUP_IN_PROGRESS, ConfigEntryState.NOT_LOADED, ) - ) or entry.state == ConfigEntryState.SETUP_RETRY: + ) or entry.state is ConfigEntryState.SETUP_RETRY: self.hass.config_entries.async_schedule_reload(entry.entry_id) else: async_dispatcher_send( diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index 08e1d274ea7..21b8d4284f3 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -9,8 +9,10 @@ from flux_led.const import ( COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, ) +from flux_led.scanner import FluxLEDDiscovery from homeassistant.components.light import ColorMode +from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "flux_led" @@ -34,7 +36,7 @@ DEFAULT_NETWORK_SCAN_INTERVAL: Final = 120 DEFAULT_SCAN_INTERVAL: Final = 5 DEFAULT_EFFECT_SPEED: Final = 50 -FLUX_LED_DISCOVERY: Final = "flux_led_discovery" +FLUX_LED_DISCOVERY: HassKey[list[FluxLEDDiscovery]] = HassKey(DOMAIN) FLUX_LED_EXCEPTIONS: Final = ( TimeoutError, diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index 78d8bb947fd..21106f34cb2 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -1,7 +1,5 @@ """The Flux LED/MagicLight integration coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/flux_led/diagnostics.py b/homeassistant/components/flux_led/diagnostics.py index 683aa362377..2d23a2ac9f9 100644 --- a/homeassistant/components/flux_led/diagnostics.py +++ b/homeassistant/components/flux_led/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for flux_led.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/flux_led/discovery.py b/homeassistant/components/flux_led/discovery.py index c3a3c5df3a7..6ff87c0baa9 100644 --- a/homeassistant/components/flux_led/discovery.py +++ b/homeassistant/components/flux_led/discovery.py @@ -1,7 +1,5 @@ """The Flux LED/MagicLight integration discovery.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -153,8 +151,7 @@ def async_update_entry_from_discovery( @callback def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | None: """Check if a device was already discovered via a broadcast discovery.""" - discoveries: list[FluxLEDDiscovery] = hass.data[DOMAIN][FLUX_LED_DISCOVERY] - for discovery in discoveries: + for discovery in hass.data[FLUX_LED_DISCOVERY]: if discovery[ATTR_IPADDR] == host: return discovery return None @@ -163,10 +160,10 @@ def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | No @callback def async_clear_discovery_cache(hass: HomeAssistant, host: str) -> None: """Clear the host from the discovery cache.""" - domain_data = hass.data[DOMAIN] - discoveries: list[FluxLEDDiscovery] = domain_data[FLUX_LED_DISCOVERY] - domain_data[FLUX_LED_DISCOVERY] = [ - discovery for discovery in discoveries if discovery[ATTR_IPADDR] != host + hass.data[FLUX_LED_DISCOVERY] = [ + discovery + for discovery in hass.data[FLUX_LED_DISCOVERY] + if discovery[ATTR_IPADDR] != host ] diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index f9b87dbb8c1..7203bb8a55e 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -1,7 +1,5 @@ """Support for Magic Home lights.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 4433ea20962..7a765ed86bc 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -1,7 +1,5 @@ """Support for Magic Home lights.""" -from __future__ import annotations - import ast import logging from typing import Any, Final diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index edf6b8c9654..efbc6e21202 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -1,7 +1,5 @@ """Support for LED numbers.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Coroutine import logging diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index bcb44c995b8..f7c3b8396a4 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -1,7 +1,5 @@ """Support for Magic Home select.""" -from __future__ import annotations - import asyncio from flux_led.aio import AIOWifiLedBulb @@ -53,7 +51,7 @@ async def async_setup_entry( entry.data.get(CONF_NAME, entry.title) base_unique_id = entry.unique_id or entry.entry_id - if device.device_type == DeviceType.Switch: + if device.device_type is DeviceType.Switch: entities.append(FluxPowerStateSelect(coordinator.device, entry)) if device.operating_modes: entities.append( diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index ad4b9bacbbe..b926fe1d139 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -1,7 +1,5 @@ """Support for Magic Home sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index 5dea5408c84..513b0c86df5 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -1,7 +1,5 @@ """Support for Magic Home switches.""" -from __future__ import annotations - from typing import Any from flux_led import DeviceType @@ -34,7 +32,7 @@ async def async_setup_entry( entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = [] base_unique_id = entry.unique_id or entry.entry_id - if coordinator.device.device_type == DeviceType.Switch: + if coordinator.device.device_type is DeviceType.Switch: entities.append(FluxSwitch(coordinator, base_unique_id, None)) if entry.data.get(CONF_REMOTE_ACCESS_HOST): diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py index 3ccade0b9d2..18f7c8fa9a5 100644 --- a/homeassistant/components/flux_led/util.py +++ b/homeassistant/components/flux_led/util.py @@ -1,7 +1,5 @@ """Utils for Magic Home.""" -from __future__ import annotations - from flux_led.aio import AIOWifiLedBulb from flux_led.const import COLOR_MODE_DIM as FLUX_COLOR_MODE_DIM, MultiColorEffects diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 4667a6c348d..ac53c9f1a88 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -1,7 +1,5 @@ """Sensor for monitoring the contents of a folder.""" -from __future__ import annotations - from datetime import timedelta import glob import logging diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index dd56b3aad72..ca8d8e3dddd 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -1,7 +1,5 @@ """Component for monitoring activity on a folder.""" -from __future__ import annotations - import logging import os from typing import cast diff --git a/homeassistant/components/folder_watcher/config_flow.py b/homeassistant/components/folder_watcher/config_flow.py index eb176cfaf24..5b065800b0e 100644 --- a/homeassistant/components/folder_watcher/config_flow.py +++ b/homeassistant/components/folder_watcher/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Folder watcher.""" -from __future__ import annotations - from collections.abc import Mapping import os from typing import Any diff --git a/homeassistant/components/folder_watcher/event.py b/homeassistant/components/folder_watcher/event.py index 472599c4ead..1c186e5cb92 100644 --- a/homeassistant/components/folder_watcher/event.py +++ b/homeassistant/components/folder_watcher/event.py @@ -1,7 +1,5 @@ """Support for Folder watcher event entities.""" -from __future__ import annotations - from typing import Any from watchdog.events import ( diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index f3c6513f051..f77e9064c6f 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,7 +1,5 @@ """Support for the Foobot indoor air quality monitor.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index a684b766b61..bdf1700009f 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -1,7 +1,5 @@ """The Forecast.Solar integration.""" -from __future__ import annotations - from types import MappingProxyType from homeassistant.config_entries import ConfigSubentry diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 7fb1bde2d5a..54378fa778c 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Forecast.Solar integration.""" -from __future__ import annotations - import re from typing import Any @@ -15,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, selector @@ -94,7 +92,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" if user_input is not None: return self.async_create_entry( - title=user_input[CONF_NAME], + title="", data={ CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], @@ -107,7 +105,11 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): CONF_AZIMUTH: user_input[CONF_AZIMUTH], CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], }, - "title": f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + "title": ( + f"{user_input[CONF_DECLINATION]}°" + f" / {user_input[CONF_AZIMUTH]}°" + f" / {user_input[CONF_MODULES_POWER]}W" + ), "unique_id": None, }, ], @@ -118,13 +120,11 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_NAME): str, vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, } ).extend(PLANE_SCHEMA.schema), { - CONF_NAME: self.hass.config.location_name, CONF_LATITUDE: self.hass.config.latitude, CONF_LONGITUDE: self.hass.config.longitude, CONF_DECLINATION: DEFAULT_DECLINATION, @@ -244,7 +244,11 @@ class PlaneSubentryFlowHandler(ConfigSubentryFlow): if user_input is not None: return self.async_create_entry( - title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + title=( + f"{user_input[CONF_DECLINATION]}°" + f" / {user_input[CONF_AZIMUTH]}°" + f" / {user_input[CONF_MODULES_POWER]}W" + ), data={ CONF_DECLINATION: user_input[CONF_DECLINATION], CONF_AZIMUTH: user_input[CONF_AZIMUTH], @@ -280,7 +284,11 @@ class PlaneSubentryFlowHandler(ConfigSubentryFlow): CONF_AZIMUTH: user_input[CONF_AZIMUTH], CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], }, - title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + title=( + f"{user_input[CONF_DECLINATION]}°" + f" / {user_input[CONF_AZIMUTH]}°" + f" / {user_input[CONF_MODULES_POWER]}W" + ), ): if not entry.update_listeners: self.hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 22d0794ba7e..66563106451 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -1,7 +1,5 @@ """Constants for the Forecast.Solar integration.""" -from __future__ import annotations - import logging DOMAIN = "forecast_solar" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index 65e699c8f38..514efd48045 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Forecast.Solar integration.""" -from __future__ import annotations - from datetime import timedelta from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError, Plane diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index 80e412dd1a8..4e79eea307b 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Forecast.Solar integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -38,7 +36,9 @@ async def async_get_config_entry_diagnostics( }, "data": { "energy_production_today": coordinator.data.energy_production_today, - "energy_production_today_remaining": coordinator.data.energy_production_today_remaining, + "energy_production_today_remaining": ( + coordinator.data.energy_production_today_remaining + ), "energy_production_tomorrow": coordinator.data.energy_production_tomorrow, "energy_current_hour": coordinator.data.energy_current_hour, "power_production_now": coordinator.data.power_production_now, diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py index 2b99c0d2b3a..e62a9e52165 100644 --- a/homeassistant/components/forecast_solar/energy.py +++ b/homeassistant/components/forecast_solar/energy.py @@ -1,7 +1,5 @@ """Energy platform.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .coordinator import ForecastSolarDataUpdateCoordinator diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 65df6a8828a..70fd07134f8 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==5.0.0"] + "requirements": ["forecast-solar==5.0.1"] } diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 13a4d5c2d23..a18fdffaa2f 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -1,7 +1,5 @@ """Support for the Forecast.Solar sensor service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -27,6 +25,8 @@ from . import ForecastSolarConfigEntry from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 3ed3f146a11..6d0c3b45844 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -7,8 +7,7 @@ "declination": "Declination (0 = Horizontal, 90 = Vertical)", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "modules_power": "Total Watt peak power of your solar modules", - "name": "[%key:common::config_flow::data::name%]" + "modules_power": "Total Watt peak power of your solar modules" }, "description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear." } diff --git a/homeassistant/components/forked_daapd/browse_media.py b/homeassistant/components/forked_daapd/browse_media.py index e6918f9e5d6..c1ba2350886 100644 --- a/homeassistant/components/forked_daapd/browse_media.py +++ b/homeassistant/components/forked_daapd/browse_media.py @@ -1,7 +1,5 @@ """Browse media for forked-daapd.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast @@ -60,11 +58,16 @@ OWNTONE_TYPE_TO_MEDIA_TYPE = { } MEDIA_TYPE_TO_OWNTONE_TYPE = {v: k for k, v in OWNTONE_TYPE_TO_MEDIA_TYPE.items()} -# media_content_id is a uri in the form of SCHEMA:Title:OwnToneURI:Subtype (Subtype only used for Genre) -# OwnToneURI is in format library:type:id (for directories, id is path) -# media_content_type - type of item (mostly used to check if playable or can expand) -# OwnTone type may differ from media_content_type when media_content_type is a directory -# OwnTone type is used in our own branching, but media_content_type is used for determining playability +# media_content_id is a uri in the form of +# SCHEMA:Title:OwnToneURI:Subtype (Subtype only for Genre) +# OwnToneURI is in format library:type:id +# (for directories, id is path) +# media_content_type - type of item +# (mostly used to check if playable or can expand) +# OwnTone type may differ from media_content_type +# when media_content_type is a directory +# OwnTone type is used in our own branching, but +# media_content_type is used for determining playability @dataclass diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 0ba339be505..77c34071530 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -1,7 +1,5 @@ """Support forked_daapd media player.""" -from __future__ import annotations - import asyncio from collections.abc import Sequence import logging diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index eb9d361504d..1565f376c75 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -1,7 +1,5 @@ """Support forked_daapd media player.""" -from __future__ import annotations - import asyncio from collections import defaultdict import logging @@ -350,7 +348,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._queue["count"] >= 1 and self._queue["items"][0]["data_kind"] == "pipe" and self._queue["items"][0]["title"] in KNOWN_PIPES - ): # if we're playing a pipe, set the source automatically so we can forward controls + ): # if playing a pipe, set source to forward controls self._source = f"{self._queue['items'][0]['title']} (pipe)" self._update_track_info() event.set() @@ -470,9 +468,11 @@ class ForkedDaapdMaster(MediaPlayerEntity): return self._player["volume"] == 0 @property - def media_content_id(self): + def media_content_id(self) -> str | None: """Content ID of current playing media.""" - return self._player["item_id"] + if (item_id := self._player["item_id"]) == 0: + return None + return str(item_id) @property def media_content_type(self): diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 3ce6d6e902f..71f6cff421d 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -3,8 +3,6 @@ This FortiOS integration provides a device_tracker platform. """ -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f95650853a0..6c517de5880 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,7 +1,5 @@ """Component providing basic support for Foscam IP cameras.""" -from __future__ import annotations - import asyncio from urllib.parse import quote diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index e25fa9381e0..b11a7a34f02 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -17,7 +17,7 @@ type FoscamConfigEntry = ConfigEntry[FoscamCoordinator] @dataclass class FoscamDeviceInfo: - """A data class representing the current state and configuration of a Foscam camera device.""" + """Represent the current state and config of a Foscam camera.""" dev_info: dict product_info: dict diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py index e9930695a75..8df5fa4321a 100644 --- a/homeassistant/components/foscam/entity.py +++ b/homeassistant/components/foscam/entity.py @@ -1,7 +1,5 @@ """Component providing basic support for Foscam IP cameras.""" -from __future__ import annotations - from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/foscam/number.py b/homeassistant/components/foscam/number.py index a693685c67e..dbbbcd98d20 100644 --- a/homeassistant/components/foscam/number.py +++ b/homeassistant/components/foscam/number.py @@ -1,7 +1,5 @@ """Foscam number platform for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 5a29182cf38..c3212926d4a 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -1,7 +1,5 @@ """Component provides support for the Foscam Switch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -17,14 +15,22 @@ from .entity import FoscamEntity def handle_ir_turn_on(session: FoscamCamera) -> None: - """Turn on IR LED: sets IR mode to auto (if supported), then turns off the IR LED.""" + """Turn on IR LED. + + Sets IR mode to auto (if supported), then turns off + the IR LED. + """ session.set_infra_led_config(1) session.open_infra_led() def handle_ir_turn_off(session: FoscamCamera) -> None: - """Turn off IR LED: sets IR mode to manual (if supported), then turns open the IR LED.""" + """Turn off IR LED. + + Sets IR mode to manual (if supported), then turns + open the IR LED. + """ session.set_infra_led_config(0) session.close_infra_led() diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index 8f6613c5c23..3c0f6e1b72c 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -1,7 +1,5 @@ """Support for Free Mobile SMS platform.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 94ccae61088..b3163741cdc 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -1,19 +1,82 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" from datetime import timedelta +import logging from freebox_api.exceptions import HttpRequestError -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_time_interval -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .router import FreeboxConfigEntry, FreeboxRouter, get_api +_LOGGER = logging.getLogger(__name__) + SCAN_INTERVAL = timedelta(seconds=30) +# Old entity name suffixes that need rewriting to the entity description key. +# Format: (platform, old name suffix, new key) +_STATIC_UNIQUE_ID_MIGRATIONS: tuple[tuple[Platform, str, str], ...] = ( + (Platform.SENSOR, "Freebox download speed", "rate_down"), + (Platform.SENSOR, "Freebox upload speed", "rate_up"), + (Platform.SENSOR, "Freebox missed calls", "missed"), + (Platform.BUTTON, "Reboot Freebox", "reboot"), + (Platform.BUTTON, "Mark calls as read", "mark_calls_as_read"), + (Platform.SWITCH, "Freebox WiFi", "wifi"), +) + + +async def async_migrate_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version < 2: + api = await get_api(hass, entry.data[CONF_HOST]) + try: + await api.open(entry.data[CONF_HOST], entry.data[CONF_PORT]) + freebox_config = await api.system.get_config() + except HttpRequestError: + _LOGGER.warning( + "Unable to migrate Freebox entry to version 2: cannot reach the router" + ) + return False + finally: + await api.close() + + mac: str = freebox_config["mac"] + entity_registry = er.async_get(hass) + + migrations: list[tuple[Platform, str, str]] = [ + (platform, f"{mac} {old_suffix}", f"{mac} {new_key}") + for platform, old_suffix, new_key in _STATIC_UNIQUE_ID_MIGRATIONS + ] + migrations.extend( + ( + Platform.SENSOR, + f"{mac} Freebox {sensor['name']}", + f"{mac} {sensor['id']}", + ) + for sensor in freebox_config.get("sensors", []) + ) + + for platform, old_uid, new_uid in migrations: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, old_uid + ): + entity_registry.async_update_entity(entity_id, new_unique_id=new_uid) + _LOGGER.debug( + "Migrated %s unique_id from %s to %s", + entity_id, + old_uid, + new_uid, + ) + + hass.config_entries.async_update_entry(entry, version=2) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: """Set up Freebox entry.""" diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 968f3dc16a6..9be0b586cd8 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -48,6 +48,7 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): """Representation of a Freebox alarm.""" _attr_code_arm_required = False + _attr_name = None def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize an alarm.""" diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 0952af2b415..1b7f11fe10a 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from __future__ import annotations - import logging from typing import Any @@ -25,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="raid_degraded", - name="degraded", + translation_key="raid_degraded", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -70,7 +68,7 @@ async def async_setup_entry( class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): """Representation of a Freebox binary sensor.""" - _sensor_name = "trigger" + _endpoint_name = "trigger" def __init__( self, @@ -81,9 +79,11 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): """Initialize a Freebox binary sensor.""" super().__init__(router, node, sub_node) self._command_id = self.get_command_id( - node["type"]["endpoints"], "signal", self._sensor_name + node["type"]["endpoints"], "signal", self._endpoint_name + ) + self._attr_is_on = self._edit_state( + self.get_value("signal", self._endpoint_name) ) - self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) async def async_update_signal(self) -> None: """Update name & state.""" @@ -93,10 +93,10 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): await FreeboxHomeEntity.async_update_signal(self) def _edit_state(self, state: bool | None) -> bool | None: - """Edit state depending on sensor name.""" + """Edit state depending on endpoint name.""" if state is None: return None - if self._sensor_name == "trigger": + if self._endpoint_name == "trigger": return not state return state @@ -105,28 +105,35 @@ class FreeboxPirSensor(FreeboxHomeBinarySensor): """Representation of a Freebox motion binary sensor.""" _attr_device_class = BinarySensorDeviceClass.MOTION + _attr_name = None class FreeboxDwsSensor(FreeboxHomeBinarySensor): """Representation of a Freebox door opener binary sensor.""" _attr_device_class = BinarySensorDeviceClass.DOOR + _attr_name = None class FreeboxCoverSensor(FreeboxHomeBinarySensor): - """Representation of a cover Freebox plastic removal cover binary sensor (for some sensors: motion detector, door opener detector...).""" + """Represent a Freebox plastic removal cover sensor. + + Applies to some sensors: motion detector, + door opener detector, etc. + """ _attr_device_class = BinarySensorDeviceClass.SAFETY _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False + _attr_translation_key = "cover" - _sensor_name = "cover" + _endpoint_name = "cover" def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize a cover for another device.""" cover_node = next( filter( - lambda x: x["name"] == self._sensor_name and x["ep_type"] == "signal", + lambda x: x["name"] == self._endpoint_name and x["ep_type"] == "signal", node["type"]["endpoints"], ), None, @@ -151,7 +158,7 @@ class FreeboxRaidDegradedSensor(BinarySensorEntity): self._router = router self._attr_device_info = router.device_info self._raid = raid - self._attr_name = f"Raid array {raid['id']} {description.name}" + self._attr_translation_placeholders = {"id": str(raid["id"])} self._attr_unique_id = ( f"{router.mac} {description.key} {raid['name']} {raid['id']}" ) diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 21a7b1c9990..577400c2597 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -1,7 +1,5 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -27,14 +25,13 @@ class FreeboxButtonEntityDescription(ButtonEntityDescription): BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = ( FreeboxButtonEntityDescription( key="reboot", - name="Reboot Freebox", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, async_press=lambda router: router.reboot(), ), FreeboxButtonEntityDescription( key="mark_calls_as_read", - name="Mark calls as read", + translation_key="mark_calls_as_read", entity_category=EntityCategory.DIAGNOSTIC, async_press=lambda router: router.call.mark_calls_log_as_read(), ), @@ -57,6 +54,7 @@ async def async_setup_entry( class FreeboxButton(ButtonEntity): """Representation of a Freebox button.""" + _attr_has_entity_name = True entity_description: FreeboxButtonEntityDescription def __init__( @@ -66,7 +64,7 @@ class FreeboxButton(ButtonEntity): self.entity_description = description self._router = router self._attr_device_info = router.device_info - self._attr_unique_id = f"{router.mac} {description.name}" + self._attr_unique_id = f"{router.mac} {description.key}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index af816b31024..74541db7c90 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -1,19 +1,16 @@ """Support for Freebox cameras.""" -from __future__ import annotations - -import logging from typing import Any -from homeassistant.components.camera import CameraEntityFeature -from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT -from homeassistant.components.ffmpeg.camera import ( # pylint: disable=hass-component-root-import - DEFAULT_ARGUMENTS, - FFmpegCamera, -) -from homeassistant.const import CONF_NAME +from aiohttp import web +from haffmpeg.camera import CameraMjpeg +from haffmpeg.tools import IMAGE_JPEG + +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.ffmpeg import DATA_FFMPEG, FFmpegManager, async_get_image from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +18,7 @@ from .const import ATTR_DETECTION, FreeboxHomeCategory from .entity import FreeboxHomeEntity from .router import FreeboxConfigEntry, FreeboxRouter -_LOGGER = logging.getLogger(__name__) +_FFMPEG_ARGUMENTS = "-pred 1" async def async_setup_entry( @@ -65,25 +62,21 @@ def add_entities( async_add_entities(new_tracked, True) -class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): +class FreeboxCamera(FreeboxHomeEntity, Camera): """Representation of a Freebox camera.""" + _attr_name = None + _attr_supported_features = CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM + def __init__( self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] ) -> None: """Initialize a camera.""" - super().__init__(router, node) - device_info = { - CONF_NAME: node["label"].strip(), - CONF_INPUT: node["props"]["Stream"], - CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, - } - FFmpegCamera.__init__(self, hass, device_info) + Camera.__init__(self) - self._supported_features = ( - CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM - ) + self._ffmpeg: FFmpegManager = hass.data[DATA_FFMPEG] + self._input: str = node["props"]["Stream"] self._command_motion_detection = self.get_command_id( node["type"]["endpoints"], "slot", ATTR_DETECTION @@ -91,6 +84,39 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): self._attr_extra_state_attributes = {} self.update_node(node) + async def stream_source(self) -> str: + """Return the stream source.""" + return self._input.split(" ")[-1] + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + return await async_get_image( + self.hass, + self._input, + output_format=IMAGE_JPEG, + extra_cmd=_FFMPEG_ARGUMENTS, + ) + + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse: + """Generate an HTTP MJPEG stream from the camera.""" + stream = CameraMjpeg(self._ffmpeg.binary) + await stream.open_camera(self._input, extra_cmd=_FFMPEG_ARGUMENTS) + + try: + stream_reader = await stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._ffmpeg.ffmpeg_stream_content_type, + ) + finally: + await stream.close() + async def async_enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" if await self.set_home_endpoint_value(self._command_motion_detection, True): @@ -104,25 +130,17 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): async def async_update_signal(self) -> None: """Update the camera node.""" self.update_node(self._router.home_devices[self._id]) - self.async_write_ha_state() + await super().async_update_signal() def update_node(self, node: dict[str, Any]) -> None: """Update params.""" - self._name = node["label"].strip() + self._attr_is_streaming = node["status"] == "active" - # Get status - if self._node["status"] == "active": - self._attr_is_streaming = True - else: - self._attr_is_streaming = False - - # Parse all endpoints values for endpoint in filter( lambda x: x["ep_type"] == "signal", node["show_endpoints"] ): self._attr_extra_state_attributes[endpoint["name"]] = endpoint["value"] - # Get motion detection status self._attr_motion_detection_enabled = self._attr_extra_state_attributes[ ATTR_DETECTION ] diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 7ca26f7f34e..31dffebdea3 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" @@ -44,6 +44,8 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN): self._data = user_input # Check if already configured + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=home-assistant-unique-id-ip-based await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index da5ae836be0..02101eafe42 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -1,7 +1,5 @@ """Freebox component constants.""" -from __future__ import annotations - import enum import socket diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 243f0de315a..67c57eefee1 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -1,7 +1,5 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from __future__ import annotations - from datetime import datetime from typing import Any @@ -57,6 +55,7 @@ def add_entities( class FreeboxDevice(ScannerEntity): """Representation of a Freebox device.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__(self, router: FreeboxRouter, device: dict[str, Any]) -> None: diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index e29ffb071e9..ca4e6268668 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -1,10 +1,9 @@ """Support for Freebox base features.""" -from __future__ import annotations - import logging from typing import Any +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -18,6 +17,8 @@ _LOGGER = logging.getLogger(__name__) class FreeboxHomeEntity(Entity): """Representation of a Freebox base entity.""" + _attr_has_entity_name = True + def __init__( self, router: FreeboxRouter, @@ -29,12 +30,9 @@ class FreeboxHomeEntity(Entity): self._node = node self._sub_node = sub_node self._id = node["id"] - self._attr_name = node["label"].strip() - self._device_name = self._attr_name self._attr_unique_id = f"{self._router.mac}-node_{self._id}" if sub_node is not None: - self._attr_name += " " + sub_node["label"].strip() self._attr_unique_id += "-" + sub_node["name"].strip() self._available = True @@ -54,7 +52,7 @@ class FreeboxHomeEntity(Entity): identifiers={(DOMAIN, self._id)}, manufacturer=self._manufacturer, model=self._model, - name=self._device_name, + name=node["label"].strip(), sw_version=self._firmware, via_device=(DOMAIN, router.mac), ) @@ -62,13 +60,13 @@ class FreeboxHomeEntity(Entity): async def async_update_signal(self) -> None: """Update signal.""" self._node = self._router.home_devices[self._id] - # Update name - if self._sub_node is None: - self._attr_name = self._node["label"].strip() - else: - self._attr_name = ( - self._node["label"].strip() + " " + self._sub_node["label"].strip() - ) + # Propagate Freebox device label changes to the device registry so + # the entity stays in sync when users rename it on the Freebox app. + device_registry = dr.async_get(self.hass) + if device := device_registry.async_get_device(identifiers={(DOMAIN, self._id)}): + new_name = self._node["label"].strip() + if device.name != new_name: + device_registry.async_update_device(device.id, name=new_name) self.async_write_ha_state() async def set_home_endpoint_value( diff --git a/homeassistant/components/freebox/icons.json b/homeassistant/components/freebox/icons.json index f4184f0673e..7dbad87389c 100644 --- a/homeassistant/components/freebox/icons.json +++ b/homeassistant/components/freebox/icons.json @@ -1,7 +1,26 @@ { - "services": { - "reboot": { - "service": "mdi:restart" + "entity": { + "sensor": { + "missed": { + "default": "mdi:phone-missed" + }, + "partition_free_space": { + "default": "mdi:harddisk" + }, + "rate_down": { + "default": "mdi:download-network" + }, + "rate_up": { + "default": "mdi:upload-network" + } + }, + "switch": { + "wifi": { + "default": "mdi:wifi", + "state": { + "off": "mdi:wifi-off" + } + } } } } diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 50c1ea96d9a..0558be9d471 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -1,13 +1,13 @@ { "domain": "freebox", "name": "Freebox", - "codeowners": ["@hacf-fr", "@Quentame"], + "codeowners": ["@hacf-fr/reviewers", "@Quentame"], "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/freebox", "integration_type": "device", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.3.0"], + "requirements": ["freebox-api==1.3.1"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index b2eb329b545..1df4422ac29 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -1,7 +1,5 @@ """Represent the Freebox router and its devices and sensors.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from contextlib import suppress from datetime import datetime @@ -127,6 +125,7 @@ class FreeboxRouter: self.supports_raid = True self.raids: dict[int, dict[str, Any]] = {} self.sensors_temperature: dict[str, int] = {} + self.sensors_temperature_names: dict[str, str] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] self.home_granted = True @@ -145,7 +144,8 @@ class FreeboxRouter: fbx_devices: list[dict[str, Any]] = [] - # Access to Host list not available in bridge mode, API return error_code 'nodev' + # Access to Host list not available in bridge mode, + # API return error_code 'nodev' if self.supports_hosts: self.supports_hosts, fbx_devices = await get_hosts_list_if_supported( self._api @@ -182,10 +182,13 @@ class FreeboxRouter: # System sensors syst_datas: dict[str, Any] = await self._api.system.get_config() - # According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree. + # According to the doc `syst_datas["sensors"]` is + # temperature sensors in celsius degree. # Name and id of sensors may vary under Freebox devices. for sensor in syst_datas["sensors"]: - self.sensors_temperature[sensor["name"]] = sensor.get("value") + sensor_id = sensor["id"] + self.sensors_temperature[sensor_id] = sensor.get("value") + self.sensors_temperature_names[sensor_id] = sensor["name"] # Connection sensors connection_datas: dict[str, Any] = await self._api.connection.get_status() diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 53314549f57..734dd006478 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -1,7 +1,5 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from __future__ import annotations - import logging from typing import Any @@ -11,7 +9,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfDataRate, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -27,36 +30,36 @@ _LOGGER = logging.getLogger(__name__) CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="rate_down", - name="Freebox download speed", + translation_key="rate_down", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, - icon="mdi:download-network", ), SensorEntityDescription( key="rate_up", - name="Freebox upload speed", + translation_key="rate_up", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, - icon="mdi:upload-network", ), ) CALL_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="missed", - name="Freebox missed calls", - icon="mdi:phone-missed", + translation_key="missed", + native_unit_of_measurement="calls", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ) DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="partition_free_space", - name="free space", + translation_key="partition_free_space", native_unit_of_measurement=PERCENTAGE, - icon="mdi:harddisk", + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -79,14 +82,15 @@ async def async_setup_entry( FreeboxSensor( router, SensorEntityDescription( - key=sensor_name, - name=f"Freebox {sensor_name}", + key=sensor_id, + name=sensor_name, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ) - for sensor_name in router.sensors_temperature + for sensor_id, sensor_name in router.sensors_temperature_names.items() ] entities.extend( @@ -123,6 +127,7 @@ class FreeboxSensor(SensorEntity): """Representation of a Freebox sensor.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, router: FreeboxRouter, description: SensorEntityDescription @@ -130,7 +135,7 @@ class FreeboxSensor(SensorEntity): """Initialize a Freebox sensor.""" self.entity_description = description self._router = router - self._attr_unique_id = f"{router.mac} {description.name}" + self._attr_unique_id = f"{router.mac} {description.key}" self._attr_device_info = router.device_info @callback @@ -206,7 +211,7 @@ class FreeboxDiskSensor(FreeboxSensor): super().__init__(router, description) self._disk_id = disk["id"] self._partition_id = partition["id"] - self._attr_name = f"{partition['label']} {description.name}" + self._attr_translation_placeholders = {"partition": partition["label"]} self._attr_unique_id = ( f"{router.mac} {description.key} {disk['id']} {partition['id']}" ) diff --git a/homeassistant/components/freebox/services.yaml b/homeassistant/components/freebox/services.yaml deleted file mode 100644 index 8ba6f278bfa..00000000000 --- a/homeassistant/components/freebox/services.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Freebox service entries description. - -reboot: diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 12ca866278a..5fde95a937f 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -25,10 +25,38 @@ } } }, - "services": { - "reboot": { - "description": "Reboots the Freebox.", - "name": "Reboot" + "entity": { + "binary_sensor": { + "cover": { + "name": "Cover" + }, + "raid_degraded": { + "name": "RAID array {id} degraded" + } + }, + "button": { + "mark_calls_as_read": { + "name": "Mark calls as read" + } + }, + "sensor": { + "missed": { + "name": "Missed calls" + }, + "partition_free_space": { + "name": "{partition} free space" + }, + "rate_down": { + "name": "Download speed" + }, + "rate_up": { + "name": "Upload speed" + } + }, + "switch": { + "wifi": { + "name": "Wi-Fi" + } } } } diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 9506a87b5fa..dc59656d9f1 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -1,14 +1,11 @@ """Support for Freebox Delta, Revolution and Mini 4K.""" -from __future__ import annotations - import logging from typing import Any from freebox_api.exceptions import InsufficientPermissionsError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,8 +17,7 @@ _LOGGER = logging.getLogger(__name__) SWITCH_DESCRIPTIONS = [ SwitchEntityDescription( key="wifi", - name="Freebox WiFi", - entity_category=EntityCategory.CONFIG, + translation_key="wifi", ) ] @@ -43,6 +39,8 @@ async def async_setup_entry( class FreeboxSwitch(SwitchEntity): """Representation of a freebox switch.""" + _attr_has_entity_name = True + def __init__( self, router: FreeboxRouter, entity_description: SwitchEntityDescription ) -> None: @@ -50,7 +48,7 @@ class FreeboxSwitch(SwitchEntity): self.entity_description = entity_description self._router = router self._attr_device_info = router.device_info - self._attr_unique_id = f"{router.mac} {entity_description.name}" + self._attr_unique_id = f"{router.mac} {entity_description.key}" async def _async_set_state(self, enabled: bool) -> None: """Turn the switch on or off.""" diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index 9ce7701216c..e40c16c357e 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -1,7 +1,5 @@ """Support for freedompro.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 4e4660bc545..b22f4d3ff90 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -1,7 +1,5 @@ """Support for Freedompro climate.""" -from __future__ import annotations - import json import logging from typing import Any diff --git a/homeassistant/components/freedompro/coordinator.py b/homeassistant/components/freedompro/coordinator.py index 23b181b2655..aab60dc6a0b 100644 --- a/homeassistant/components/freedompro/coordinator.py +++ b/homeassistant/components/freedompro/coordinator.py @@ -1,7 +1,5 @@ """Freedompro data update coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index c65afb3a0e2..b1dddd87d29 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -1,7 +1,5 @@ """Support for Freedompro fan.""" -from __future__ import annotations - import json from typing import Any diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index f9d90420c5d..0f738c2afb5 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -1,7 +1,5 @@ """Support for Freedompro light.""" -from __future__ import annotations - import json from typing import Any diff --git a/homeassistant/components/freshr/config_flow.py b/homeassistant/components/freshr/config_flow.py index 90c5dd21420..0ea91819003 100644 --- a/homeassistant/components/freshr/config_flow.py +++ b/homeassistant/components/freshr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Fresh-r integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py index 133e1f03f11..b5e9e633dd7 100644 --- a/homeassistant/components/freshr/coordinator.py +++ b/homeassistant/components/freshr/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta from aiohttp import ClientError from pyfreshr import FreshrClient from pyfreshr.exceptions import ApiResponseError, LoginError -from pyfreshr.models import DeviceReadings, DeviceSummary +from pyfreshr.models import DeviceReadings, DeviceSummary, DeviceType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -18,6 +18,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +_DEVICE_TYPE_NAMES: dict[DeviceType, str] = { + DeviceType.FRESH_R: "Fresh-r", + DeviceType.FORWARD: "Fresh-r Forward", + DeviceType.MONITOR: "Fresh-r Monitor", +} + DEVICES_SCAN_INTERVAL = timedelta(hours=1) READINGS_SCAN_INTERVAL = timedelta(minutes=10) @@ -110,6 +116,12 @@ class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]): ) self._device = device self._client = client + self.device_info = dr.DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), + serial_number=device.id, + manufacturer="Fresh-r", + ) @property def device_id(self) -> str: diff --git a/homeassistant/components/freshr/diagnostics.py b/homeassistant/components/freshr/diagnostics.py index a3f37a9f5cb..dee204b12dd 100644 --- a/homeassistant/components/freshr/diagnostics.py +++ b/homeassistant/components/freshr/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Fresh-r.""" -from __future__ import annotations - import dataclasses from typing import Any diff --git a/homeassistant/components/freshr/entity.py b/homeassistant/components/freshr/entity.py new file mode 100644 index 00000000000..a5412dfd82a --- /dev/null +++ b/homeassistant/components/freshr/entity.py @@ -0,0 +1,16 @@ +"""Base entity for the Fresh-r integration.""" + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import FreshrReadingsCoordinator + + +class FreshrEntity(CoordinatorEntity[FreshrReadingsCoordinator]): + """Base class for Fresh-r entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FreshrReadingsCoordinator) -> None: + """Initialize the Fresh-r entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/freshr/manifest.json b/homeassistant/components/freshr/manifest.json index 7f5d2ab81ac..0dad2dd7cb2 100644 --- a/homeassistant/components/freshr/manifest.json +++ b/homeassistant/components/freshr/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/freshr", "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pyfreshr==1.2.0"] } diff --git a/homeassistant/components/freshr/sensor.py b/homeassistant/components/freshr/sensor.py index a943ecacabb..f1e809f6038 100644 --- a/homeassistant/components/freshr/sensor.py +++ b/homeassistant/components/freshr/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Fresh-r integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -21,12 +19,10 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator +from .entity import FreshrEntity PARALLEL_UPDATES = 0 @@ -93,12 +89,6 @@ _TEMP = FreshrSensorEntityDescription( value_fn=lambda r: r.temp, ) -_DEVICE_TYPE_NAMES: dict[DeviceType, str] = { - DeviceType.FRESH_R: "Fresh-r", - DeviceType.FORWARD: "Fresh-r Forward", - DeviceType.MONITOR: "Fresh-r Monitor", -} - SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = { DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP), DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP), @@ -131,17 +121,10 @@ async def async_setup_entry( descriptions = SENSOR_TYPES.get( device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] ) - device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), - serial_number=device_id, - manufacturer="Fresh-r", - ) entities.extend( FreshrSensor( config_entry.runtime_data.readings[device_id], description, - device_info, ) for description in descriptions ) @@ -151,22 +134,19 @@ async def async_setup_entry( config_entry.async_on_unload(coordinator.async_add_listener(_check_devices)) -class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity): +class FreshrSensor(FreshrEntity, SensorEntity): """Representation of a Fresh-r sensor.""" - _attr_has_entity_name = True entity_description: FreshrSensorEntityDescription def __init__( self, coordinator: FreshrReadingsCoordinator, description: FreshrSensorEntityDescription, - device_info: DeviceInfo, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_device_info = device_info self._attr_unique_id = f"{coordinator.device_id}_{description.key}" @property diff --git a/homeassistant/components/fressnapf_tracker/__init__.py b/homeassistant/components/fressnapf_tracker/__init__.py index 91c97f4fcd9..98a679b15de 100644 --- a/homeassistant/components/fressnapf_tracker/__init__.py +++ b/homeassistant/components/fressnapf_tracker/__init__.py @@ -38,7 +38,8 @@ _LOGGER = logging.getLogger(__name__) async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None: """Test if the tracker returns valid data and return it. - Malformed data might indicate the tracker is broken or hasn't been properly registered with the app. + Malformed data might indicate the tracker is broken or + hasn't been properly registered with the app. """ client = ApiClient( serial_number=device.serialnumber, diff --git a/homeassistant/components/fressnapf_tracker/device_tracker.py b/homeassistant/components/fressnapf_tracker/device_tracker.py index 35f3ce30664..bd1535f19ed 100644 --- a/homeassistant/components/fressnapf_tracker/device_tracker.py +++ b/homeassistant/components/fressnapf_tracker/device_tracker.py @@ -1,7 +1,6 @@ """Device tracker platform for fressnapf_tracker.""" -from homeassistant.components.device_tracker import SourceType -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -61,11 +60,6 @@ class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity): return self.coordinator.data.position.lng return None - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - @property def location_accuracy(self) -> float: """Return the location accuracy of the device. diff --git a/homeassistant/components/fressnapf_tracker/light.py b/homeassistant/components/fressnapf_tracker/light.py index 363a41ad1ae..7b2d8ddabb9 100644 --- a/homeassistant/components/fressnapf_tracker/light.py +++ b/homeassistant/components/fressnapf_tracker/light.py @@ -57,7 +57,7 @@ class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity): if TYPE_CHECKING: # The entity is not created if led_brightness_value is None assert self.coordinator.data.led_brightness_value is not None - return int(round((self.coordinator.data.led_brightness_value / 100) * 255)) + return round((self.coordinator.data.led_brightness_value / 100) * 255) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" diff --git a/homeassistant/components/fressnapf_tracker/quality_scale.yaml b/homeassistant/components/fressnapf_tracker/quality_scale.yaml index 39614e94b66..f1500b496df 100644 --- a/homeassistant/components/fressnapf_tracker/quality_scale.yaml +++ b/homeassistant/components/fressnapf_tracker/quality_scale.yaml @@ -63,7 +63,7 @@ rules: comment: | This integration does not have many entities. All of them are fundamental. entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: done repair-issues: todo diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 69be911f8f1..74da4a88120 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -54,27 +54,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo ), ) + hass.data.setdefault(FRITZ_DATA_KEY, FritzData()) + try: await avm_wrapper.async_setup(entry.options) except FRITZ_AUTH_EXCEPTIONS as ex: raise ConfigEntryAuthFailed from ex except FRITZ_EXCEPTIONS as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="error_connecting", + translation_placeholders={"error": str(ex)}, + ) from ex if ( "X_AVM-DE_UPnP1" in avm_wrapper.connection.services and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"] ): - raise ConfigEntryAuthFailed("Missing UPnP configuration") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="error_upnp_disabled", + ) await avm_wrapper.async_config_entry_first_refresh() - await avm_wrapper.async_trigger_cleanup() entry.runtime_data = avm_wrapper - if FRITZ_DATA_KEY not in hass.data: - hass.data[FRITZ_DATA_KEY] = FritzData() - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -89,6 +94,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo if avm_wrapper.unique_id in fritz_data.tracked: fritz_data.tracked.pop(avm_wrapper.unique_id) + fritz_data.profile_switches.pop(avm_wrapper.unique_id) + fritz_data.wol_buttons.pop(avm_wrapper.unique_id) if not bool(fritz_data.tracked): hass.data.pop(FRITZ_DATA_KEY) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 0bc772db5a4..6dd6a864886 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -1,7 +1,5 @@ """AVM FRITZ!Box connectivity sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 25053abaca3..7fdb0ae9e5c 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -1,7 +1,5 @@ """Switches for AVM Fritz!Box buttons.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -45,6 +43,7 @@ BUTTONS: Final = [ device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(), + entity_registry_enabled_default=False, ), FritzButtonDescription( key="reboot", @@ -96,6 +95,33 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: ) +def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: + """Repair issue for firmware update button.""" + entity_registry = er.async_get(hass) + + if ( + ( + entity_button := entity_registry.async_get_entity_id( + "button", DOMAIN, f"{avm_wrapper.unique_id}-firmware_update" + ) + ) + and (entity_entry := entity_registry.async_get(entity_button)) + and not entity_entry.disabled + ): + # Deprecate the 'firmware update' button: create a Repairs issue for users + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id="deprecated_firmware_update_button", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_firmware_update_button", + translation_placeholders={"removal_version": "2026.11.0"}, + breaks_in_ha_version="2026.11.0", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, @@ -112,6 +138,7 @@ async def async_setup_entry( if avm_wrapper.mesh_role == MeshRoles.SLAVE: async_add_entities(entities_list) repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) return data_fritz = hass.data[FRITZ_DATA_KEY] @@ -131,6 +158,7 @@ async def async_setup_entry( ) repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) class FritzButton(ButtonEntity): @@ -160,9 +188,21 @@ class FritzButton(ButtonEntity): """Triggers Fritz!Box service.""" if self.entity_description.key == "cleanup": _LOGGER.warning( - "The 'cleanup' button is deprecated and will be removed in Home Assistant Core 2026.11.0. " - "Please update your automations and dashboards to remove any usage of this button. " - "The action is now performed automatically at each data refresh", + "The 'cleanup' button is deprecated and will" + " be removed in Home Assistant Core" + " 2026.11.0. Please update your automations" + " and dashboards to remove any usage of" + " this button. The action is now performed" + " automatically at each data refresh", + ) + elif self.entity_description.key == "firmware_update": + _LOGGER.warning( + "The 'firmware update' button is deprecated" + " and will be removed in Home Assistant" + " Core 2026.11.0. It has been superseded" + " by an update entity. Please update your" + " automations and dashboards to remove" + " any usage of this button", ) await self.entity_description.press_action(self.avm_wrapper) @@ -177,9 +217,6 @@ def _async_wol_buttons_list( new_wols: list[FritzBoxWOLButton] = [] - if avm_wrapper.unique_id not in data_fritz.wol_buttons: - data_fritz.wol_buttons[avm_wrapper.unique_id] = set() - for mac, device in avm_wrapper.devices.items(): if _is_tracked(mac, data_fritz.wol_buttons.values()): _LOGGER.debug("Skipping wol button creation for device %s", device.hostname) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index cd8dda57402..7467bae1209 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the FRITZ!Box Tools integration.""" -from __future__ import annotations - from collections.abc import Mapping import ipaddress import logging @@ -36,7 +34,6 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) -from homeassistant.helpers.typing import VolDictType from .const import ( CONF_FEATURE_DEVICE_TRACKING, @@ -198,7 +195,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._host == self._host # noqa: SLF001 + return other_flow._host == self._host async def async_step_confirm( self, user_input: dict[str, Any] | None = None @@ -227,19 +224,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self, errors: dict[str, str] | None = None ) -> ConfigFlowResult: """Show the setup form to the user.""" - - advanced_data_schema: VolDictType = {} - if self.show_advanced_options: - advanced_data_schema = { - vol.Optional(CONF_PORT): vol.Coerce(int), - } - return self.async_show_form( step_id="user", data_schema=vol.Schema( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, - **advanced_data_schema, + vol.Optional(CONF_PORT): vol.Coerce(int), vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, @@ -359,18 +349,14 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any], errors: dict[str, str] | None = None ) -> ConfigFlowResult: """Show the reconfigure form to the user.""" - advanced_data_schema: VolDictType = {} - if self.show_advanced_options: - advanced_data_schema = { - vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(int), - } - return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( { vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, - **advanced_data_schema, + vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce( + int + ), vol.Required(CONF_SSL, default=user_input[CONF_SSL]): bool, } ), @@ -384,11 +370,23 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reconfigure flow.""" if user_input is None: reconfigure_entry_data = self._get_reconfigure_entry().data + port = reconfigure_entry_data[CONF_PORT] + ssl = reconfigure_entry_data.get(CONF_SSL, DEFAULT_SSL) + + if (port == DEFAULT_HTTP_PORT and not ssl) or ( + port == DEFAULT_HTTPS_PORT and ssl + ): + # don't show default ports in reconfigure flow, + # as they are determined by ssl value + # this allows the user to toggle ssl + # without having to change the port + port = vol.UNDEFINED + return self._show_setup_form_reconfigure( { CONF_HOST: reconfigure_entry_data[CONF_HOST], - CONF_PORT: reconfigure_entry_data[CONF_PORT], - CONF_SSL: reconfigure_entry_data.get(CONF_SSL, DEFAULT_SSL), + CONF_PORT: port, + CONF_SSL: ssl, } ) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 032efb3f4ae..5050907c1d8 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork" BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 55743af5e83..005d85e9e1c 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!Box classes.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass, field @@ -10,6 +8,7 @@ from functools import partial import logging import re from typing import Any, TypedDict, cast +from xml.etree.ElementTree import ParseError from fritzconnection import FritzConnection from fritzconnection.core.exceptions import FritzActionError @@ -26,7 +25,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -188,6 +187,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self._options = options await self.hass.async_add_executor_job(self.setup) + self.hass.data[FRITZ_DATA_KEY].tracked[self.unique_id] = set() + self.hass.data[FRITZ_DATA_KEY].profile_switches[self.unique_id] = set() + self.hass.data[FRITZ_DATA_KEY].wol_buttons[self.unique_id] = set() + device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, @@ -228,7 +231,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection) self.fritz_status = FritzStatus(fc=self.connection) self.fritz_call = FritzCall(fc=self.connection) - info = self.fritz_status.get_device_info() + try: + info = self.fritz_status.get_device_info() + except ParseError as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="error_parse_device_info", + ) from ex _LOGGER.debug( "gathered device info of %s %s", @@ -453,10 +462,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): if not attributes.get("MACAddress"): continue + wan_access_result = None if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None: - wan_access_result = "granted" in wan_access - else: - wan_access_result = None + # wan_access can be "granted", "denied", "unknown" or "error" + if "granted" in wan_access: + wan_access_result = True + elif "denied" in wan_access: + wan_access_result = False hosts[attributes["MACAddress"]] = Device( name=attributes["HostName"], @@ -692,7 +704,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): _LOGGER.debug("Device tracker cleanup triggered") device_hosts = {self.mac: Device(True, "", "", "", "", None)} if self.device_discovery_enabled: - device_hosts = await self._async_update_hosts_info() + device_hosts.update(await self._async_update_hosts_info()) entity_reg: er.EntityRegistry = er.async_get(self.hass) config_entry = self.config_entry @@ -707,6 +719,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) and entry_mac not in device_hosts: _LOGGER.debug("Removing orphan entity entry %s", entity.entity_id) entity_reg.async_remove(entity.entity_id) + self._devices.pop(entry_mac, None) device_reg = dr.async_get(self.hass) valid_connections = { @@ -721,6 +734,29 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device.id, remove_config_entry_id=config_entry.entry_id ) + fritz_data = self.hass.data[FRITZ_DATA_KEY] + + tracked = fritz_data.tracked.get(self.unique_id, set()) + for mac in tracked.copy(): + if mac in device_hosts: + continue + _LOGGER.debug("Removing orphan mac address %s from device trackers", mac) + tracked.remove(mac) + + profile_switches = fritz_data.profile_switches.get(self.unique_id, set()) + for mac in profile_switches.copy(): + if mac in device_hosts: + continue + _LOGGER.debug("Removing orphan mac address %s from profile switches", mac) + profile_switches.remove(mac) + + wol_buttons = fritz_data.wol_buttons.get(self.unique_id, set()) + for mac in wol_buttons.copy(): + if mac in device_hosts: + continue + _LOGGER.debug("Removing orphan mac address %s from WOL buttons", mac) + wol_buttons.remove(mac) + class AvmWrapper(FritzBoxTools): """Setup AVM wrapper for API calls.""" @@ -902,3 +938,15 @@ class AvmWrapper(FritzBoxTools): "X_AVM-DE_WakeOnLANByMACAddress", NewMACAddress=mac_address, ) + + async def async_get_firmware_extra_infos(self) -> dict[str, Any]: + """Return extra infos for firmware.""" + return await self._async_service_call("UserInterface", "1", "X_AVM-DE_GetInfo") + + async def async_get_device_uptime_hours(self) -> int: + """Get device uptime in hours.""" + + def _get_uptime_hours() -> int: + return int(self.fritz_status.device_uptime // 3600) + + return await self.hass.async_add_executor_job(_get_uptime_hours) diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index be8cde57534..26b04a6ea68 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -1,7 +1,5 @@ """Support for FRITZ!Box devices.""" -from __future__ import annotations - import datetime import logging @@ -53,9 +51,6 @@ def _async_add_entities( """Add new tracker entities from the AVM device.""" new_tracked = [] - if avm_wrapper.unique_id not in data_fritz.tracked: - data_fritz.tracked[avm_wrapper.unique_id] = set() - for mac, device in avm_wrapper.devices.items(): if device_filter_out_from_trackers(mac, device, data_fritz.tracked.values()): continue diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index bfeb22e2721..0165e37de0c 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AVM FRITZ!Box.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -26,9 +24,11 @@ async def async_get_config_entry_diagnostics( "unique_id": avm_wrapper.unique_id.replace( avm_wrapper.unique_id[6:11], "XX:XX" ), + "device_uptime_hours": await avm_wrapper.async_get_device_uptime_hours(), "current_firmware": avm_wrapper.current_firmware, "latest_firmware": avm_wrapper.latest_firmware, "update_available": avm_wrapper.update_available, + "firmware_extra_infos": await avm_wrapper.async_get_firmware_extra_infos(), "connection_type": avm_wrapper.device_conn_type, "is_router": avm_wrapper.device_is_router, "mesh_role": avm_wrapper.mesh_role, diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index ade76993972..33179d4c9ea 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -1,7 +1,5 @@ """AVM FRITZ!Tools entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -55,6 +53,8 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): class FritzBoxBaseEntity: """Fritz host entity base class.""" + _attr_has_entity_name = True + def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None: """Init device info class.""" self._avm_wrapper = avm_wrapper diff --git a/homeassistant/components/fritz/helpers.py b/homeassistant/components/fritz/helpers.py index 47f2e462cd8..1d9b24a076e 100644 --- a/homeassistant/components/fritz/helpers.py +++ b/homeassistant/components/fritz/helpers.py @@ -1,7 +1,5 @@ """Helpers for AVM FRITZ!Box.""" -from __future__ import annotations - from collections.abc import ValuesView import logging diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index b752aa2cdcd..e12e5537d56 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -1,7 +1,5 @@ """FRITZ image integration.""" -from __future__ import annotations - from io import BytesIO import logging @@ -78,7 +76,6 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity): _attr_content_type = "image/png" _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True _attr_should_poll = True def __init__( diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 96f73918085..a23c1697456 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["fritzconnection"], "quality_scale": "gold", - "requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"], + "requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/models.py b/homeassistant/components/fritz/models.py index 83bb790dc58..cfb276d34b5 100644 --- a/homeassistant/components/fritz/models.py +++ b/homeassistant/components/fritz/models.py @@ -1,7 +1,5 @@ """Models for AVM FRITZ!Box.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -172,7 +170,6 @@ class SwitchInfo(TypedDict): """FRITZ!Box switch info class.""" description: str - friendly_name: str icon: str type: str callback_update: Callable diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 8aa48b216cb..b2290ade9af 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,13 +1,13 @@ """AVM FRITZ!Box binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging +from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus +from requests.exceptions import RequestException from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .const import DSL_CONNECTION from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .models import ConnectionInfo @@ -38,31 +38,18 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: - """Calculate uptime with deviation.""" - delta_uptime = utcnow() - timedelta(seconds=seconds_uptime) - - if ( - not last_value - or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_value - - def _retrieve_device_uptime_state( - status: FritzStatus, last_value: datetime + status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from device.""" - return _uptime_calculation(status.device_uptime, last_value) + return utcnow() - timedelta(seconds=status.device_uptime) def _retrieve_connection_uptime_state( status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from connection.""" - return _uptime_calculation(status.connection_uptime, last_value) + return utcnow() - timedelta(seconds=status.connection_uptime) def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: @@ -145,46 +132,65 @@ def _retrieve_link_attenuation_received_state( def _retrieve_cpu_temperature_state( status: FritzStatus, last_value: float | None -) -> float: +) -> float | None: """Return the first CPU temperature value.""" - return status.get_cpu_temperatures()[0] # type: ignore[no-any-return] + try: + return status.get_cpu_temperatures()[0] # type: ignore[no-any-return] + except RequestException: + return None + + +def _is_suitable_cpu_temperature(status: FritzStatus) -> bool: + """Return whether the CPU temperature sensor is suitable.""" + try: + cpu_temp = status.get_cpu_temperatures()[0] + except RequestException, IndexError, FritzConnectionException: + _LOGGER.debug("CPU temperature not supported by the device") + return False + if cpu_temp == 0: + _LOGGER.debug("CPU temperature returns 0°C, treating as not supported") + return False + return True @dataclass(frozen=True, kw_only=True) -class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): - """Describes Fritz sensor entity.""" +class FritzConnectionSensorEntityDescription( + SensorEntityDescription, FritzEntityDescription +): + """Describes Fritz connection sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled -SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( - FritzSensorEntityDescription( +@dataclass(frozen=True, kw_only=True) +class FritzDeviceSensorEntityDescription( + SensorEntityDescription, FritzEntityDescription +): + """Describes Fritz device sensor entity.""" + + is_suitable: Callable[[FritzStatus], bool] = lambda status: True + + +CONNECTION_SENSOR_TYPES: tuple[FritzConnectionSensorEntityDescription, ...] = ( + FritzConnectionSensorEntityDescription( key="external_ip", translation_key="external_ip", value_fn=_retrieve_external_ip_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="external_ipv6", translation_key="external_ipv6", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), - FritzSensorEntityDescription( - key="device_uptime", - translation_key="device_uptime", - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=_retrieve_device_uptime_state, - is_suitable=lambda info: True, - ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="connection_uptime", translation_key="connection_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="kb_s_sent", translation_key="kb_s_sent", state_class=SensorStateClass.MEASUREMENT, @@ -192,7 +198,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="kb_s_received", translation_key="kb_s_received", state_class=SensorStateClass.MEASUREMENT, @@ -200,21 +206,21 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="max_kb_s_sent", translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_max_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="max_kb_s_received", translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_max_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="gb_sent", translation_key="gb_sent", state_class=SensorStateClass.TOTAL_INCREASING, @@ -222,7 +228,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DATA_SIZE, value_fn=_retrieve_gb_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="gb_received", translation_key="gb_received", state_class=SensorStateClass.TOTAL_INCREASING, @@ -230,7 +236,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DATA_SIZE, value_fn=_retrieve_gb_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_kb_s_sent", translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -238,7 +244,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_kb_s_received", translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -246,7 +252,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -255,7 +261,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -264,7 +270,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -273,7 +279,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -282,7 +288,16 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( +) + +DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = ( + FritzDeviceSensorEntityDescription( + key="device_uptime", + device_class=SensorDeviceClass.UPTIME, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_device_uptime_state, + ), + FritzDeviceSensorEntityDescription( key="cpu_temperature", translation_key="cpu_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -290,7 +305,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_cpu_temperature_state, - is_suitable=lambda info: True, + is_suitable=_is_suitable_cpu_temperature, ), ) @@ -305,20 +320,32 @@ async def async_setup_entry( avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() - entities = [ FritzBoxSensor(avm_wrapper, entry.title, description) - for description in SENSOR_TYPES + for description in CONNECTION_SENSOR_TYPES if description.is_suitable(connection_info) ] + fritz_status = avm_wrapper.fritz_status + + def _generate_device_sensors() -> list[FritzBoxSensor]: + return [ + FritzBoxSensor(avm_wrapper, entry.title, description) + for description in DEVICE_SENSOR_TYPES + if description.is_suitable(fritz_status) + ] + + entities += await hass.async_add_executor_job(_generate_device_sensors) + async_add_entities(entities) class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" - entity_description: FritzSensorEntityDescription + entity_description: ( + FritzConnectionSensorEntityDescription | FritzDeviceSensorEntityDescription + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 9d7d6b339b2..193463233f9 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -13,7 +13,10 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.service import async_extract_config_entry_ids +from homeassistant.helpers.service import ( + async_extract_config_entry_ids, + async_register_admin_service, +) from .const import DOMAIN from .coordinator import FritzConfigEntry @@ -118,7 +121,8 @@ async def _async_dial(service_call: ServiceCall) -> None: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_GUEST_WIFI_PW, _async_set_guest_wifi_password, diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 22cdd12bd20..fb269118aa2 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -120,9 +120,6 @@ "cpu_temperature": { "name": "CPU temperature" }, - "device_uptime": { - "name": "Last restart" - }, "external_ip": { "name": "External IP" }, @@ -188,9 +185,18 @@ "config_entry_not_found": { "message": "Failed to perform action \"{service}\". Config entry for target not found" }, + "error_connecting": { + "message": "Error connecting to the FRITZ!Box: {error}" + }, + "error_parse_device_info": { + "message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list." + }, "error_refresh_hosts_info": { "message": "Error refreshing hosts info" }, + "error_upnp_disabled": { + "message": "UPnP is disabled on the FRITZ!Box. Please enable UPnP to use this integration." + }, "service_dial_failed": { "message": "Failed to dial, check if the click to dial service of the FRITZ!Box is activated" }, @@ -200,9 +206,6 @@ "service_parameter_unknown": { "message": "Action or parameter unknown" }, - "unable_to_connect": { - "message": "Unable to establish a connection" - }, "update_failed": { "message": "Error while updating the data: {error}" } @@ -211,6 +214,10 @@ "deprecated_cleanup_button": { "description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.", "title": "'Cleanup' button is deprecated" + }, + "deprecated_firmware_update_button": { + "description": "The 'Firmware update' button is deprecated and will be removed in Home Assistant Core {removal_version}. It has been superseded by an update entity. Please update your automations and dashboards to remove any usage of this button.", + "title": "'Firmware update' button is deprecated" } }, "options": { diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index dd91c1a966b..43bb408d741 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -1,7 +1,5 @@ """Switches for AVM Fritz!Box functions.""" -from __future__ import annotations - import logging from typing import Any @@ -51,8 +49,9 @@ def _wifi_naming( """Return a friendly name for a Wi-Fi network.""" if wifi_index == 2 and wifi_count == 4: - # In case of 4 Wi-Fi networks, the 2nd one is used for internal communication - # between mesh devices and should not be named like the others to avoid confusion + # In case of 4 Wi-Fi networks, the 2nd one is used + # for internal communication between mesh devices and + # should not be named like the others to avoid confusion return None if (wifi_index + 1) == wifi_count: @@ -112,7 +111,10 @@ async def _migrate_to_new_unique_id( description += f" ({WIFI_STANDARD[index]})" old_unique_id = f"{avm_wrapper.unique_id}-{slugify(description)}" - new_unique_id = f"{avm_wrapper.unique_id}-wi_fi_{slugify(_wifi_naming(network, index - 1, len(networks)))}" + new_unique_id = ( + f"{avm_wrapper.unique_id}-wi_fi_" + f"{slugify(_wifi_naming(network, index - 1, len(networks)))}" + ) entity_id = entity_registry.async_get_entity_id( Platform.SWITCH, DOMAIN, old_unique_id @@ -240,9 +242,6 @@ async def _async_profile_entities_list( if "X_AVM-DE_HostFilter1" not in avm_wrapper.connection.services: return new_profiles - if avm_wrapper.unique_id not in data_fritz.profile_switches: - data_fritz.profile_switches[avm_wrapper.unique_id] = set() - for mac, device in avm_wrapper.devices.items(): if device_filter_out_from_trackers( mac, device, data_fritz.profile_switches.values() @@ -381,44 +380,18 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity): """Init Fritzbox base switch.""" super().__init__(avm_wrapper, device_friendly_name) - self._description = switch_info["description"] - self._friendly_name = switch_info["friendly_name"] - self._icon = switch_info["icon"] + description = switch_info["description"] + self._type = switch_info["type"] self._update = switch_info["callback_update"] self._switch = switch_info["callback_switch"] + + self._attr_icon = switch_info["icon"] self._attr_is_on = switch_info["init_state"] - - self._name = f"{self._friendly_name} {self._description}" - self._unique_id = f"{self._avm_wrapper.unique_id}-{slugify(self._description)}" - - self._attributes: dict[str, str | None] = {} - self._is_available = True - - @property - def name(self) -> str: - """Return name.""" - return self._name - - @property - def icon(self) -> str: - """Return icon.""" - return self._icon - - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available - - @property - def extra_state_attributes(self) -> dict[str, str | None]: - """Return device attributes.""" - return self._attributes + self._attr_name = description + self._attr_unique_id = f"{self._avm_wrapper.unique_id}-{slugify(description)}" + self._attr_extra_state_attributes: dict[str, Any | None] = {} + self._attr_available = True async def async_update(self) -> None: """Update data.""" @@ -452,17 +425,15 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch): connection_type: str, ) -> None: """Init Fritzbox port switch.""" - self._avm_wrapper = avm_wrapper - - self._attributes = {} self.connection_type = connection_type - self.port_mapping = port_mapping # dict in the format as it comes from fritzconnection. eg: {'NewRemoteHost': '0.0.0.0', 'NewExternalPort': 22, 'NewProtocol': 'TCP', 'NewInternalPort': 22, 'NewInternalClient': '192.168.178.31', 'NewEnabled': True, 'NewPortMappingDescription': 'Beast SSH ', 'NewLeaseDuration': 0} + # dict in the format as it comes from fritzconnection, + # eg: {"NewRemoteHost": "0.0.0.0", "NewExternalPort": 22, ...} + self.port_mapping = port_mapping self._idx = idx # needed for update routine self._attr_entity_category = EntityCategory.CONFIG switch_info = SwitchInfo( description=f"Port forward {port_name}", - friendly_name=device_friendly_name, icon="mdi:check-network", type=SWITCH_TYPE_PORTFORWARD, callback_update=self._async_fetch_update, @@ -481,11 +452,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch): "Specific %s response: %s", SWITCH_TYPE_PORTFORWARD, self.port_mapping ) if not self.port_mapping: - self._is_available = False + self._attr_available = False return self._attr_is_on = self.port_mapping["NewEnabled"] is True - self._is_available = True + self._attr_available = True attributes_dict = { "NewInternalClient": "internal_ip", @@ -496,7 +467,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch): } for key, attr in attributes_dict.items(): - self._attributes[attr] = self.port_mapping[key] + self._attr_extra_state_attributes[attr] = self.port_mapping[key] async def _async_switch_on_off_executor(self, turn_on: bool) -> None: self.port_mapping["NewEnabled"] = "1" if turn_on else "0" @@ -614,10 +585,8 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): network_data: dict[str, Any], ) -> None: """Init Fritz Wifi switch.""" - self._avm_wrapper = avm_wrapper self._wifi_info = network_data - self._attributes = {} self._attr_entity_category = EntityCategory.CONFIG self._attr_entity_registry_enabled_default = ( avm_wrapper.mesh_role is not MeshRoles.SLAVE @@ -629,14 +598,13 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): switch_info = SwitchInfo( description=description, - friendly_name=device_friendly_name, icon="mdi:wifi", type=SWITCH_TYPE_WIFINETWORK, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, init_state=network_data["NewEnable"], ) - super().__init__(self._avm_wrapper, device_friendly_name, switch_info) + super().__init__(avm_wrapper, device_friendly_name, switch_info) async def _async_fetch_update(self) -> None: """Fetch updates.""" @@ -649,16 +617,16 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): ) if not wifi_info: - self._is_available = False + self._attr_available = False return self._attr_is_on = wifi_info["NewEnable"] is True - self._is_available = True + self._attr_available = True std = wifi_info["NewStandard"] - self._attributes["standard"] = std or None - self._attributes["bssid"] = wifi_info["NewBSSID"] - self._attributes["mac_address_control"] = wifi_info[ + self._attr_extra_state_attributes["standard"] = std or None + self._attr_extra_state_attributes["bssid"] = wifi_info["NewBSSID"] + self._attr_extra_state_attributes["mac_address_control"] = wifi_info[ "NewMACAddressControlEnabled" ] self._wifi_info = wifi_info diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 4e54f4c28d3..74ef24090bc 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!Box update platform.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 75bf923c66a..75336ea35e9 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome devices.""" -from __future__ import annotations - from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 9515656d6c1..786c6ec1414 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Fritzbox binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 8ba6fbd5f86..c33d3c7d05d 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome thermostat devices.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 3f66b43cc0c..3c3648e56e5 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -1,7 +1,5 @@ """Config flow for AVM FRITZ!SmartHome.""" -from __future__ import annotations - from collections.abc import Mapping import ipaddress from typing import Any, Self @@ -148,7 +146,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._host == self._host # noqa: SLF001 + return other_flow._host == self._host async def async_step_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 99ab173c21f..9f1de1e7dea 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -1,7 +1,5 @@ """Constants for the AVM FRITZ!SmartHome integration.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 756264f5e35..bc99fb5e7f7 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for AVM FRITZ!SmartHome devices.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta @@ -69,9 +67,15 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat try: await self.hass.async_add_executor_job(self.fritz.login) except RequestConnectionError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connect_error", + ) from err except LoginError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="login_failed", + ) from err self.has_templates = await self.hass.async_add_executor_job( self.fritz.has_templates @@ -190,7 +194,10 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ex, ) self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - raise UpdateFailed from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connect_error_reload", + ) from ex for device in new_data.devices.values(): # create device registry entry for new main devices diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index b315fba8fc6..00dab348bf3 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome cover devices.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( diff --git a/homeassistant/components/fritzbox/diagnostics.py b/homeassistant/components/fritzbox/diagnostics.py index cee4233e458..e888ac73088 100644 --- a/homeassistant/components/fritzbox/diagnostics.py +++ b/homeassistant/components/fritzbox/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AVM Fritz!Smarthome.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/fritzbox/entity.py b/homeassistant/components/fritzbox/entity.py index bbc7d9fe276..3d62bdac653 100644 --- a/homeassistant/components/fritzbox/entity.py +++ b/homeassistant/components/fritzbox/entity.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome devices.""" -from __future__ import annotations - from abc import ABC, abstractmethod from pyfritzhome import FritzhomeDevice @@ -18,6 +16,8 @@ from .coordinator import FritzboxDataUpdateCoordinator class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): """Basis FritzBox entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: FritzboxDataUpdateCoordinator, @@ -29,11 +29,9 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): self.ain = ain if entity_description is not None: - self._attr_has_entity_name = True self.entity_description = entity_description self._attr_unique_id = f"{ain}_{entity_description.key}" else: - self._attr_name = self.data.name self._attr_unique_id = ain @property @@ -43,7 +41,7 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): class FritzBoxDeviceEntity(FritzBoxEntity): - """Reflects FritzhomeDevice and uses its attributes to construct FritzBoxDeviceEntity.""" + """Reflect FritzhomeDevice and construct FritzBoxDeviceEntity.""" @property def available(self) -> bool: diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 66917298922..b2cd66780f9 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome lightbulbs.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.light import ( @@ -140,7 +138,8 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): ) else: LOGGER.debug( - "device has no fullcolorsupport, using supported colors with 'setcolor'" + "device has no fullcolorsupport," + " using supported colors with 'setcolor'" ) # find supported hs values closest to what user selected hue = min( diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index f0353bc58d6..2a37999e775 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -1,7 +1,5 @@ """Models for the AVM FRITZ!SmartHome integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TypedDict diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index c526793e73e..5c7cd903b09 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 0f60ebe70b2..e504dab50ad 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -1,4 +1,9 @@ { + "common": { + "data_description_host": "The hostname or IP address of your FRITZ!Box router.", + "data_description_password": "Password for the user to connect Home Assistant to your FRITZ!Box.", + "data_description_username": "Name of the user to connect Home Assistant to your FRITZ!Box." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -20,6 +25,10 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "[%key:component::fritzbox::common::data_description_password%]", + "username": "[%key:component::fritzbox::common::data_description_username%]" + }, "description": "Do you want to set up {name}?" }, "reauth_confirm": { @@ -27,6 +36,10 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "[%key:component::fritzbox::common::data_description_password%]", + "username": "[%key:component::fritzbox::common::data_description_username%]" + }, "description": "Update your login information for {name}." }, "reconfigure": { @@ -34,7 +47,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router." + "host": "[%key:component::fritzbox::common::data_description_host%]" }, "description": "Update your configuration information for {name}." }, @@ -45,7 +58,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router." + "host": "[%key:component::fritzbox::common::data_description_host%]", + "password": "[%key:component::fritzbox::common::data_description_password%]", + "username": "[%key:component::fritzbox::common::data_description_username%]" }, "description": "Enter your FRITZ!Box information." } @@ -91,6 +106,15 @@ "change_settings_while_lock_enabled": { "message": "Can't change settings while manual access for telephone, app, or user interface is disabled on the device" }, + "connect_error": { + "message": "A connection error occurred while setting up the integration. The setup will be retried." + }, + "connect_error_reload": { + "message": "A connection error occurred while updating the data from the FRITZ!Box. The integration is going to be reloaded to ensure a proper re-login." + }, + "login_failed": { + "message": "Login failed, please check your username and password and try again." + }, "manual_switching_disabled": { "message": "Can't toggle switch while manual switching is disabled for the device." } diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 9ddc48b55d3..2fdd0ff0cb4 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -1,7 +1,5 @@ """Support for AVM FRITZ!SmartHome switch devices.""" -from __future__ import annotations - from typing import Any from pyfritzhome.devicetypes import FritzhomeTrigger @@ -73,7 +71,7 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): await self.coordinator.async_refresh() def check_lock_state(self) -> None: - """Raise an Error if manual switching via FRITZ!Box user interface is disabled.""" + """Raise an Error if manual switching via FRITZ!Box is disabled.""" if self.data.lock: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 3c8714624e7..3b136be16aa 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -1,7 +1,5 @@ """Base class for fritzbox_callmonitor entities.""" -from __future__ import annotations - from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -64,7 +62,7 @@ class FritzBoxPhonebook: self.prefixes = prefixes def init_phonebook(self) -> None: - """Establish a connection to the FRITZ!Box and check if phonebook_id is valid.""" + """Connect to the FRITZ!Box and check if phonebook_id is valid.""" self.fph = FritzPhonebook( address=self.host, user=self.username, diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 25e25336d57..ba4e4720f4c 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -1,7 +1,5 @@ """Config flow for fritzbox_callmonitor.""" -from __future__ import annotations - from collections.abc import Mapping from enum import StrEnum from typing import Any, cast diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 574ae9ef7f2..2288c07e39c 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -1,7 +1,5 @@ """Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import datetime, timedelta from enum import StrEnum diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index cfbdfbcb424..e88227fe33c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -1,7 +1,5 @@ """The Fronius integration.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta import logging @@ -86,8 +84,9 @@ class FroniusSolarNet: self.coordinator_lock = asyncio.Lock() self.fronius = fronius self.host: str = entry.data[CONF_HOST] - # entry.unique_id is either logger uid or first inverter uid if no logger available - # prepended by "solar_net_" to have individual device for whole system (power_flow) + # entry.unique_id is either logger uid or first inverter + # uid if no logger available prepended by "solar_net_" + # to have individual device for whole system (power_flow) self.solar_net_device_id = f"solar_net_{entry.unique_id}" self.system_device_info: DeviceInfo | None = None @@ -213,14 +212,15 @@ class FroniusSolarNet: inverter_info=_inverter_info, config_entry=self.config_entry, ) - if self.config_entry.state == ConfigEntryState.LOADED: + if self.config_entry.state is ConfigEntryState.LOADED: await _coordinator.async_refresh() else: await _coordinator.async_config_entry_first_refresh() self.inverter_coordinators.append(_coordinator) - # Only for re-scans. Initial setup adds entities through sensor.async_setup_entry - if self.config_entry.state == ConfigEntryState.LOADED: + # Only for re-scans. Initial setup adds entities + # through sensor.async_setup_entry + if self.config_entry.state is ConfigEntryState.LOADED: async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) _LOGGER.debug( @@ -235,7 +235,7 @@ class FroniusSolarNet: try: _inverter_info = await self.fronius.inverter_info() except FroniusError as err: - if self.config_entry.state == ConfigEntryState.LOADED: + if self.config_entry.state is ConfigEntryState.LOADED: # During a re-scan we will attempt again as per schedule. _LOGGER.debug("Re-scan failed for %s", self.host) return inverter_infos diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index 97e040abf98..f7a7115c995 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fronius integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any, Final diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index d4f1fc6c230..27b94561c99 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinators for the Fronius integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod from datetime import timedelta from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index e287786aaa8..a831d163668 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,7 +1,5 @@ """Support for Fronius devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final @@ -774,7 +772,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn return self.coordinator.data[self.solar_net_id] def _get_entity_value(self) -> Any: - """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" + """Extract entity value from coordinator. + + Raises KeyError if not included in latest update. + """ new_value = self.coordinator.data[self.solar_net_id][self.response_key]["value"] if new_value is None: return self.entity_description.default_value @@ -792,8 +793,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn try: self._attr_native_value = self._get_entity_value() except KeyError: - # sets state to `None` if no default_value is defined in entity description - # KeyError: raised when omitted in response - eg. at night when no production + # sets state to `None` if no default_value is defined + # in entity description + # KeyError: raised when omitted in response + # eg. at night when no production self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6531f80ddaf..7df0bd76e85 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,7 +1,5 @@ """Handle the frontend for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable, Iterator from functools import lru_cache, partial import logging @@ -34,7 +32,7 @@ from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey from .pr_download import download_pr_artifact @@ -354,7 +352,6 @@ class Panel: return response -@bind_hass @callback def async_register_built_in_panel( hass: HomeAssistant, @@ -393,7 +390,6 @@ def async_register_built_in_panel( hass.bus.async_fire(EVENT_PANELS_UPDATED) -@bind_hass @callback def async_remove_panel( hass: HomeAssistant, frontend_url_path: str, *, warn_if_unknown: bool = True @@ -409,6 +405,12 @@ def async_remove_panel( hass.bus.async_fire(EVENT_PANELS_UPDATED) +@callback +def async_panel_exists(hass: HomeAssistant, frontend_url_path: str) -> bool: + """Return if a panel is registered for the given frontend URL path.""" + return frontend_url_path in hass.data.get(DATA_PANELS, {}) + + def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: """Register extra js or module url to load. @@ -507,7 +509,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.info("Using frontend from PR #%s", dev_pr_number) except HomeAssistantError as err: _LOGGER.error( - "Failed to download PR #%s: %s, falling back to the integrated frontend", + "Failed to download PR #%s: %s, falling back" + " to the integrated frontend", dev_pr_number, err, ) @@ -599,6 +602,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: sidebar_title="home", show_in_sidebar=False, ) + async_register_built_in_panel( + hass, + "maintenance", + sidebar_icon="mdi:wrench", + sidebar_title="maintenance", + show_in_sidebar=False, + ) async_register_built_in_panel(hass, "profile") async_register_built_in_panel(hass, "notfound") diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 284e9f4b77f..83d3f469ca5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.6"] + "requirements": ["home-assistant-frontend==20260527.4"] } diff --git a/homeassistant/components/frontend/pr_download.py b/homeassistant/components/frontend/pr_download.py index 1d4c28a0471..56fb45f6371 100644 --- a/homeassistant/components/frontend/pr_download.py +++ b/homeassistant/components/frontend/pr_download.py @@ -1,7 +1,5 @@ """GitHub PR artifact download functionality for frontend development.""" -from __future__ import annotations - import io import logging import pathlib diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 71b6580a0a1..e60ef66ac7f 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,5 @@ """API for persistent storage for the frontend.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from functools import wraps diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 71196c13f68..45db1d44f2a 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -1,10 +1,8 @@ """The Frontier Silicon integration.""" -from __future__ import annotations - import logging -from afsapi import AFSAPI, ConnectionError as FSConnectionError +from afsapi import AFSAPI, FSConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN, Platform diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py index 9bad880a9b3..89b7c80b390 100644 --- a/homeassistant/components/frontier_silicon/browse_media.py +++ b/homeassistant/components/frontier_silicon/browse_media.py @@ -2,7 +2,7 @@ import logging -from afsapi import AFSAPI, FSApiException, OutOfRangeException, Preset +from afsapi import AFSAPI, FSApiError, OutOfRangeError, Preset from homeassistant.components.media_player import ( BrowseError, @@ -136,11 +136,11 @@ async def browse_node( # Return items in this folder children = [ _item_payload(key, item, player_mode, parent_keys=parent_keys) - async for key, item in await afsapi.nav_list() + async for key, item in afsapi.nav_list() ] - except OutOfRangeException as err: + except OutOfRangeError as err: raise BrowseError("The requested item is out of range") from err - except FSApiException as err: + except FSApiError as err: raise BrowseError(str(err)) from err return BrowseMedia( diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index dc4f6bea989..c42a9ec96c7 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,18 +1,11 @@ """Config flow for Frontier Silicon Media Player integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse -from afsapi import ( - AFSAPI, - ConnectionError as FSConnectionError, - InvalidPinException, - NotImplementedException, -) +from afsapi import AFSAPI, FSConnectionError, FSNotImplementedError, InvalidPinError import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult @@ -116,12 +109,12 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) try: await afsapi.get_friendly_name() - except InvalidPinException: + except InvalidPinError: return self.async_abort(reason="invalid_auth") try: unique_id = await afsapi.get_radio_id() - except NotImplementedException: + except FSNotImplementedError: unique_id = None await self.async_set_unique_id(unique_id) @@ -136,7 +129,8 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_step_device_config_if_needed(self) -> ConfigFlowResult: """Most users will not have changed the default PIN on their radio. - We try to use this default PIN, and only if this fails ask for it via `async_step_device_config` + We try to use this default PIN, and only if this fails + ask for it via `async_step_device_config` """ try: @@ -144,7 +138,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) self._name = await afsapi.get_friendly_name() - except InvalidPinException: + except InvalidPinError: # Ask for a PIN return await self.async_step_device_config() @@ -152,7 +146,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): try: unique_id = await afsapi.get_radio_id() - except NotImplementedException: + except FSNotImplementedError: unique_id = None await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -162,7 +156,10 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Allow the user to confirm adding the device. Used when the default PIN could successfully be used.""" + """Allow the user to confirm adding the device. + + Used when the default PIN could successfully be used. + """ if user_input is not None: return await self._async_create_entry() @@ -201,7 +198,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): except FSConnectionError: errors["base"] = "cannot_connect" - except InvalidPinException: + except InvalidPinError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -215,7 +212,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): try: unique_id = await afsapi.get_radio_id() - except NotImplementedException: + except FSNotImplementedError: unique_id = None await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 2a3fc0255e6..461265433d2 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -6,7 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["afsapi==0.3.1"], + "loggers": ["afsapi"], + "requirements": ["afsapi==1.0.1"], "ssdp": [ { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 1a85245933a..55a4880995b 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -1,14 +1,17 @@ """Support for Frontier Silicon Devices (Medion, Hama, Auna,...).""" -from __future__ import annotations - +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps import logging -from typing import Any +from typing import Any, Concatenate from afsapi import ( AFSAPI, - ConnectionError as FSConnectionError, - NotImplementedException as FSNotImplementedException, + FSApiError, + FSConnectionError, + FSNotImplementedError, + PlayCaps, + PlayRepeatMode, PlayState, ) @@ -19,10 +22,13 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + RepeatMode, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import FrontierSiliconConfigEntry from .browse_media import browse_node, browse_top_level @@ -31,6 +37,37 @@ from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) +def fs_command_exception_wrap[ + _AFSAPIDeviceT: AFSAPIDevice, + **_P, + _R, +]( + func: Callable[Concatenate[_AFSAPIDeviceT, _P], Awaitable[_R]], +) -> Callable[Concatenate[_AFSAPIDeviceT, _P], Coroutine[Any, Any, _R]]: + """Wrap command methods and map API exceptions to HA errors.""" + + @wraps(func) + async def _wrap(self: _AFSAPIDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except FSConnectionError as err: + command = func.__name__.removeprefix("async_") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"command": command}, + ) from err + except FSApiError as err: + command = func.__name__.removeprefix("async_") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"command": command, "message": str(err)}, + ) from err + + return _wrap + + async def async_setup_entry( hass: HomeAssistant, config_entry: FrontierSiliconConfigEntry, @@ -59,21 +96,14 @@ class AFSAPIDevice(MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None - _attr_supported_features = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET + _BASE_SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.BROWSE_MEDIA ) @@ -90,9 +120,45 @@ class AFSAPIDevice(MediaPlayerEntity): self.__modes_by_label: dict[str, str] | None = None self.__sound_modes_by_label: dict[str, str] | None = None + self.__play_caps: PlayCaps = PlayCaps(0) self._supports_sound_mode: bool = True + # Fallback used when the device doesn't support get_play_caps; covers the + # basic transport controls exposed by this integration by default. + _FALLBACK_PLAY_CAPS = ( + PlayCaps.PAUSE | PlayCaps.STOP | PlayCaps.SKIP_PREVIOUS | PlayCaps.SKIP_NEXT + ) + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the currently supported features for this device.""" + features = self._BASE_SUPPORTED_FEATURES + if self.__play_caps & (PlayCaps.PAUSE | PlayCaps.STOP): + features |= MediaPlayerEntityFeature.PLAY + if self.__play_caps & PlayCaps.PAUSE: + features |= MediaPlayerEntityFeature.PAUSE + if self.__play_caps & PlayCaps.STOP: + features |= MediaPlayerEntityFeature.STOP + if self.__play_caps & ( + PlayCaps.SKIP_PREVIOUS | PlayCaps.REWIND | PlayCaps.SKIP_BACKWARD + ): + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if self.__play_caps & ( + PlayCaps.SKIP_NEXT | PlayCaps.FAST_FORWARD | PlayCaps.SKIP_FORWARD + ): + features |= MediaPlayerEntityFeature.NEXT_TRACK + if self.__play_caps & (PlayCaps.REPEAT | PlayCaps.REPEAT_ONE): + features |= MediaPlayerEntityFeature.REPEAT_SET + if self.__play_caps & PlayCaps.SHUFFLE: + features |= MediaPlayerEntityFeature.SHUFFLE_SET + if self.__play_caps & PlayCaps.SEEK: + features |= MediaPlayerEntityFeature.SEEK + if self._supports_sound_mode: + features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE + + return features + async def async_update(self) -> None: """Get the latest date and update device state.""" afsapi = self.fs_device @@ -100,12 +166,13 @@ class AFSAPIDevice(MediaPlayerEntity): if await afsapi.get_power(): status = await afsapi.get_play_status() self._attr_state = { + PlayState.IDLE: MediaPlayerState.IDLE, + PlayState.BUFFERING: MediaPlayerState.BUFFERING, PlayState.PLAYING: MediaPlayerState.PLAYING, PlayState.PAUSED: MediaPlayerState.PAUSED, + PlayState.REBUFFERING: MediaPlayerState.BUFFERING, PlayState.STOPPED: MediaPlayerState.IDLE, - PlayState.LOADING: MediaPlayerState.BUFFERING, - None: MediaPlayerState.IDLE, - }.get(status) + }.get(status, MediaPlayerState.IDLE) else: self._attr_state = MediaPlayerState.OFF except FSConnectionError: @@ -115,7 +182,9 @@ class AFSAPIDevice(MediaPlayerEntity): self.name or afsapi.webfsapi_endpoint, ) self._attr_available = False - return + + # Device is not available, stop the update + return if not self._attr_available: _LOGGER.warning( @@ -127,19 +196,44 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._attr_source_list: self.__modes_by_label = { - (mode.label or mode.id): mode.key for mode in await afsapi.get_modes() + (mode.label or mode.id): mode.key + for mode in await afsapi.get_modes() + if mode.selectable } self._attr_source_list = list(self.__modes_by_label) + try: + self.__play_caps = await afsapi.get_play_caps() + except FSNotImplementedError: + self.__play_caps = self._FALLBACK_PLAY_CAPS + + if self.__play_caps & (PlayCaps.REPEAT | PlayCaps.REPEAT_ONE): + try: + repeat_mode = await afsapi.get_play_repeat() + except FSNotImplementedError: + self._attr_repeat = RepeatMode.OFF + else: + self._attr_repeat = { + PlayRepeatMode.OFF: RepeatMode.OFF, + PlayRepeatMode.REPEAT_ALL: RepeatMode.ALL, + PlayRepeatMode.REPEAT_ONE: RepeatMode.ONE, + }.get(repeat_mode, RepeatMode.OFF) + else: + self._attr_repeat = RepeatMode.OFF + + if self.__play_caps & PlayCaps.SHUFFLE: + try: + self._attr_shuffle = bool(await afsapi.get_play_shuffle()) + except FSNotImplementedError: + self._attr_shuffle = False + else: + self._attr_shuffle = False + if not self._attr_sound_mode_list and self._supports_sound_mode: try: equalisers = await afsapi.get_equalisers() - except FSNotImplementedException: + except FSNotImplementedError: self._supports_sound_mode = False - # Remove SELECT_SOUND_MODE from the advertised supported features - self._attr_supported_features ^= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) else: self.__sound_modes_by_label = { sound_mode.label: sound_mode.key for sound_mode in equalisers @@ -166,15 +260,26 @@ class AFSAPIDevice(MediaPlayerEntity): self._attr_is_volume_muted = await afsapi.get_mute() self._attr_media_image_url = await afsapi.get_play_graphic() + if self.__play_caps and self.__play_caps & PlayCaps.SEEK: + position_ms = await afsapi.get_play_position() + duration_ms = await afsapi.get_play_duration() + self._attr_media_position = ( + position_ms // 1000 if position_ms is not None else None + ) + self._attr_media_duration = ( + duration_ms // 1000 if duration_ms is not None else None + ) + self._attr_media_position_updated_at = dt_util.utcnow() + else: + self._attr_media_position = None + self._attr_media_duration = None + self._attr_media_position_updated_at = None + if self._supports_sound_mode: try: eq_preset = await afsapi.get_eq_preset() - except FSNotImplementedException: + except FSNotImplementedError: self._supports_sound_mode = False - # Remove SELECT_SOUND_MODE from the advertised supported features - self._attr_supported_features ^= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) else: self._attr_sound_mode = ( eq_preset.label if eq_preset is not None else None @@ -194,69 +299,82 @@ class AFSAPIDevice(MediaPlayerEntity): self._attr_is_volume_muted = None self._attr_media_image_url = None self._attr_sound_mode = None + self._attr_media_position = None + self._attr_media_duration = None + self._attr_media_position_updated_at = None self._attr_volume_level = None # Management actions # power control + @fs_command_exception_wrap async def async_turn_on(self) -> None: """Turn on the device.""" await self.fs_device.set_power(True) + @fs_command_exception_wrap async def async_turn_off(self) -> None: """Turn off the device.""" await self.fs_device.set_power(False) + @fs_command_exception_wrap async def async_media_play(self) -> None: """Send play command.""" - await self.fs_device.play() + if (await self.fs_device.get_play_status()) == PlayState.STOPPED: + # The 'play' command only seems to work when the current stream is paused. + # We need to send a 'stop' command instead to resume a stopped stream. + await self.fs_device.stop() + else: + await self.fs_device.play() + @fs_command_exception_wrap async def async_media_pause(self) -> None: """Send pause command.""" await self.fs_device.pause() - async def async_media_play_pause(self) -> None: - """Send play/pause command.""" - if self._attr_state == MediaPlayerState.PLAYING: - await self.fs_device.pause() - else: - await self.fs_device.play() - + @fs_command_exception_wrap async def async_media_stop(self) -> None: - """Send play/pause command.""" - await self.fs_device.pause() + """Send stop command.""" + await self.fs_device.stop() + @fs_command_exception_wrap async def async_media_previous_track(self) -> None: """Send previous track command (results in rewind).""" await self.fs_device.rewind() + @fs_command_exception_wrap async def async_media_next_track(self) -> None: """Send next track command (results in fast-forward).""" await self.fs_device.forward() + @fs_command_exception_wrap async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" await self.fs_device.set_mute(mute) # volume + @fs_command_exception_wrap async def async_volume_up(self) -> None: """Send volume up command.""" volume = await self.fs_device.get_volume() volume = int(volume or 0) + 1 await self.fs_device.set_volume(min(volume, self._max_volume or 1)) + @fs_command_exception_wrap async def async_volume_down(self) -> None: """Send volume down command.""" volume = await self.fs_device.get_volume() volume = int(volume or 0) - 1 await self.fs_device.set_volume(max(volume, 0)) + @fs_command_exception_wrap async def async_set_volume_level(self, volume: float) -> None: """Set volume command.""" if self._max_volume: # Can't do anything sensible if not set volume = int(volume * self._max_volume) await self.fs_device.set_volume(volume) + @fs_command_exception_wrap async def async_select_source(self, source: str) -> None: """Select input source.""" await self.fs_device.set_power(True) @@ -266,6 +384,7 @@ class AFSAPIDevice(MediaPlayerEntity): ): await self.fs_device.set_mode(mode) + @fs_command_exception_wrap async def async_select_sound_mode(self, sound_mode: str) -> None: """Select EQ Preset.""" if ( @@ -274,6 +393,27 @@ class AFSAPIDevice(MediaPlayerEntity): ): await self.fs_device.set_eq_preset(mode) + @fs_command_exception_wrap + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self.fs_device.play_repeat( + { + RepeatMode.OFF: PlayRepeatMode.OFF, + RepeatMode.ALL: PlayRepeatMode.REPEAT_ALL, + RepeatMode.ONE: PlayRepeatMode.REPEAT_ONE, + }.get(repeat, PlayRepeatMode.OFF) + ) + + @fs_command_exception_wrap + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set shuffle mode.""" + await self.fs_device.set_play_shuffle(shuffle) + + @fs_command_exception_wrap + async def async_media_seek(self, position: float) -> None: + """Seek to a position in seconds.""" + await self.fs_device.set_play_position(int(position * 1000)) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, @@ -285,6 +425,7 @@ class AFSAPIDevice(MediaPlayerEntity): return await browse_node(self.fs_device, media_content_type, media_content_id) + @fs_command_exception_wrap async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -304,7 +445,8 @@ class AFSAPIDevice(MediaPlayerEntity): if len(keys) != 1: raise BrowseError("Presets can only have 1 level") - # Keys of presets are 0-based, while the list shown on the device starts from 1 + # Keys of presets are 0-based, while the list shown + # on the device starts from 1 preset = int(keys[0]) - 1 await self.fs_device.select_preset(preset) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index cc13e2d0d47..fe18bb92646 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -33,5 +33,13 @@ } } } + }, + "exceptions": { + "api_error": { + "message": "Failed to execute {command}: {message}" + }, + "connection_error": { + "message": "Failed to execute {command}: could not connect to device" + } } } diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index 699356a2e75..4396739fbfa 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -1,17 +1,15 @@ """The Fujitsu HVAC (based on Ayla IOT) integration.""" -from __future__ import annotations - from contextlib import suppress from ayla_iot_unofficial import new_ayla_api from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU +from .const import API_TIMEOUT, CONF_EUROPE, REGION_DEFAULT, REGION_EU from .coordinator import FGLairConfigEntry, FGLairCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index 9369fd7b7cd..bd9ec053063 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -9,11 +9,11 @@ from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.helpers import aiohttp_client from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from .const import API_TIMEOUT, CONF_REGION, DOMAIN, REGION_DEFAULT, REGION_EU +from .const import API_TIMEOUT, DOMAIN, REGION_DEFAULT, REGION_EU _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fujitsu_fglair/const.py b/homeassistant/components/fujitsu_fglair/const.py index 73c811a1ed5..6abb5d6631e 100644 --- a/homeassistant/components/fujitsu_fglair/const.py +++ b/homeassistant/components/fujitsu_fglair/const.py @@ -7,7 +7,6 @@ API_REFRESH = timedelta(minutes=5) DOMAIN = "fujitsu_fglair" -CONF_REGION = "region" CONF_EUROPE = "is_europe" REGION_EU = "eu" REGION_DEFAULT = "default" diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 3bb693e1068..af632aad23c 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -29,7 +29,10 @@ async def async_setup_entry( class FGLairOutsideTemperature(FGLairEntity, SensorEntity): - """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump.""" + """Entity representing outside temperature. + + Sensed by the outside unit of a Fujitsu Heatpump. + """ _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py index 8a25376f635..29030eb25e7 100644 --- a/homeassistant/components/fully_kiosk/binary_sensor.py +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser sensor.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index b93f1191f84..48a50571647 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser button.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py index 6357660f8e8..167778767b5 100644 --- a/homeassistant/components/fully_kiosk/camera.py +++ b/homeassistant/components/fully_kiosk/camera.py @@ -1,7 +1,5 @@ """Support for Fully Kiosk Browser camera.""" -from __future__ import annotations - from fullykiosk import FullyKioskError from homeassistant.components.camera import Camera, CameraEntityFeature diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 7ab6ac90f14..a0fcc99b9f3 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fully Kiosk Browser integration.""" -from __future__ import annotations - import asyncio import json from typing import Any diff --git a/homeassistant/components/fully_kiosk/const.py b/homeassistant/components/fully_kiosk/const.py index 35fe539a552..39be4ff7cff 100644 --- a/homeassistant/components/fully_kiosk/const.py +++ b/homeassistant/components/fully_kiosk/const.py @@ -1,7 +1,5 @@ """Constants for the Fully Kiosk Browser integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py index c8364c77753..a83ffcc36dc 100644 --- a/homeassistant/components/fully_kiosk/diagnostics.py +++ b/homeassistant/components/fully_kiosk/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Fully Kiosk Browser.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index a1f077d7886..6f79ce005f8 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,7 +1,5 @@ """Base entity for the Fully Kiosk Browser integration.""" -from __future__ import annotations - import json from yarl import URL @@ -23,7 +21,9 @@ def valid_global_mac_address(mac: str | None) -> bool: return False try: first_octet = int(mac.split(":")[0], 16) - # If the second least-significant bit is set, it's a locally administered address, should not be used as an ID + # If the second least-significant bit is set, it's a + # locally administered address, should not be used as + # an ID return not bool(first_octet & 0x2) except ValueError: return False diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index 158eae8671c..2a0a38399f8 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -1,7 +1,5 @@ """Support for Fully Kiosk Browser image.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index c7b48759233..7389e31bf64 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser media player.""" -from __future__ import annotations - from typing import Any from homeassistant.components import media_source diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py index 0a0c24c60e2..ce494ab0be7 100644 --- a/homeassistant/components/fully_kiosk/notify.py +++ b/homeassistant/components/fully_kiosk/notify.py @@ -1,7 +1,5 @@ """Support for Fully Kiosk Browser notifications.""" -from __future__ import annotations - from dataclasses import dataclass from fullykiosk import FullyKioskError diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 146608c3901..d4824edcce4 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser number entity.""" -from __future__ import annotations - from contextlib import suppress from homeassistant.components.number import NumberEntity, NumberEntityDescription diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index 6bc9a254760..399f464a602 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 4a57572f4ed..fba95312587 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -1,7 +1,5 @@ """Services for the Fully Kiosk Browser integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -44,7 +42,7 @@ async def _collect_coordinators( raise HomeAssistantError(f"Device '{target}' not found in device registry") coordinators = list[FullyKioskDataUpdateCoordinator]() for config_entry in config_entries: - if config_entry.state != ConfigEntryState.LOADED: + if config_entry.state is not ConfigEntryState.LOADED: raise HomeAssistantError(f"{config_entry.title} is not loaded") coordinators.append(config_entry.runtime_data) return coordinators diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 804233dcc9e..d1eab622ce8 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -1,7 +1,5 @@ """Fully Kiosk Browser switch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/fumis/__init__.py b/homeassistant/components/fumis/__init__.py new file mode 100644 index 00000000000..c57e001ebb6 --- /dev/null +++ b/homeassistant/components/fumis/__init__.py @@ -0,0 +1,31 @@ +"""Support for Fumis pellet stoves.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool: + """Set up Fumis from a config entry.""" + coordinator = FumisDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool: + """Unload Fumis config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fumis/binary_sensor.py b/homeassistant/components/fumis/binary_sensor.py new file mode 100644 index 00000000000..1877034e32d --- /dev/null +++ b/homeassistant/components/fumis/binary_sensor.py @@ -0,0 +1,74 @@ +"""Support for Fumis binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from fumis import FumisInfo + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class FumisBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Fumis binary sensor entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + is_on_fn: Callable[[FumisInfo], bool | None] + + +BINARY_SENSORS: tuple[FumisBinarySensorEntityDescription, ...] = ( + FumisBinarySensorEntityDescription( + key="door", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.controller.door_open is not None, + is_on_fn=lambda data: data.controller.door_open, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis binary sensor entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisBinarySensorEntity(coordinator=coordinator, description=description) + for description in BINARY_SENSORS + if description.has_fn(coordinator.data) + ) + + +class FumisBinarySensorEntity(FumisEntity, BinarySensorEntity): + """Defines a Fumis binary sensor entity.""" + + entity_description: FumisBinarySensorEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisBinarySensorEntityDescription, + ) -> None: + """Initialize the Fumis binary sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/fumis/button.py b/homeassistant/components/fumis/button.py new file mode 100644 index 00000000000..884e0843689 --- /dev/null +++ b/homeassistant/components/fumis/button.py @@ -0,0 +1,69 @@ +"""Support for Fumis button entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisButtonEntityDescription(ButtonEntityDescription): + """Describes a Fumis button entity.""" + + press_fn: Callable[[Fumis], Awaitable[Any]] + + +BUTTONS: tuple[FumisButtonEntityDescription, ...] = ( + FumisButtonEntityDescription( + key="sync_clock", + translation_key="sync_clock", + entity_category=EntityCategory.DIAGNOSTIC, + press_fn=lambda client: client.set_clock(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis button entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisButtonEntity(coordinator=coordinator, description=description) + for description in BUTTONS + ) + + +class FumisButtonEntity(FumisEntity, ButtonEntity): + """Defines a Fumis button entity.""" + + entity_description: FumisButtonEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisButtonEntityDescription, + ) -> None: + """Initialize the Fumis button entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @fumis_exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/fumis/climate.py b/homeassistant/components/fumis/climate.py new file mode 100644 index 00000000000..bcde869d2da --- /dev/null +++ b/homeassistant/components/fumis/climate.py @@ -0,0 +1,126 @@ +"""Support for Fumis climate entities.""" + +from typing import Any + +from fumis import StoveStatus + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + +STOVE_STATUS_TO_HVAC_ACTION: dict[StoveStatus, HVACAction | None] = { + StoveStatus.OFF: HVACAction.OFF, + StoveStatus.COLD_START_OFF: HVACAction.OFF, + StoveStatus.WOOD_BURNING_OFF: HVACAction.OFF, + StoveStatus.PRE_HEATING: HVACAction.PREHEATING, + StoveStatus.IGNITION: HVACAction.PREHEATING, + StoveStatus.PRE_COMBUSTION: HVACAction.PREHEATING, + StoveStatus.COLD_START: HVACAction.PREHEATING, + StoveStatus.COMBUSTION: HVACAction.HEATING, + StoveStatus.ECO: HVACAction.HEATING, + StoveStatus.HYBRID_INIT: HVACAction.HEATING, + StoveStatus.HYBRID_START: HVACAction.HEATING, + StoveStatus.WOOD_START: HVACAction.HEATING, + StoveStatus.WOOD_COMBUSTION: HVACAction.HEATING, + StoveStatus.COOLING: HVACAction.IDLE, + StoveStatus.UNKNOWN: None, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis climate entity based on a config entry.""" + async_add_entities([FumisClimateEntity(entry.runtime_data)]) + + +class FumisClimateEntity(FumisEntity, ClimateEntity): + """Defines a Fumis climate entity.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_max_temp = 35.0 + _attr_min_temp = 10.0 + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_target_temperature_step = 0.5 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: FumisDataUpdateCoordinator) -> None: + """Initialize the Fumis climate entity.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.config_entry.unique_id + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + if self.coordinator.data.controller.on: + return HVACMode.HEAT + return HVACMode.OFF + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return STOVE_STATUS_TO_HVAC_ACTION[ + self.coordinator.data.controller.stove_status + ] + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if (temp := self.coordinator.data.controller.main_temperature) is None: + return None + return temp.actual + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + if (temp := self.coordinator.data.controller.main_temperature) is None: + return None + return temp.setpoint + + @fumis_exception_handler + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + if hvac_mode == HVACMode.HEAT: + await self.coordinator.client.turn_on() + else: + await self.coordinator.client.turn_off() + await self.coordinator.async_request_refresh() + + @fumis_exception_handler + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self.coordinator.client.set_target_temperature(temperature) + await self.coordinator.async_request_refresh() + + @fumis_exception_handler + async def async_turn_on(self) -> None: + """Turn on the stove.""" + await self.coordinator.client.turn_on() + await self.coordinator.async_request_refresh() + + @fumis_exception_handler + async def async_turn_off(self) -> None: + """Turn off the stove.""" + await self.coordinator.client.turn_off() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/fumis/config_flow.py b/homeassistant/components/fumis/config_flow.py new file mode 100644 index 00000000000..9f8afa57347 --- /dev/null +++ b/homeassistant/components/fumis/config_flow.py @@ -0,0 +1,202 @@ +"""Config flow to configure the Fumis integration.""" + +from collections.abc import Mapping +from typing import Any + +from fumis import ( + Fumis, + FumisAuthenticationError, + FumisConnectionError, + FumisInfo, + FumisStoveOfflineError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_MAC, CONF_PIN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import DOMAIN, LOGGER + + +class FumisFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Fumis config flow.""" + + _discovered_mac: str + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery of a Fumis WiRCU module.""" + mac = discovery_info.macaddress.replace(":", "").replace("-", "").upper() + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + self._discovered_mac = mac + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle DHCP discovery confirmation.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, info = await self._validate_input( + self._discovered_mac, user_input[CONF_PIN] + ) + if info: + return self.async_create_entry( + title=info.controller.model_name or "Fumis", + data={ + CONF_MAC: self._discovered_mac, + CONF_PIN: user_input[CONF_PIN], + }, + ) + + return self.async_show_form( + step_id="dhcp_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + mac = user_input[CONF_MAC].replace(":", "").replace("-", "").upper() + errors, info = await self._validate_input(mac, user_input[CONF_PIN]) + if info: + await self.async_set_unique_id(format_mac(mac), raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info.controller.model_name or "Fumis", + data={ + CONF_MAC: mac, + CONF_PIN: user_input[CONF_PIN], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_MAC): TextSelector( + TextSelectorConfig(autocomplete="off") + ), + vol.Required(CONF_PIN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + user_input, + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a Fumis stove.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + errors, _ = await self._validate_input( + reconfigure_entry.data[CONF_MAC], user_input[CONF_PIN] + ) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_PIN: user_input[CONF_PIN]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of a Fumis stove.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + errors, _ = await self._validate_input( + reauth_entry.data[CONF_MAC], user_input[CONF_PIN] + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PIN: user_input[CONF_PIN]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def _validate_input( + self, mac: str, pin: str + ) -> tuple[dict[str, str], FumisInfo | None]: + """Validate credentials, returning errors and info.""" + errors: dict[str, str] = {} + fumis = Fumis( + mac=mac, + password=pin, + session=async_get_clientsession(self.hass), + ) + try: + info = await fumis.update_info() + except FumisAuthenticationError: + errors[CONF_PIN] = "invalid_auth" + except FumisStoveOfflineError: + errors["base"] = "device_offline" + except FumisConnectionError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, info + return errors, None diff --git a/homeassistant/components/fumis/const.py b/homeassistant/components/fumis/const.py new file mode 100644 index 00000000000..d83b2ad824d --- /dev/null +++ b/homeassistant/components/fumis/const.py @@ -0,0 +1,9 @@ +"""Constants for the Fumis integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "fumis" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/fumis/coordinator.py b/homeassistant/components/fumis/coordinator.py new file mode 100644 index 00000000000..516e08554dc --- /dev/null +++ b/homeassistant/components/fumis/coordinator.py @@ -0,0 +1,69 @@ +"""DataUpdateCoordinator for Fumis.""" + +from fumis import ( + Fumis, + FumisAuthenticationError, + FumisConnectionError, + FumisError, + FumisInfo, + FumisStoveOfflineError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type FumisConfigEntry = ConfigEntry[FumisDataUpdateCoordinator] + + +class FumisDataUpdateCoordinator(DataUpdateCoordinator[FumisInfo]): + """Class to manage fetching Fumis data.""" + + config_entry: FumisConfigEntry + + def __init__(self, hass: HomeAssistant, entry: FumisConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = Fumis( + mac=entry.data[CONF_MAC], + password=entry.data[CONF_PIN], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{entry.unique_id}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> FumisInfo: + """Fetch data from the Fumis API.""" + try: + return await self.client.update_info() + except FumisAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from err + except FumisStoveOfflineError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="stove_offline", + ) from err + except FumisConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err + except FumisError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/fumis/diagnostics.py b/homeassistant/components/fumis/diagnostics.py new file mode 100644 index 00000000000..23d3a3ce995 --- /dev/null +++ b/homeassistant/components/fumis/diagnostics.py @@ -0,0 +1,19 @@ +"""Diagnostics support for Fumis.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import FumisConfigEntry + +TO_REDACT_UNIT = {"id", "ip"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: FumisConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = await entry.runtime_data.client.raw_status() + data["unit"] = async_redact_data(data["unit"], TO_REDACT_UNIT) + return data diff --git a/homeassistant/components/fumis/entity.py b/homeassistant/components/fumis/entity.py new file mode 100644 index 00000000000..5111ffd26e3 --- /dev/null +++ b/homeassistant/components/fumis/entity.py @@ -0,0 +1,33 @@ +"""Base entity for the Fumis integration.""" + +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FumisDataUpdateCoordinator + + +class FumisEntity(CoordinatorEntity[FumisDataUpdateCoordinator]): + """Defines a Fumis entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FumisDataUpdateCoordinator) -> None: + """Initialize a Fumis entity.""" + super().__init__(coordinator=coordinator) + info = coordinator.data + mac = format_mac(coordinator.config_entry.data[CONF_MAC]) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + connections={(CONNECTION_NETWORK_MAC, mac)}, + manufacturer=info.controller.manufacturer or "Fumis", + model=info.controller.model_name, + name=info.controller.model_name or "Pellet stove", + sw_version=str(info.controller.version), + hw_version=str(info.unit.version), + ) diff --git a/homeassistant/components/fumis/helpers.py b/homeassistant/components/fumis/helpers.py new file mode 100644 index 00000000000..97e11aeb5d2 --- /dev/null +++ b/homeassistant/components/fumis/helpers.py @@ -0,0 +1,61 @@ +"""Helpers for Fumis.""" + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from fumis import ( + FumisAuthenticationError, + FumisConnectionError, + FumisError, + FumisStoveOfflineError, +) + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import FumisEntity + + +def fumis_exception_handler[_FumisEntityT: FumisEntity, **_P]( + func: Callable[Concatenate[_FumisEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_FumisEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Fumis calls to handle exceptions. + + A decorator that wraps the passed in function, catches Fumis errors. + """ + + async def handler(self: _FumisEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + self.coordinator.async_update_listeners() + + except FumisAuthenticationError as error: + self.hass.config_entries.async_schedule_reload( + self.coordinator.config_entry.entry_id + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error + + except FumisStoveOfflineError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stove_offline", + ) from error + + except FumisConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except FumisError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/fumis/icons.json b/homeassistant/components/fumis/icons.json new file mode 100644 index 00000000000..ec17593cbd3 --- /dev/null +++ b/homeassistant/components/fumis/icons.json @@ -0,0 +1,112 @@ +{ + "entity": { + "button": { + "sync_clock": { + "default": "mdi:clock-check" + } + }, + "number": { + "fan_speed": { + "default": "mdi:fan" + }, + "power_level": { + "default": "mdi:fire" + } + }, + "sensor": { + "alert": { + "default": "mdi:alert", + "state": { + "airflow_malfunction": "mdi:fan-off", + "door_open": "mdi:door-open", + "flue_gas_warning": "mdi:thermometer-alert", + "low_battery": "mdi:battery-alert", + "low_fuel": "mdi:gauge-empty", + "none": "mdi:check-circle", + "service_due": "mdi:wrench-clock", + "speed_sensor_failure": "mdi:fan-alert" + } + }, + "combustion_chamber_temperature": { + "default": "mdi:thermometer-high" + }, + "detailed_stove_status": { + "default": "mdi:fireplace" + }, + "error": { + "default": "mdi:alert-circle", + "state": { + "chimney_alarm": "mdi:broom", + "chimney_dirty": "mdi:broom", + "door_alarm": "mdi:door-open", + "fire_error": "mdi:fire-alert", + "flue_gas_overtemp": "mdi:thermometer-high", + "fuel_ignition_timeout": "mdi:fire-off", + "gas_alarm": "mdi:alert-circle", + "general_error": "mdi:alert-circle", + "grate_error": "mdi:alert-circle", + "ignition_failed": "mdi:fire-alert", + "mfdoor_alarm": "mdi:door-open", + "no_pellet_alarm": "mdi:gauge-empty", + "none": "mdi:check-circle", + "ntc1_alarm": "mdi:thermometer-alert", + "ntc2_alarm": "mdi:thermometer-alert", + "ntc3_alarm": "mdi:thermometer-alert", + "pressure_alarm": "mdi:gauge-empty", + "pressure_sensor_off": "mdi:gauge-empty", + "safety_switch": "mdi:shield-alert", + "sensor_t01_t02": "mdi:thermometer-alert", + "sensor_t01_t03": "mdi:thermometer-alert", + "sensor_t02": "mdi:thermometer-alert", + "sensor_t03_t05": "mdi:thermometer-alert", + "sensor_t04": "mdi:thermometer-alert", + "tc1_alarm": "mdi:thermometer-alert" + } + }, + "fan_1_speed": { + "default": "mdi:fan" + }, + "fan_2_speed": { + "default": "mdi:fan" + }, + "fuel_quantity": { + "default": "mdi:gauge" + }, + "fuel_used": { + "default": "mdi:counter" + }, + "igniter_starts": { + "default": "mdi:counter" + }, + "misfires": { + "default": "mdi:alert-outline" + }, + "overheatings": { + "default": "mdi:thermometer-alert" + }, + "power_output": { + "default": "mdi:fire" + }, + "pressure": { + "default": "mdi:gauge" + }, + "stove_status": { + "default": "mdi:fireplace" + }, + "time_to_service": { + "default": "mdi:wrench-clock" + }, + "wifi_signal_strength": { + "default": "mdi:wifi" + } + }, + "switch": { + "eco_mode": { + "default": "mdi:leaf" + }, + "timer": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/fumis/manifest.json b/homeassistant/components/fumis/manifest.json new file mode 100644 index 00000000000..ad090be6412 --- /dev/null +++ b/homeassistant/components/fumis/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "fumis", + "name": "Fumis", + "codeowners": ["@frenck"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "0016D0*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/fumis", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["fumis"], + "quality_scale": "platinum", + "requirements": ["fumis==0.4.0"] +} diff --git a/homeassistant/components/fumis/number.py b/homeassistant/components/fumis/number.py new file mode 100644 index 00000000000..4fbece36fb1 --- /dev/null +++ b/homeassistant/components/fumis/number.py @@ -0,0 +1,95 @@ +"""Support for Fumis number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis, FumisInfo + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisNumberEntityDescription(NumberEntityDescription): + """Describes a Fumis number entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + value_fn: Callable[[FumisInfo], float | None] + set_fn: Callable[[Fumis, float], Awaitable[Any]] + + +NUMBERS: tuple[FumisNumberEntityDescription, ...] = ( + FumisNumberEntityDescription( + key="fan_speed", + translation_key="fan_speed", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_min_value=0, + native_max_value=5, + native_step=1, + has_fn=lambda data: len(data.controller.fans) > 0, + value_fn=lambda data: ( + data.controller.fans[0].speed if data.controller.fans else None + ), + set_fn=lambda client, value: client.set_fan_speed(int(value)), + ), + FumisNumberEntityDescription( + key="power_level", + translation_key="power_level", + native_min_value=1, + native_max_value=5, + native_step=1, + value_fn=lambda data: data.controller.power.set_power, + set_fn=lambda client, value: client.set_power(int(value)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis number entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisNumberEntity(coordinator=coordinator, description=description) + for description in NUMBERS + if description.has_fn(coordinator.data) + ) + + +class FumisNumberEntity(FumisEntity, NumberEntity): + """Defines a Fumis number entity.""" + + entity_description: FumisNumberEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisNumberEntityDescription, + ) -> None: + """Initialize the Fumis number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator.data) + + @fumis_exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self.entity_description.set_fn(self.coordinator.client, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/fumis/quality_scale.yaml b/homeassistant/components/fumis/quality_scale.yaml new file mode 100644 index 00000000000..2bf005be7da --- /dev/null +++ b/homeassistant/components/fumis/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: done + discovery-update-info: + status: exempt + comment: Cloud-only API, no local device information to update. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/fumis/sensor.py b/homeassistant/components/fumis/sensor.py new file mode 100644 index 00000000000..4c76d139847 --- /dev/null +++ b/homeassistant/components/fumis/sensor.py @@ -0,0 +1,332 @@ +"""Support for Fumis sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any + +from fumis import FumisInfo, StoveAlert, StoveError, StoveState, StoveStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity + +PARALLEL_UPDATES = 0 + + +def _code_to_state(code: StoveAlert | StoveError | None) -> str | None: + """Convert a stove alert or error code to a sensor state value. + + Returns "none" when there is no active alert/error, None when the code + is unknown, or the enum member name in lowercase for known codes. + """ + if code is None: + return "none" + if code.name == "UNKNOWN": + return None + return code.name.lower() + + +def _code_to_attr(code: StoveAlert | StoveError | None) -> dict[str, str | None]: + """Convert a stove alert or error code to extra state attributes.""" + if code is None or code.name == "UNKNOWN": + return {"code": None} + return {"code": code.value} + + +@dataclass(frozen=True, kw_only=True) +class FumisSensorEntityDescription(SensorEntityDescription): + """Describes a Fumis sensor entity.""" + + attr_fn: Callable[[FumisInfo], dict[str, Any]] | None = None + has_fn: Callable[[FumisInfo], bool] = lambda _: True + value_fn: Callable[[FumisInfo], datetime | float | int | str | None] + + +SENSORS: tuple[FumisSensorEntityDescription, ...] = ( + FumisSensorEntityDescription( + key="alert", + translation_key="alert", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "none", + *( + alert.name.lower() + for alert in StoveAlert + if alert != StoveAlert.UNKNOWN + ), + ], + value_fn=lambda data: _code_to_state(data.controller.stove_alert), + attr_fn=lambda data: _code_to_attr(data.controller.stove_alert), + ), + FumisSensorEntityDescription( + key="combustion_chamber_temperature", + translation_key="combustion_chamber_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.controller.combustion_chamber_temperature is not None, + value_fn=lambda data: data.controller.combustion_chamber_temperature, + ), + FumisSensorEntityDescription( + key="detailed_stove_status", + translation_key="detailed_stove_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + status.name.lower() + for status in StoveStatus + if status != StoveStatus.UNKNOWN + ], + value_fn=lambda data: ( + None + if data.controller.stove_status is StoveStatus.UNKNOWN + else data.controller.stove_status.name.lower() + ), + ), + FumisSensorEntityDescription( + key="error", + translation_key="error", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "none", + *( + error.name.lower() + for error in StoveError + if error != StoveError.UNKNOWN + ), + ], + value_fn=lambda data: _code_to_state(data.controller.stove_error), + attr_fn=lambda data: _code_to_attr(data.controller.stove_error), + ), + FumisSensorEntityDescription( + key="fan_1_speed", + translation_key="fan_1_speed", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda data: data.controller.fan1_speed is not None, + value_fn=lambda data: data.controller.fan1_speed, + ), + FumisSensorEntityDescription( + key="fan_2_speed", + translation_key="fan_2_speed", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda data: data.controller.fan2_speed is not None, + value_fn=lambda data: data.controller.fan2_speed, + ), + FumisSensorEntityDescription( + key="fuel_quantity", + translation_key="fuel_quantity", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda data: ( + len(data.controller.fuels) > 0 + and data.controller.fuels[0].quantity_percentage is not None + ), + value_fn=lambda data: ( + data.controller.fuels[0].quantity_percentage + if data.controller.fuels + else None + ), + ), + FumisSensorEntityDescription( + key="fuel_used", + translation_key="fuel_used", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.controller.statistic.fuel_quantity_used, + ), + FumisSensorEntityDescription( + key="heating_time", + translation_key="heating_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: data.controller.statistic.heating_time.total_seconds(), + ), + FumisSensorEntityDescription( + key="igniter_starts", + translation_key="igniter_starts", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.controller.statistic.igniter_starts, + ), + FumisSensorEntityDescription( + key="misfires", + translation_key="misfires", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.controller.statistic.misfires, + ), + FumisSensorEntityDescription( + key="module_temperature", + translation_key="module_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda data: data.unit.temperature is not None, + value_fn=lambda data: data.unit.temperature, + ), + FumisSensorEntityDescription( + key="overheatings", + translation_key="overheatings", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.controller.statistic.overheatings, + ), + FumisSensorEntityDescription( + key="power_output", + translation_key="power_output", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + value_fn=lambda data: data.controller.power.kw, + ), + FumisSensorEntityDescription( + key="pressure", + translation_key="pressure", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda data: data.controller.pressure is not None, + value_fn=lambda data: data.controller.pressure, + ), + FumisSensorEntityDescription( + key="stove_status", + translation_key="stove_status", + device_class=SensorDeviceClass.ENUM, + options=[state.value for state in StoveState if state != StoveState.UNKNOWN], + value_fn=lambda data: ( + None + if data.controller.state is StoveState.UNKNOWN + else data.controller.state.value + ), + ), + FumisSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.controller.main_temperature is not None, + value_fn=lambda data: ( + data.controller.main_temperature.actual + if data.controller.main_temperature + else None + ), + ), + FumisSensorEntityDescription( + key="time_to_service", + translation_key="time_to_service", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.controller.time_to_service is not None, + value_fn=lambda data: data.controller.time_to_service, + ), + FumisSensorEntityDescription( + key="uptime", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=ignore_variance( + lambda data: ( + utcnow().replace(microsecond=0) - data.controller.statistic.uptime + ), + timedelta(minutes=5), + ), + ), + FumisSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.unit.rssi, + ), + FumisSensorEntityDescription( + key="wifi_signal_strength", + translation_key="wifi_signal_strength", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.unit.signal_strength, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis sensor entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisSensorEntity(coordinator=coordinator, description=description) + for description in SENSORS + if description.has_fn(coordinator.data) + ) + + +class FumisSensorEntity(FumisEntity, SensorEntity): + """Defines a Fumis sensor entity.""" + + entity_description: FumisSensorEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisSensorEntityDescription, + ) -> None: + """Initialize the Fumis sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes.""" + if self.entity_description.attr_fn is None: + return None + return self.entity_description.attr_fn(self.coordinator.data) + + @property + def native_value(self) -> datetime | float | int | str | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json new file mode 100644 index 00000000000..54d615ad9a4 --- /dev/null +++ b/homeassistant/components/fumis/strings.json @@ -0,0 +1,215 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "device_offline": "Your stove's Fumis WiRCU Wi-Fi module is not connected to the internet. Make sure the module has power and is connected to your Wi-Fi network.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "dhcp_confirm": { + "data": { + "pin": "[%key:component::fumis::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::fumis::config::step::user::data_description::pin%]" + }, + "description": "A Fumis WiRCU Wi-Fi module was discovered on your network. Enter the PIN code from the label on the module to set up your pellet stove." + }, + "reauth_confirm": { + "data": { + "pin": "[%key:component::fumis::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::fumis::config::step::user::data_description::pin%]" + }, + "description": "The PIN code for your stove has changed. Please enter the new PIN code to re-authenticate." + }, + "reconfigure": { + "data": { + "pin": "[%key:component::fumis::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::fumis::config::step::user::data_description::pin%]" + }, + "description": "Reconfigure your Fumis pellet stove connection." + }, + "user": { + "data": { + "mac": "MAC address", + "pin": "PIN code" + }, + "data_description": { + "mac": "The MAC address is a unique code of letters and numbers that identifies your stove. You can find it on the label of the Fumis WiRCU Wi-Fi module connected to your stove.", + "pin": "You can find the PIN code on the label of the Fumis WiRCU Wi-Fi module connected to your stove." + }, + "description": "Integrate your Fumis-based pellet stove with Home Assistant to monitor and control it. You can see your stove's temperature, heating status, and adjust the target temperature right from your dashboard or use it in your automations. This way, you can make sure your home is always nice, warm, and comfortable." + } + } + }, + "entity": { + "button": { + "sync_clock": { + "name": "Sync clock" + } + }, + "number": { + "fan_speed": { + "name": "Fan speed" + }, + "power_level": { + "name": "Power level" + } + }, + "sensor": { + "alert": { + "name": "Alert", + "state": { + "airflow_malfunction": "Airflow sensor malfunction", + "door_open": "Door open", + "flue_gas_warning": "Flue gas temperature warning", + "low_battery": "Low battery", + "low_fuel": "Low fuel level", + "none": "No alert", + "service_due": "Service due", + "speed_sensor_failure": "Speed sensor failure" + } + }, + "combustion_chamber_temperature": { + "name": "Combustion chamber" + }, + "detailed_stove_status": { + "name": "Detailed stove status", + "state": { + "cold_start": "Cold start", + "cold_start_off": "Off (cold start)", + "combustion": "Combustion", + "cooling": "Cooling", + "eco": "Eco", + "hybrid_init": "Hybrid init", + "hybrid_start": "Hybrid start", + "ignition": "Ignition", + "off": "[%key:common::state::off%]", + "pre_combustion": "Pre-combustion", + "pre_heating": "Pre-heating", + "wood_burning_off": "Off (wood burning)", + "wood_combustion": "Wood combustion", + "wood_start": "Wood start" + } + }, + "error": { + "name": "Error", + "state": { + "chimney_alarm": "Chimney alarm", + "chimney_dirty": "Chimney or burning pot dirty", + "door_alarm": "Door alarm", + "fire_error": "Fire error", + "flue_gas_overtemp": "Flue gas overtemperature", + "fuel_ignition_timeout": "Fuel ignition timeout", + "gas_alarm": "Gas alarm", + "general_error": "General error", + "grate_error": "Grate error", + "ignition_failed": "Ignition failed", + "mfdoor_alarm": "MFDoor alarm", + "no_pellet_alarm": "No pellet alarm", + "none": "No error", + "ntc1_alarm": "NTC1 alarm", + "ntc2_alarm": "NTC2 alarm", + "ntc3_alarm": "NTC3 alarm", + "pressure_alarm": "Pressure alarm", + "pressure_sensor_off": "Pressure sensor off", + "safety_switch": "Safety switch tripped", + "sensor_t01_t02": "Sensor T01/T02 malfunction", + "sensor_t01_t03": "Sensor T01/T03 malfunction", + "sensor_t02": "Sensor T02 malfunction", + "sensor_t03_t05": "Sensor T03/T05 malfunction", + "sensor_t04": "Sensor T04 malfunction", + "tc1_alarm": "TC1 alarm" + } + }, + "fan_1_speed": { + "name": "Fan 1 speed" + }, + "fan_2_speed": { + "name": "Fan 2 speed" + }, + "fuel_quantity": { + "name": "Fuel level" + }, + "fuel_used": { + "name": "Fuel consumed" + }, + "heating_time": { + "name": "Burning time" + }, + "igniter_starts": { + "name": "Igniter starts" + }, + "misfires": { + "name": "Misfires" + }, + "module_temperature": { + "name": "WiRCU module" + }, + "overheatings": { + "name": "Overheatings" + }, + "power_output": { + "name": "Power output" + }, + "pressure": { + "name": "Combustion chamber pressure" + }, + "stove_status": { + "name": "Stove status", + "state": { + "burning": "Burning", + "cooling": "Cooling", + "eco": "Eco", + "heating_up": "Heating up", + "ignition": "Ignition", + "off": "[%key:common::state::off%]" + } + }, + "time_to_service": { + "name": "Time to service" + }, + "uptime": { + "name": "Uptime" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, + "wifi_signal_strength": { + "name": "Wi-Fi signal strength" + } + }, + "switch": { + "eco_mode": { + "name": "Eco mode" + }, + "timer": { + "name": "Timer" + } + } + }, + "exceptions": { + "authentication_error": { + "message": "Authentication with the Fumis online service failed. Check your MAC address and PIN code." + }, + "communication_error": { + "message": "An error occurred while communicating with the Fumis online service: {error}" + }, + "stove_offline": { + "message": "Your stove's Fumis WiRCU Wi-Fi module is not connected to the internet." + }, + "unknown_error": { + "message": "An unexpected error occurred while communicating with the Fumis online service: {error}" + } + } +} diff --git a/homeassistant/components/fumis/switch.py b/homeassistant/components/fumis/switch.py new file mode 100644 index 00000000000..b4aa8b34fe4 --- /dev/null +++ b/homeassistant/components/fumis/switch.py @@ -0,0 +1,98 @@ +"""Support for Fumis switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis, FumisInfo + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisSwitchEntityDescription(SwitchEntityDescription): + """Describes a Fumis switch entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + is_on_fn: Callable[[FumisInfo], bool] + turn_on_fn: Callable[[Fumis], Awaitable[Any]] + turn_off_fn: Callable[[Fumis], Awaitable[Any]] + + +SWITCHES: tuple[FumisSwitchEntityDescription, ...] = ( + FumisSwitchEntityDescription( + key="eco_mode", + translation_key="eco_mode", + entity_category=EntityCategory.CONFIG, + has_fn=lambda data: data.controller.eco_mode is not None, + is_on_fn=lambda data: ( + data.controller.eco_mode.enabled if data.controller.eco_mode else False + ), + turn_on_fn=lambda client: client.set_eco_mode(enabled=True), + turn_off_fn=lambda client: client.set_eco_mode(enabled=False), + ), + FumisSwitchEntityDescription( + key="timer", + translation_key="timer", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda data: data.controller.timer_enable, + turn_on_fn=lambda client: client.set_timer(enabled=True), + turn_off_fn=lambda client: client.set_timer(enabled=False), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis switch entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisSwitchEntity(coordinator=coordinator, description=description) + for description in SWITCHES + if description.has_fn(coordinator.data) + ) + + +class FumisSwitchEntity(FumisEntity, SwitchEntity): + """Defines a Fumis switch entity.""" + + entity_description: FumisSwitchEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisSwitchEntityDescription, + ) -> None: + """Initialize the Fumis switch entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + @fumis_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.turn_on_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + @fumis_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.turn_off_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index be15e2b2230..9b02b6d5441 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -1,7 +1,5 @@ """Support for FutureNow Ethernet unit outputs as Lights.""" -from __future__ import annotations - from typing import Any import pyfnip diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 2264f341bad..1d519ed3b83 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -1,7 +1,5 @@ """Initialization of FYTA integration.""" -from __future__ import annotations - from datetime import datetime import logging diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py index ac092f1d9cb..8d54a06de79 100644 --- a/homeassistant/components/fyta/binary_sensor.py +++ b/homeassistant/components/fyta/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for Fyta.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 9c5ab1de405..078daae98f1 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -1,7 +1,5 @@ """Config flow for FYTA integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 012ed3b2af0..6cbe0a68098 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for FYTA integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -68,7 +66,8 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): ) from err _LOGGER.debug("Data successfully updated") - # data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices + # data must be assigned before _async_add_remove_devices, + # as it is uses to set-up possible new devices self.data = data self._async_add_remove_devices() diff --git a/homeassistant/components/fyta/diagnostics.py b/homeassistant/components/fyta/diagnostics.py index d6bda70d754..00f6e3ef377 100644 --- a/homeassistant/components/fyta/diagnostics.py +++ b/homeassistant/components/fyta/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Fyta.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index 891c0bf53eb..0899aaaaa2c 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -1,7 +1,5 @@ """Entity for Fyta plant image.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index d16a3eccfff..f43f3d96538 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -1,7 +1,5 @@ """Summary data from Fyta.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 36bc6ce36ba..4cfac53d417 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -1,7 +1,5 @@ """Platform for the Garadget cover component.""" -from __future__ import annotations - import logging from typing import Any @@ -254,7 +252,10 @@ class GaradgetCover(CoverEntity): def _get_variable(self, var): """Get latest status.""" - url = f"{self.particle_url}/v1/devices/{self.device_id}/{var}?access_token={self.access_token}" + url = ( + f"{self.particle_url}/v1/devices/{self.device_id}" + f"/{var}?access_token={self.access_token}" + ) ret = requests.get(url, timeout=10) result = {} for pairs in ret.json()["result"].split("|"): diff --git a/homeassistant/components/garage_door/__init__.py b/homeassistant/components/garage_door/__init__.py index ef353a5d31b..b186fec647a 100644 --- a/homeassistant/components/garage_door/__init__.py +++ b/homeassistant/components/garage_door/__init__.py @@ -1,7 +1,5 @@ """Integration for garage door triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/garage_door/conditions.yaml b/homeassistant/components/garage_door/conditions.yaml index 32215fdc5eb..7782ca40bc6 100644 --- a/homeassistant/components/garage_door/conditions.yaml +++ b/homeassistant/components/garage_door/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json index 574a117f517..87eddbf8cc3 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Garage door", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::trigger_for_name%]" } }, "name": "Garage door closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::trigger_for_name%]" } }, "name": "Garage door opened" diff --git a/homeassistant/components/garage_door/triggers.yaml b/homeassistant/components/garage_door/triggers.yaml index 5a36582d0de..dbff35f8eb2 100644 --- a/homeassistant/components/garage_door/triggers.yaml +++ b/homeassistant/components/garage_door/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: closed: fields: *trigger_common_fields diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 854e41f2d89..5c4749c2954 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -1,7 +1,5 @@ """The Garages Amsterdam integration.""" -from __future__ import annotations - from odp_amsterdam import ODPAmsterdam from homeassistant.const import Platform diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 6cfd68c8a00..bf253025d78 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Garages Amsterdam.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py index 0f4f277ed61..0f1d59ad1a8 100644 --- a/homeassistant/components/garages_amsterdam/config_flow.py +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Garages Amsterdam integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py index be5e2216a81..552dc33907a 100644 --- a/homeassistant/components/garages_amsterdam/const.py +++ b/homeassistant/components/garages_amsterdam/const.py @@ -1,7 +1,5 @@ """Constants for the Garages Amsterdam integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py index 74f2361980d..9b7aab4cb6f 100644 --- a/homeassistant/components/garages_amsterdam/coordinator.py +++ b/homeassistant/components/garages_amsterdam/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Garages Amsterdam integration.""" -from __future__ import annotations - from odp_amsterdam import Garage, ODPAmsterdam, VehicleType from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py index 433bc75b962..8ef486214b1 100644 --- a/homeassistant/components/garages_amsterdam/entity.py +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -1,7 +1,5 @@ """Generic entity for Garages Amsterdam.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 5467ae73b1e..4f237e1d1a9 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Garages Amsterdam.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 2e915beb22e..4526c9c852b 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -1,35 +1,24 @@ """The Gardena Bluetooth integration.""" -from __future__ import annotations - -import asyncio +from contextlib import suppress import logging from bleak.backends.device import BLEDevice from gardena_bluetooth.client import CachedConnection, Client -from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation -from gardena_bluetooth.exceptions import ( - CharacteristicNoAccess, - CharacteristicNotFound, - CommunicationFailure, -) -from gardena_bluetooth.parse import CharacteristicTime +from gardena_bluetooth.const import ScanService +from gardena_bluetooth.parse import ManufacturerData, ProductType +from habluetooth import BluetoothServiceInfoBleak from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import dt as dt_util -from .const import DOMAIN from .coordinator import ( DeviceUnavailable, GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator, ) -from .util import async_get_product_type PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -38,11 +27,69 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.VALVE, ] LOGGER = logging.getLogger(__name__) -TIMEOUT = 20.0 DISCONNECT_DELAY = 5 +PRODUCTS_SCAN_TIMEOUT = 10 +PRODUCT_TYPE_TIMEOUT = 30 + + +async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType: + """Get a product type for the given address.""" + + data = ManufacturerData() + + def _data_callback(info: BluetoothServiceInfoBleak) -> bool: + LOGGER.debug("Processing advertisement from %s: %s", info.address, info) + if info.device.address != address: + return False + + data.update(info.manufacturer_data.get(ManufacturerData.company, b"")) + return data.product_type is not ProductType.UNKNOWN + + with suppress(TimeoutError): + await bluetooth.async_process_advertisements( + hass, + _data_callback, + bluetooth.BluetoothCallbackMatcher( + address=address, manufacturer_id=ManufacturerData.company + ), + mode=bluetooth.BluetoothScanningMode.ACTIVE, + timeout=PRODUCT_TYPE_TIMEOUT, + ) + return data.product_type + + +async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]: + """Get all products that are currently advertising.""" + products: dict[str, ManufacturerData] = {} + + def _data_callback(info: BluetoothServiceInfoBleak) -> bool: + LOGGER.debug("Processing advertisement from %s: %s", info.address, info) + if ScanService not in info.service_uuids: + return False + + raw = info.manufacturer_data.get(ManufacturerData.company, b"") + if (data := products.get(info.device.address)) is None: + data = ManufacturerData() + products[info.device.address] = data + + data.update(raw) + return False + + with suppress(TimeoutError): + await bluetooth.async_process_advertisements( + hass, + _data_callback, + bluetooth.BluetoothCallbackMatcher( + manufacturer_id=ManufacturerData.company + ), + mode=bluetooth.BluetoothScanningMode.ACTIVE, + timeout=PRODUCTS_SCAN_TIMEOUT, + ) + return products def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: @@ -59,15 +106,6 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: return CachedConnection(DISCONNECT_DELAY, _device_lookup) -async def _update_timestamp(client: Client, characteristics: CharacteristicTime): - try: - await client.update_timestamp(characteristics, dt_util.now()) - except CharacteristicNotFound: - pass - except CharacteristicNoAccess: - LOGGER.debug("No access to update internal time") - - async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry ) -> bool: @@ -75,50 +113,25 @@ async def async_setup_entry( address = entry.data[CONF_ADDRESS] - try: - async with asyncio.timeout(TIMEOUT): - product_type = await async_get_product_type(hass, address) - except TimeoutError as exception: - raise ConfigEntryNotReady("Unable to find product type") from exception + product_type = await async_get_product_type(hass, address) + if product_type is ProductType.UNKNOWN: + raise ConfigEntryNotReady("Unable to find product type") client = Client(get_connection(hass, address), product_type) - try: - chars = await client.get_all_characteristics() - - sw_version = await client.read_char(DeviceInformation.firmware_version, None) - manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None) - model = await client.read_char(DeviceInformation.model_number, None) - - name = entry.title - name = await client.read_char(DeviceConfiguration.custom_device_name, name) - name = await client.read_char(AquaContour.custom_device_name, name) - - await _update_timestamp(client, DeviceConfiguration.unix_timestamp) - await _update_timestamp(client, AquaContour.unix_timestamp) - - except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: - await client.disconnect() - raise ConfigEntryNotReady( - f"Unable to connect to device {address} due to {exception}" - ) from exception - - device = DeviceInfo( - identifiers={(DOMAIN, address)}, - connections={(dr.CONNECTION_BLUETOOTH, address)}, - name=name, - sw_version=sw_version, - manufacturer=manufacturer, - model=model, - ) coordinator = GardenaBluetoothCoordinator( - hass, entry, LOGGER, client, set(chars.keys()), device, address + hass, + entry, + LOGGER, + client, + address, ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await coordinator.async_refresh() - + await coordinator.async_request_refresh() return True @@ -126,7 +139,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry ) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await entry.runtime_data.async_shutdown() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index 4fddd1a53b1..db105e62313 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary_sensor entities.""" -from __future__ import annotations - from dataclasses import dataclass, field from gardena_bluetooth.const import AquaContour, Sensor, Valve diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 1dda3717487..db8708ebf96 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -1,7 +1,5 @@ """Support for button entities.""" -from __future__ import annotations - from dataclasses import dataclass, field from gardena_bluetooth.const import Reset diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 329d8a8fb3b..f7a18157bd6 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -1,62 +1,32 @@ """Config flow for Gardena Bluetooth integration.""" -from __future__ import annotations - import logging from typing import Any from gardena_bluetooth.client import Client -from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService +from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure -from gardena_bluetooth.parse import ManufacturerData, ProductType +from gardena_bluetooth.parse import ProductType import voluptuous as vol -from homeassistant.components.bluetooth import ( - BluetoothServiceInfo, - async_discovered_service_info, -) +from homeassistant.components.bluetooth import BluetoothServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import AbortFlow -from . import get_connection +from . import async_get_product_type, async_get_products, get_connection from .const import DOMAIN _LOGGER = logging.getLogger(__name__) - -def _is_supported(discovery_info: BluetoothServiceInfo): - """Check if device is supported.""" - if ScanService not in discovery_info.service_uuids: - return False - - if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)): - _LOGGER.debug("Missing manufacturer data: %s", discovery_info) - return False - - manufacturer_data = ManufacturerData.decode(data) - product_type = ProductType.from_manufacturer_data(manufacturer_data) - - if product_type not in ( - ProductType.PUMP, - ProductType.VALVE, - ProductType.WATER_COMPUTER, - ProductType.AUTOMATS, - ProductType.PRESSURE_TANKS, - ProductType.AQUA_CONTOURS, - ): - _LOGGER.debug("Unsupported device: %s", manufacturer_data) - return False - - return True - - -def _get_name(discovery_info: BluetoothServiceInfo): - data = discovery_info.manufacturer_data[ManufacturerData.company] - manufacturer_data = ManufacturerData.decode(data) - product_type = ProductType.from_manufacturer_data(manufacturer_data) - - return PRODUCT_NAMES.get(product_type, "Gardena Device") +_SUPPORTED_PRODUCT_TYPES = { + ProductType.PUMP, + ProductType.VALVE, + ProductType.WATER_COMPUTER, + ProductType.AUTOMATS, + ProductType.PRESSURE_TANKS, + ProductType.AQUA_CONTOURS, +} class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @@ -90,11 +60,12 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the bluetooth discovery step.""" _LOGGER.debug("Discovered device: %s", discovery_info) - if not _is_supported(discovery_info): + product_type = await async_get_product_type(self.hass, discovery_info.address) + if product_type not in _SUPPORTED_PRODUCT_TYPES: return self.async_abort(reason="no_devices_found") self.address = discovery_info.address - self.devices = {discovery_info.address: _get_name(discovery_info)} + self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]} await self.async_set_unique_id(self.address) self._abort_if_unique_id_configured() return await self.async_step_confirm() @@ -130,13 +101,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_confirm() - current_addresses = self._async_current_ids(include_ignore=False) - for discovery_info in async_discovered_service_info(self.hass): - address = discovery_info.address - if address in current_addresses or not _is_supported(discovery_info): - continue + current = self._async_current_ids(include_ignore=False) + devices = await async_get_products(self.hass) - self.devices[address] = _get_name(discovery_info) + # Keep selection sorted by address to ensure stable tests + self.devices = { + address: PRODUCT_NAMES[data.product_type] + for address in sorted(devices) + if address not in current + and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES + } if not self.devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index f85fb839657..ca0155b2bac 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -1,22 +1,31 @@ """Provides the DataUpdateCoordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from gardena_bluetooth.client import Client +from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation from gardena_bluetooth.exceptions import ( CharacteristicNoAccess, + CharacteristicNotFound, + CommunicationFailure, GardenaBluetoothException, ) -from gardena_bluetooth.parse import Characteristic, CharacteristicType +from gardena_bluetooth.parse import ( + Characteristic, + CharacteristicTime, + CharacteristicType, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=60) LOGGER = logging.getLogger(__name__) @@ -39,8 +48,6 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): config_entry: GardenaBluetoothConfigEntry, logger: logging.Logger, client: Client, - characteristics: set[str], - device_info: DeviceInfo, address: str, ) -> None: """Initialize global data updater.""" @@ -54,14 +61,63 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): self.address = address self.data = {} self.client = client - self.characteristics = characteristics - self.device_info = device_info + self.characteristics: set[str] = set() + self.device_info = DeviceInfo( + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + name=config_entry.title, + ) async def async_shutdown(self) -> None: """Shutdown coordinator and any connection.""" await super().async_shutdown() await self.client.disconnect() + async def _async_setup(self) -> None: + """Set up the coordinator and read initial device metadata.""" + try: + chars = await self.client.get_all_characteristics() + + sw_version = await self.client.read_char( + DeviceInformation.firmware_version, None + ) + manufacturer = await self.client.read_char( + DeviceInformation.manufacturer_name, None + ) + model = await self.client.read_char(DeviceInformation.model_number, None) + + name = self.config_entry.title + name = await self.client.read_char( + DeviceConfiguration.custom_device_name, name + ) + name = await self.client.read_char(AquaContour.custom_device_name, name) + + await self._update_timestamp(DeviceConfiguration.unix_timestamp) + await self._update_timestamp(AquaContour.unix_timestamp) + + self.characteristics = set(chars.keys()) + self.device_info = DeviceInfo( + { + **self.device_info, + "name": name, + "sw_version": sw_version, + "manufacturer": manufacturer, + "model": model, + } + ) + except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: + raise UpdateFailed( + f"Unable to set up Gardena Bluetooth device due to {exception}" + ) from exception + + async def _update_timestamp(self, char: CharacteristicTime) -> None: + try: + await self.client.update_timestamp(char, dt_util.now()) + except CharacteristicNotFound: + pass + except CharacteristicNoAccess: + LOGGER.debug("No access to update internal time") + async def _async_update_data(self) -> dict[str, bytes]: """Poll the device.""" uuids: set[str] = { diff --git a/homeassistant/components/gardena_bluetooth/entity.py b/homeassistant/components/gardena_bluetooth/entity.py index a0344fc4ca0..79277cffe9f 100644 --- a/homeassistant/components/gardena_bluetooth/entity.py +++ b/homeassistant/components/gardena_bluetooth/entity.py @@ -1,7 +1,5 @@ """Provides the DataUpdateCoordinator.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/gardena_bluetooth/icons.json b/homeassistant/components/gardena_bluetooth/icons.json new file mode 100644 index 00000000000..9ac18776773 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "text": { + "contour_name": { + "default": "mdi:vector-polygon" + }, + "position_name": { + "default": "mdi:map-marker-radius" + } + } + } +} diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index d9ffb7b25d2..284615c014b 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==2.3.0"] + "requirements": ["gardena-bluetooth==2.8.1"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index ef0c751cc50..c484863d2e0 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -1,7 +1,5 @@ """Support for number entities.""" -from __future__ import annotations - from dataclasses import dataclass, field from gardena_bluetooth.const import ( @@ -87,17 +85,6 @@ DESCRIPTIONS = ( char=Valve.remaining_open_time, device_class=NumberDeviceClass.DURATION, ), - GardenaBluetoothNumberEntityDescription( - key=AquaContourWatering.remaining_watering_time.unique_id, - translation_key="remaining_watering_time", - native_unit_of_measurement=UnitOfTime.SECONDS, - native_min_value=0.0, - native_max_value=24 * 60 * 60, - native_step=60.0, - entity_category=EntityCategory.DIAGNOSTIC, - char=AquaContourWatering.remaining_watering_time, - device_class=NumberDeviceClass.DURATION, - ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.unique_id, translation_key="rain_pause", @@ -142,6 +129,7 @@ DESCRIPTIONS = ( native_min_value=0.0, native_max_value=359.0, native_step=1.0, + entity_category=EntityCategory.CONFIG, char=Spray.sector, ), GardenaBluetoothNumberEntityDescription( @@ -153,6 +141,7 @@ DESCRIPTIONS = ( native_max_value=100.0, native_step=0.1, char=Spray.distance, + entity_category=EntityCategory.CONFIG, scale=10.0, ), ) diff --git a/homeassistant/components/gardena_bluetooth/select.py b/homeassistant/components/gardena_bluetooth/select.py index 931517e3e4d..0223675aa3f 100644 --- a/homeassistant/components/gardena_bluetooth/select.py +++ b/homeassistant/components/gardena_bluetooth/select.py @@ -1,7 +1,5 @@ """Support for select entities.""" -from __future__ import annotations - from dataclasses import dataclass, field from enum import IntEnum @@ -13,6 +11,7 @@ from gardena_bluetooth.const import ( from gardena_bluetooth.parse import CharacteristicInt from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -61,6 +60,7 @@ DESCRIPTIONS = ( translation_key="operation_mode", char=AquaContour.operation_mode, option_to_number=_enum_to_int(AquaContour.operation_mode.enum), + entity_category=EntityCategory.CONFIG, ), GardenaBluetoothSelectEntityDescription( translation_key="active_position", diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index d31a00f73da..19ca315c456 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -1,13 +1,12 @@ """Support for switch entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from gardena_bluetooth.const import ( AquaContourBattery, + AquaContourWatering, Battery, EventHistory, FlowStatistics, @@ -47,10 +46,10 @@ def _get_timestamp(value: datetime | None): return value.replace(tzinfo=dt_util.get_default_time_zone()) -def _get_distance_ratio(value: int | None): +def _get_distance_percentage(value: int | None) -> float | None: if value is None: return None - return value / 1000 + return value / 10 @dataclass(frozen=True) @@ -133,7 +132,7 @@ DESCRIPTIONS = ( key=FlowStatistics.overall.unique_id, translation_key="flow_statistics_overall", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.overall, @@ -141,6 +140,7 @@ DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( key=FlowStatistics.current.unique_id, translation_key="flow_statistics_current", + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLUME_FLOW_RATE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, @@ -150,7 +150,7 @@ DESCRIPTIONS = ( key=FlowStatistics.resettable.unique_id, translation_key="flow_statistics_resettable", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.resettable, @@ -166,10 +166,11 @@ DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( key=Spray.current_distance.unique_id, translation_key="spray_current_distance", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, char=Spray.current_distance, - get=_get_distance_ratio, + get=_get_distance_percentage, ), GardenaBluetoothSensorEntityDescription( key=Spray.current_sector.unique_id, @@ -216,7 +217,22 @@ async def async_setup_entry( if description.char.unique_id in coordinator.characteristics ] if Valve.remaining_open_time.unique_id in coordinator.characteristics: - entities.append(GardenaBluetoothRemainSensor(coordinator)) + entities.append( + GardenaBluetoothRemainSensor( + coordinator, Valve.remaining_open_time, "remaining_open_timestamp" + ) + ) + if ( + AquaContourWatering.remaining_watering_time.unique_id + in coordinator.characteristics + ): + entities.append( + GardenaBluetoothRemainSensor( + coordinator, + AquaContourWatering.remaining_watering_time, + "remaining_watering_timestamp", + ) + ) async_add_entities(entities) @@ -243,24 +259,27 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_native_value: datetime | None = None - _attr_translation_key = "remaining_open_timestamp" def __init__( self, coordinator: GardenaBluetoothCoordinator, + char: Characteristic[int], + key: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, {Valve.remaining_open_time.uuid}) - self._attr_unique_id = f"{coordinator.address}-remaining_open_timestamp" + super().__init__(coordinator, {char.uuid}) + self._attr_unique_id = f"{coordinator.address}-{key}" + self._attr_translation_key = key + self._char = char def _handle_coordinator_update(self) -> None: - value = self.coordinator.get_cached(Valve.remaining_open_time) + value = self.coordinator.get_cached(self._char) if not value: self._attr_native_value = None super()._handle_coordinator_update() return - time = datetime.now(UTC) + timedelta(seconds=value) + time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow if not self._attr_native_value: self._attr_native_value = time super()._handle_coordinator_update() @@ -269,8 +288,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): error = time - self._attr_native_value if abs(error.total_seconds()) > 10: self._attr_native_value = time - super()._handle_coordinator_update() - return + super()._handle_coordinator_update() @property def available(self) -> bool: diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 80fdd63bf68..b7a848d0680 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -134,6 +134,9 @@ "remaining_open_timestamp": { "name": "Valve closing" }, + "remaining_watering_timestamp": { + "name": "Watering finished" + }, "sensor_battery_level": { "name": "Sensor battery" }, @@ -154,6 +157,14 @@ "state": { "name": "[%key:common::state::open%]" } + }, + "text": { + "contour_name": { + "name": "Contour {number}" + }, + "position_name": { + "name": "Position {number}" + } } } } diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index 053a90aaa4d..3183ac88efe 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -1,7 +1,5 @@ """Support for switch entities.""" -from __future__ import annotations - from typing import Any from gardena_bluetooth.const import Valve diff --git a/homeassistant/components/gardena_bluetooth/text.py b/homeassistant/components/gardena_bluetooth/text.py new file mode 100644 index 00000000000..5c3cfd5d503 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/text.py @@ -0,0 +1,86 @@ +"""Support for text entities.""" + +from dataclasses import dataclass + +from gardena_bluetooth.const import AquaContourContours, AquaContourPosition +from gardena_bluetooth.parse import CharacteristicNullString + +from homeassistant.components.text import TextEntity, TextEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import GardenaBluetoothConfigEntry +from .entity import GardenaBluetoothDescriptorEntity + + +@dataclass(frozen=True, kw_only=True) +class GardenaBluetoothTextEntityDescription(TextEntityDescription): + """Description of entity.""" + + char: CharacteristicNullString + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + + +DESCRIPTIONS = ( + *( + GardenaBluetoothTextEntityDescription( + key=f"position_{i}_name", + translation_key="position_name", + translation_placeholders={"number": str(i)}, + has_entity_name=True, + char=getattr(AquaContourPosition, f"position_name_{i}"), + native_max=20, + entity_category=EntityCategory.CONFIG, + ) + for i in range(1, 6) + ), + *( + GardenaBluetoothTextEntityDescription( + key=f"contour_{i}_name", + translation_key="contour_name", + translation_placeholders={"number": str(i)}, + has_entity_name=True, + char=getattr(AquaContourContours, f"contour_name_{i}"), + native_max=20, + entity_category=EntityCategory.CONFIG, + ) + for i in range(1, 6) + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GardenaBluetoothConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up text based on a config entry.""" + coordinator = entry.runtime_data + entities = [ + GardenaBluetoothTextEntity(coordinator, description, description.context) + for description in DESCRIPTIONS + if description.char.unique_id in coordinator.characteristics + ] + async_add_entities(entities) + + +class GardenaBluetoothTextEntity(GardenaBluetoothDescriptorEntity, TextEntity): + """Representation of a text entity.""" + + entity_description: GardenaBluetoothTextEntityDescription + + @property + def native_value(self) -> str | None: + """Return the value reported by the text.""" + char = self.entity_description.char + return self.coordinator.get_cached(char) + + async def async_set_value(self, value: str) -> None: + """Change the text.""" + char = self.entity_description.char + await self.coordinator.write(char, value) diff --git a/homeassistant/components/gardena_bluetooth/util.py b/homeassistant/components/gardena_bluetooth/util.py deleted file mode 100644 index ce2d862c600..00000000000 --- a/homeassistant/components/gardena_bluetooth/util.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Utility functions for Gardena Bluetooth integration.""" - -import asyncio -from collections.abc import AsyncIterator - -from gardena_bluetooth.parse import ManufacturerData, ProductType - -from homeassistant.components import bluetooth - - -async def _async_service_info( - hass, address -) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]: - queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]() - - def _callback( - service_info: bluetooth.BluetoothServiceInfoBleak, - change: bluetooth.BluetoothChange, - ) -> None: - if change != bluetooth.BluetoothChange.ADVERTISEMENT: - return - - queue.put_nowait(service_info) - - service_info = bluetooth.async_last_service_info(hass, address, True) - if service_info: - yield service_info - - cancel = bluetooth.async_register_callback( - hass, - _callback, - {bluetooth.match.ADDRESS: address}, - bluetooth.BluetoothScanningMode.ACTIVE, - ) - try: - while True: - yield await queue.get() - finally: - cancel() - - -async def async_get_product_type(hass, address: str) -> ProductType: - """Wait for enough packets of manufacturer data to get the product type.""" - data = ManufacturerData() - - async for service_info in _async_service_info(hass, address): - data.update(service_info.manufacturer_data.get(ManufacturerData.company, b"")) - product_type = ProductType.from_manufacturer_data(data) - if product_type is not ProductType.UNKNOWN: - return product_type - raise AssertionError("Iterator should have been infinite") diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index a5fa2796244..18ebd730456 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -1,7 +1,5 @@ """Support for switch entities.""" -from __future__ import annotations - from typing import Any from gardena_bluetooth.const import Valve diff --git a/homeassistant/components/gate/__init__.py b/homeassistant/components/gate/__init__.py index b1fa802e45c..e6fb94c21d1 100644 --- a/homeassistant/components/gate/__init__.py +++ b/homeassistant/components/gate/__init__.py @@ -1,7 +1,5 @@ """Integration for gate triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/gate/conditions.yaml b/homeassistant/components/gate/conditions.yaml index aea805c2069..ec0b8cf2b77 100644 --- a/homeassistant/components/gate/conditions.yaml +++ b/homeassistant/components/gate/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json index ed1f04b0fc6..d40aae3a4ae 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Gate", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::trigger_for_name%]" } }, "name": "Gate closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::trigger_for_name%]" } }, "name": "Gate opened" diff --git a/homeassistant/components/gate/triggers.yaml b/homeassistant/components/gate/triggers.yaml index b50ae440c36..1381db7cd5c 100644 --- a/homeassistant/components/gate/triggers.yaml +++ b/homeassistant/components/gate/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: closed: fields: *trigger_common_fields diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index 34cbbdbbb1c..7830ed23728 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -1,7 +1,5 @@ """Support for controlling Global Cache gc100.""" -from __future__ import annotations - import gc100 import voluptuous as vol diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index 3dcbb355d3a..20743c99348 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary sensor using GC100.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index bb4742bafdf..aec4290a577 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -1,7 +1,5 @@ """Support for switches using GC100.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 1a8f2fce236..f34375b1155 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -1,7 +1,5 @@ """The Global Disaster Alert and Coordination System (GDACS) integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/gdacs/diagnostics.py b/homeassistant/components/gdacs/diagnostics.py index 9501fb29dd2..ed75f83c00d 100644 --- a/homeassistant/components/gdacs/diagnostics.py +++ b/homeassistant/components/gdacs/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for GDACS integration.""" -from __future__ import annotations - from typing import Any from aio_georss_client.status_update import StatusUpdate diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index e4057633101..9e9da1a72ee 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -1,7 +1,5 @@ """Geolocation support for GDACS Feed.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index f23a02d92b0..3a3411e5640 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -1,7 +1,5 @@ """Feed Entity Manager Sensor support for GDACS Feed.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging diff --git a/homeassistant/components/generic/__init__.py b/homeassistant/components/generic/__init__.py index 5fdb27ce516..29da4e7ac6c 100644 --- a/homeassistant/components/generic/__init__.py +++ b/homeassistant/components/generic/__init__.py @@ -1,7 +1,5 @@ """The generic component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 530d9a0bb9a..fa02d512fa8 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,5 @@ """Support for IP Cameras.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from datetime import datetime, timedelta @@ -137,6 +135,7 @@ class GenericCamera(Camera): return None try: url = self._still_image_url.async_render(parse_result=False) + # pylint: disable-next=home-assistant-action-swallowed-exception except TemplateError as err: _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) return self._last_image diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 4e04b2eae68..21b8670f328 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -1,7 +1,5 @@ """Config flow for generic (IP Camera).""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import contextlib @@ -106,7 +104,6 @@ class InvalidStreamException(HomeAssistantError): def build_schema( is_options_flow: bool = False, - show_advanced_options: bool = False, ) -> vol.Schema: """Create schema for camera config setup.""" rtsp_options = [ @@ -143,8 +140,7 @@ def build_schema( } if is_options_flow: advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool - if show_advanced_options: - advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool + advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool return vol.Schema(spec) @@ -349,7 +345,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} hass = self.hass if user_input: - # Secondary validation because serialised vol can't seem to handle this complexity: + # Secondary validation because serialised vol can't + # seem to handle this complexity: if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( CONF_STREAM_SOURCE ): @@ -433,7 +430,8 @@ class GenericOptionsFlowHandler(OptionsFlow): hass = self.hass if user_input: - # Secondary validation because serialised vol can't seem to handle this complexity: + # Secondary validation because serialised vol can't + # seem to handle this complexity: if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( CONF_STREAM_SOURCE ): @@ -471,10 +469,7 @@ class GenericOptionsFlowHandler(OptionsFlow): return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( - build_schema( - True, - self.show_advanced_options, - ), + build_schema(True), user_input or self.config_entry.options, ), errors=errors, @@ -587,7 +582,10 @@ async def ws_start_preview( ha_stream_url = None if user_input.get(CONF_STILL_IMAGE_URL): - ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}" + ha_still_url = ( + "/api/generic/preview_flow_image" + f"/{msg['flow_id']}?t={datetime.now().isoformat()}" + ) _LOGGER.debug("Got preview still URL: %s", ha_still_url) if ha_stream := flow.preview_stream: diff --git a/homeassistant/components/generic/diagnostics.py b/homeassistant/components/generic/diagnostics.py index 3150ba0cd4c..bb160b30b58 100644 --- a/homeassistant/components/generic/diagnostics.py +++ b/homeassistant/components/generic/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for generic (IP camera).""" -from __future__ import annotations - from typing import Any import yarl diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b6d354b6f60..33463d54dbd 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==16.0.1", "Pillow==12.1.1"] + "requirements": ["av==17.0.1", "Pillow==12.2.0"] } diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index b36cf6ea1fe..58a460e3656 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -40,8 +40,8 @@ "rtsp_transport": "RTSP transport protocol", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, - "description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.", - "name": "Advanced settings" + "description": "These options are only needed for special cases.", + "name": "More options" } } }, diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 5b3edcb976e..624d0fb861b 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import ( config_validation as cv, @@ -28,7 +28,6 @@ CONF_SENSOR = "target_sensor" CONF_MIN_HUMIDITY = "min_humidity" CONF_MAX_HUMIDITY = "max_humidity" CONF_TARGET_HUMIDITY = "target_humidity" -CONF_DEVICE_CLASS = "device_class" CONF_MIN_DUR = "min_cycle_duration" CONF_DRY_TOLERANCE = "dry_tolerance" CONF_WET_TOLERANCE = "wet_tolerance" diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 88cf12d741b..6c221dc9afd 100644 --- a/homeassistant/components/generic_hygrostat/config_flow.py +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Generic hygrostat.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast @@ -10,7 +8,7 @@ import voluptuous as vol from homeassistant.components import fan, switch from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, PERCENTAGE from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaConfigFlowHandler, @@ -18,7 +16,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from . import ( - CONF_DEVICE_CLASS, CONF_DRY_TOLERANCE, CONF_HUMIDIFIER, CONF_MIN_DUR, diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 7746346d010..3acac834512 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -1,7 +1,5 @@ """Adds support for generic hygrostat units.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Mapping from datetime import datetime, timedelta @@ -22,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, @@ -58,7 +57,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( CONF_AWAY_FIXED, CONF_AWAY_HUMIDITY, - CONF_DEVICE_CLASS, CONF_DRY_TOLERANCE, CONF_HUMIDIFIER, CONF_INITIAL_STATE, diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 10b24ec17ca..b7141e5bcb1 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -1,18 +1,18 @@ """Adds support for generic thermostat units.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from datetime import datetime, timedelta from functools import partial import logging import math +import time from typing import Any import voluptuous as vol from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_PRESET_MODE, PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, PRESET_NONE, @@ -51,6 +51,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.entity import CONTEXT_RECENT_TIME_SECONDS from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -451,6 +452,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE) self._target_temp = temperature + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(hvac_mode) + return await self._async_control_heating(force=True) self.async_write_ha_state() @@ -478,6 +482,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return + self.async_set_context(event.context) self._async_update_temp(new_state) await self._async_control_heating() self.async_write_ha_state() @@ -531,9 +536,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): _LOGGER.error("Unable to update from sensor: %s", ex) async def _async_control_heating( - self, time: datetime | None = None, force: bool = False + self, _time: datetime | None = None, force: bool = False ) -> None: """Check if we need to turn heating on or off.""" + called_by_timer = _time is not None + async with self._temp_lock: if not self._active and None not in ( self._cur_temp, @@ -552,7 +559,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not self._active or self._hvac_mode == HVACMode.OFF: return - if force and time is not None and self.max_cycle_duration: + if force and called_by_timer and self.max_cycle_duration: # We were invoked due to `max_cycle_duration`, so turn off _LOGGER.debug( "Turning off heater %s due to max cycle time of %s", @@ -587,7 +594,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): now - self._last_toggled_time + self.min_cycle_duration, self._async_timer_control_heating, ) - elif time is not None: + elif called_by_timer: # This is a keep-alive call, so ensure it's on _LOGGER.debug( "Keep-alive - Turning on heater %s", @@ -609,7 +616,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): now - self._last_toggled_time + self.cycle_cooldown, self._async_timer_control_heating, ) - elif time is not None: + elif called_by_timer: # This is a keep-alive call, so ensure it's off _LOGGER.debug( "Keep-alive - Turning off heater %s", self.heater_entity_id @@ -624,13 +631,25 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return self.hass.states.is_state(self.heater_entity_id, STATE_ON) + def _get_current_context(self) -> Context | None: + """Return the current context if it is still recent, or None.""" + if ( + self._context_set is not None + and time.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS + ): + self._context = None + self._context_set = None + return self._context + async def _async_heater_turn_on(self, keepalive: bool = False) -> None: """Turn heater toggleable device on.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} - # Create a new context for this service call so we can identify - # the resulting state change event as originating from us - new_context = Context(parent_id=self._context.id if self._context else None) - self.async_set_context(new_context) + # Create a child context for the switch service call so we can + # identify the resulting state change event as originating from us. + # Don't set it as our own context — the climate entity's state changes + # should remain attributed to the parent context (e.g., set_hvac_mode). + current_context = self._get_current_context() + new_context = Context(parent_id=current_context.id if current_context else None) self._last_context_id = new_context.id await self.hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context @@ -654,10 +673,12 @@ class GenericThermostat(ClimateEntity, RestoreEntity): async def _async_heater_turn_off(self, keepalive: bool = False) -> None: """Turn heater toggleable device off.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} - # Create a new context for this service call so we can identify - # the resulting state change event as originating from us - new_context = Context(parent_id=self._context.id if self._context else None) - self.async_set_context(new_context) + # Create a child context for the switch service call so we can + # identify the resulting state change event as originating from us. + # Don't set it as our own context — the climate entity's state changes + # should remain attributed to the parent context (e.g., set_hvac_mode). + current_context = self._get_current_context() + new_context = Context(parent_id=current_context.id if current_context else None) self._last_context_id = new_context.id await self.hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context @@ -675,7 +696,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity): f" {self.preset_modes}" ) if preset_mode == self._attr_preset_mode: - # I don't think we need to call async_write_ha_state if we didn't change the state + # I don't think we need to call async_write_ha_state + # if we didn't change the state return if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 564d7bc01a3..e82a00c574b 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Generic hygrostat.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from typing import Any, cast diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 9bc645c6391..f9d6972f9c2 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -1,7 +1,5 @@ """Support for a Genius Hub system.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -146,9 +144,11 @@ def setup_service_functions(hass: HomeAssistant, broker): async_dispatcher_send(hass, DOMAIN, payload) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA ) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index c2f25532453..68fbd75c839 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Genius Hub binary_sensor devices.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 3c5cc4d4ad9..9fc866dfbcc 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -1,7 +1,5 @@ """Support for Genius Hub climate devices.""" -from __future__ import annotations - from homeassistant.components.climate import ( PRESET_ACTIVITY, PRESET_BOOST, diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index b0f2f41fbeb..b6858563fc3 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Geniushub integration.""" -from __future__ import annotations - from http import HTTPStatus import logging import socket diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index de7c047e934..c3c7269a678 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -1,7 +1,5 @@ """Support for Genius Hub sensor devices.""" -from __future__ import annotations - from datetime import timedelta from typing import Any @@ -77,7 +75,7 @@ class GeniusBattery(GeniusDevice, SensorEntity): icon = "mdi:battery" if battery_level <= 95: - icon += f"-{int(round(battery_level / 10 - 0.01)) * 10}" + icon += f"-{round(battery_level / 10 - 0.01) * 10}" return icon diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 874bd0cee7b..f8389481cf4 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -1,7 +1,5 @@ """Support for Genius Hub switch/outlet devices.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index 60acf8f2cca..ce6f181b3b7 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -1,7 +1,5 @@ """Support for Genius Hub water_heater devices.""" -from __future__ import annotations - from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, diff --git a/homeassistant/components/gentex_homelink/__init__.py b/homeassistant/components/gentex_homelink/__init__.py index 68cf0dfac52..f2b4da4d396 100644 --- a/homeassistant/components/gentex_homelink/__init__.py +++ b/homeassistant/components/gentex_homelink/__init__.py @@ -1,7 +1,5 @@ """The homelink integration.""" -from __future__ import annotations - from aiohttp import ClientResponseError from homelink.mqtt_provider import MQTTProvider diff --git a/homeassistant/components/gentex_homelink/coordinator.py b/homeassistant/components/gentex_homelink/coordinator.py index 9e03b16fc79..cfa2063bfbc 100644 --- a/homeassistant/components/gentex_homelink/coordinator.py +++ b/homeassistant/components/gentex_homelink/coordinator.py @@ -1,7 +1,5 @@ """Establish MQTT connection and listen for event data.""" -from __future__ import annotations - from collections.abc import Callable from functools import partial from typing import TypedDict diff --git a/homeassistant/components/gentex_homelink/event.py b/homeassistant/components/gentex_homelink/event.py index 213502c9970..b1838e50b9e 100644 --- a/homeassistant/components/gentex_homelink/event.py +++ b/homeassistant/components/gentex_homelink/event.py @@ -1,7 +1,5 @@ """Platform for Event integration.""" -from __future__ import annotations - from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/gentex_homelink/manifest.json b/homeassistant/components/gentex_homelink/manifest.json index 57ce93f674d..34110d6db82 100644 --- a/homeassistant/components/gentex_homelink/manifest.json +++ b/homeassistant/components/gentex_homelink/manifest.json @@ -1,11 +1,12 @@ { "domain": "gentex_homelink", "name": "HomeLink", - "codeowners": ["@niaexa", "@ryanjones-gentex"], + "codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/gentex_homelink", + "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["homelink-integration-api==0.0.1"] + "requirements": ["homelink-integration-api==0.0.5"] } diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index e38c17008a5..22c17b8655a 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -1,7 +1,5 @@ """The GeoJSON events component.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py index 65e5d2b1c75..9d7c2261fd5 100644 --- a/homeassistant/components/geo_json_events/config_flow.py +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the GeoJSON events integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py index 679e8f2e565..a8e5c8b0e02 100644 --- a/homeassistant/components/geo_json_events/const.py +++ b/homeassistant/components/geo_json_events/const.py @@ -1,7 +1,5 @@ """Define constants for the GeoJSON events integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index a119571a0ca..d14fd805582 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -1,7 +1,5 @@ """Support for generic GeoJSON events.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index 223d3bf571f..e54a991ed35 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -1,7 +1,5 @@ """Entity manager for generic GeoJSON events.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 06b0320c805..5ab34d4a8e8 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -1,7 +1,5 @@ """Support for Geolocation.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, final diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index ab5bde3682e..6ab87f3da8e 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -1,7 +1,5 @@ """Offer geolocation automation rules.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 34f5283b50c..959093c279e 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -5,8 +5,6 @@ shows information on events filtered by distance to the HA instance's location and grouped by category. """ -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/geocaching/config_flow.py b/homeassistant/components/geocaching/config_flow.py index 05676cc346e..f9f4d1c2a91 100644 --- a/homeassistant/components/geocaching/config_flow.py +++ b/homeassistant/components/geocaching/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Geocaching.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/geocaching/const.py b/homeassistant/components/geocaching/const.py index 8c255f5452a..836b8c8ce3b 100644 --- a/homeassistant/components/geocaching/const.py +++ b/homeassistant/components/geocaching/const.py @@ -1,7 +1,5 @@ """Constants for the Geocaching integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py index bfe82069650..a3dfeaf878b 100644 --- a/homeassistant/components/geocaching/coordinator.py +++ b/homeassistant/components/geocaching/coordinator.py @@ -1,7 +1,5 @@ """Provides the Geocaching DataUpdateCoordinator.""" -from __future__ import annotations - from geocachingapi.exceptions import GeocachingApiError, GeocachingInvalidSettingsError from geocachingapi.geocachingapi import GeocachingApi from geocachingapi.models import GeocachingStatus diff --git a/homeassistant/components/geocaching/entity.py b/homeassistant/components/geocaching/entity.py index 6912b65ec04..fb5388aa578 100644 --- a/homeassistant/components/geocaching/entity.py +++ b/homeassistant/components/geocaching/entity.py @@ -29,8 +29,10 @@ class GeocachingCacheEntity(GeocachingBaseEntity): super().__init__(coordinator) self.cache = cache - # A device can have multiple entities, and for a cache which requires multiple entities we want to group them together. - # Therefore, we create a device for each cache, which holds all related entities. + # A device can have multiple entities, and for a cache + # which requires multiple entities we want to group them + # together. Therefore, we create a device for each cache, + # which holds all related entities. self._attr_device_info = DeviceInfo( name=f"Geocache {cache.name}", identifiers={(DOMAIN, cast(str, cache.reference_code))}, diff --git a/homeassistant/components/geocaching/oauth.py b/homeassistant/components/geocaching/oauth.py index c872f9a7522..06c5e62237a 100644 --- a/homeassistant/components/geocaching/oauth.py +++ b/homeassistant/components/geocaching/oauth.py @@ -1,7 +1,5 @@ """oAuth2 functions and classes for Geocaching API integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.application_credentials import ( @@ -48,7 +46,8 @@ class GeocachingOAuth2Implementation(AuthImplementation): "redirect_uri": redirect_uri, } token = await self._token_request(data) - # Store the redirect_uri (Needed for refreshing token, but not according to oAuth2 spec!) + # Store the redirect_uri + # (Needed for refreshing token, but not per oAuth2 spec!) token["redirect_uri"] = redirect_uri return token @@ -59,7 +58,8 @@ class GeocachingOAuth2Implementation(AuthImplementation): "client_secret": self.client_secret, "grant_type": "refresh_token", "refresh_token": token["refresh_token"], - # Add previously stored redirect_uri (Mandatory, but not according to oAuth2 spec!) + # Add previously stored redirect_uri + # (Mandatory, but not per oAuth2 spec!) "redirect_uri": token["redirect_uri"], } diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index daf64546f47..80e8d2cb29a 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import datetime @@ -117,7 +115,9 @@ async def async_setup_entry( # Base class for a cache entity. -# Sets the device, ID and translation settings to correctly group the entity to the correct cache device and give it the correct name. +# Sets the device, ID and translation settings to correctly +# group the entity to the correct cache device and give it +# the correct name. class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity): """Base class for cache entities.""" @@ -132,7 +132,8 @@ class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity): self._attr_unique_id = f"{cache.reference_code}_{key}" - # The translation key determines the name of the entity as this is the lookup for the `strings.json` file. + # The translation key determines the name of the entity + # as this is the lookup for the `strings.json` file. self._attr_translation_key = f"cache_{key}" diff --git a/homeassistant/components/geonetnz_quakes/diagnostics.py b/homeassistant/components/geonetnz_quakes/diagnostics.py index ebb6a2e9046..d3b0040cb2c 100644 --- a/homeassistant/components/geonetnz_quakes/diagnostics.py +++ b/homeassistant/components/geonetnz_quakes/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for GeoNet NZ Quakes Feeds integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index e67d22c850f..599e168240f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -1,7 +1,5 @@ """Geolocation support for GeoNet NZ Quakes Feeds.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index d817a62dffb..2003a3495af 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -1,7 +1,5 @@ """Feed Entity Manager Sensor support for GeoNet NZ Quakes Feeds.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index c3ceeab33f8..380ea31d852 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -1,7 +1,5 @@ """The GeoNet NZ Volcano integration.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 55fb7a477bf..d1f0c721b81 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -1,7 +1,5 @@ """Feed Entity Manager Sensor support for GeoNet NZ Volcano Feeds.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ghost/__init__.py b/homeassistant/components/ghost/__init__.py index cc1182bd1c3..9c162bd2cd3 100644 --- a/homeassistant/components/ghost/__init__.py +++ b/homeassistant/components/ghost/__init__.py @@ -1,7 +1,5 @@ """The Ghost integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/ghost/config_flow.py b/homeassistant/components/ghost/config_flow.py index 44d6600e55d..4e02e452a23 100644 --- a/homeassistant/components/ghost/config_flow.py +++ b/homeassistant/components/ghost/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ghost integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/ghost/coordinator.py b/homeassistant/components/ghost/coordinator.py index 3e9b712b86f..25efcdffa03 100644 --- a/homeassistant/components/ghost/coordinator.py +++ b/homeassistant/components/ghost/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Ghost.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/ghost/diagnostics.py b/homeassistant/components/ghost/diagnostics.py index db24c9de6a4..8f3982234a6 100644 --- a/homeassistant/components/ghost/diagnostics.py +++ b/homeassistant/components/ghost/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Ghost.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/ghost/icons.json b/homeassistant/components/ghost/icons.json index 65d12a9bd94..e02b11e525d 100644 --- a/homeassistant/components/ghost/icons.json +++ b/homeassistant/components/ghost/icons.json @@ -13,6 +13,9 @@ "free_members": { "default": "mdi:account-outline" }, + "gift_members": { + "default": "mdi:gift-outline" + }, "latest_email": { "default": "mdi:email-newsletter" }, diff --git a/homeassistant/components/ghost/manifest.json b/homeassistant/components/ghost/manifest.json index 41546c0ee6b..db00e03ff1c 100644 --- a/homeassistant/components/ghost/manifest.json +++ b/homeassistant/components/ghost/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioghost"], "quality_scale": "gold", - "requirements": ["aioghost==0.4.0"] + "requirements": ["aioghost==0.4.16"] } diff --git a/homeassistant/components/ghost/sensor.py b/homeassistant/components/ghost/sensor.py index 9fd3ea977c6..731fc8d3360 100644 --- a/homeassistant/components/ghost/sensor.py +++ b/homeassistant/components/ghost/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Ghost.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -72,6 +70,12 @@ SENSORS: tuple[GhostSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, value_fn=lambda data: data.members.get("comped", 0), ), + GhostSensorEntityDescription( + key="gift_members", + translation_key="gift_members", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.members.get("gift", 0), + ), # Post metrics GhostSensorEntityDescription( key="published_posts", diff --git a/homeassistant/components/ghost/strings.json b/homeassistant/components/ghost/strings.json index 7713705e4e1..ea7ba971f78 100644 --- a/homeassistant/components/ghost/strings.json +++ b/homeassistant/components/ghost/strings.json @@ -62,6 +62,9 @@ "free_members": { "name": "Free members" }, + "gift_members": { + "name": "Gift members" + }, "latest_email": { "name": "Latest email" }, diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index e19b1d280d2..712dfc95613 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -1,7 +1,5 @@ """The GIOS component.""" -from __future__ import annotations - import logging from aiohttp.client_exceptions import ClientConnectorError diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index eb83e92bc03..27178549028 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for GIOS.""" -from __future__ import annotations - import asyncio from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 2d21b0b8d9e..a71f35515a7 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -1,7 +1,5 @@ """Constants for GIOS integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index 60525b33edf..cc7b6f6d5c2 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -1,7 +1,5 @@ """The GIOS component.""" -from __future__ import annotations - import asyncio import logging from typing import TYPE_CHECKING @@ -50,7 +48,8 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): if TYPE_CHECKING: # Station ID is Optional in the library, but here we know it is set for sure # so we can safely assert it is not None for type checking purposes - # Gios instance is created only with a valid station ID in the async_setup_entry. + # Gios instance is created only with a valid station + # ID in the async_setup_entry. assert station_id is not None self.device_info = DeviceInfo( diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index e25f56dcbc7..3fcb9ae9e21 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for GIOS.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index e92e14ae555..c341f397da7 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==7.0.0"] + "requirements": ["gios==7.1.0"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 5304fb98cf2..5ef40cea4b3 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -1,7 +1,5 @@ """Support for the GIOS service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/gios/system_health.py b/homeassistant/components/gios/system_health.py index 46fe78556e2..2b5b632ec91 100644 --- a/homeassistant/components/gios/system_health.py +++ b/homeassistant/components/gios/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any, Final from homeassistant.components import system_health diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index df50039b03f..1309f4b58d6 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -1,18 +1,19 @@ """The GitHub integration.""" -from __future__ import annotations +from types import MappingProxyType from aiogithubapi import GitHubAPI +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_ACCESS_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -from .const import CONF_REPOSITORIES, DOMAIN, LOGGER +from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -26,10 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo client_name=SERVER_SOFTWARE, ) - repositories: list[str] = entry.options[CONF_REPOSITORIES] - entry.runtime_data = {} - for repository in repositories: + for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY): + repository = repository_subentry.data[CONF_REPOSITORY] coordinator = GitHubDataUpdateCoordinator( hass=hass, config_entry=entry, @@ -42,41 +42,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo if not entry.pref_disable_polling: await coordinator.subscribe() - entry.runtime_data[repository] = coordinator + entry.runtime_data[repository_subentry.subentry_id] = coordinator - async_cleanup_device_registry(hass=hass, entry=entry) + entry.async_on_unload(entry.add_update_listener(async_update_entry)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -@callback -def async_cleanup_device_registry( - hass: HomeAssistant, - entry: GithubConfigEntry, -) -> None: - """Remove entries form device registry if we no longer track the repository.""" - device_registry = dr.async_get(hass) - devices = dr.async_entries_for_config_entry( - registry=device_registry, - config_entry_id=entry.entry_id, - ) - for device in devices: - for item in device.identifiers: - if item[0] == DOMAIN and item[1] not in entry.options[CONF_REPOSITORIES]: - LOGGER.debug( - ( - "Unlinking device %s for untracked repository %s from config" - " entry %s" - ), - device.id, - item[1], - entry.entry_id, - ) - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - break +async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: + """Update entry.""" + await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool: @@ -86,3 +62,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool: + """Migrate old entry.""" + if entry.minor_version == 1: + dev_reg = dr.async_get(hass) + # In minor version 2 we migrated repositories from entry options to + # subentries, so we need to convert the list from + # entry.options[CONF_REPOSITORIES] into individual subentries. + for repository in entry.options[CONF_REPOSITORIES]: + subentry = ConfigSubentry( + data=MappingProxyType({CONF_REPOSITORY: repository}), + subentry_type=SUBENTRY_TYPE_REPOSITORY, + title=repository, + unique_id=repository, + ) + hass.config_entries.async_add_subentry(entry, subentry) + if device := dev_reg.async_get_device({(DOMAIN, repository)}): + dev_reg.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=entry.entry_id, + ) + hass.config_entries.async_update_entry(entry, minor_version=2) + return True diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index a2a7e56830f..9ebfdd8f4d6 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -1,7 +1,5 @@ """Config flow for GitHub integration.""" -from __future__ import annotations - import asyncio from typing import TYPE_CHECKING, Any @@ -19,23 +17,31 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + SubentryFlowResult, ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER +from .const import ( + CLIENT_ID, + CONF_REPOSITORY, + DEFAULT_REPOSITORIES, + DOMAIN, + LOGGER, + SUBENTRY_TYPE_REPOSITORY, +) async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: """Return a list of repositories that the user owns or has starred.""" client = GitHubAPI(token=access_token, session=async_get_clientsession(hass)) - repositories = set() + repositories: set[str] = set() async def _get_starred_repositories() -> None: response = await client.user.starred(params={"per_page": 100}) @@ -53,7 +59,7 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: for result in results: response.data.extend(result.data) - repositories.update(response.data) + repositories.update(repo.full_name for repo in response.data) async def _get_personal_repositories() -> None: response = await client.user.repos(params={"per_page": 100}) @@ -71,7 +77,7 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: for result in results: response.data.extend(result.data) - repositories.update(response.data) + repositories.update(repo.full_name for repo in response.data) try: await asyncio.gather( @@ -82,21 +88,26 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: ) except GitHubException: - return DEFAULT_REPOSITORIES + repositories.update(DEFAULT_REPOSITORIES) if len(repositories) == 0: - return DEFAULT_REPOSITORIES + repositories.update(DEFAULT_REPOSITORIES) - return sorted( - (repo.full_name for repo in repositories), - key=str.casefold, - ) + current_repositories = { + subentry.data[CONF_REPOSITORY] + for entry in hass.config_entries.async_entries(DOMAIN) + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_REPOSITORY + } + repositories = repositories - current_repositories + + return sorted(repositories, key=str.casefold) class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for GitHub.""" - VERSION = 1 + MINOR_VERSION = 2 login_task: asyncio.Task | None = None @@ -106,6 +117,14 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): self._login: GitHubLoginOauthModel | None = None self._login_device: GitHubLoginDeviceModel | None = None + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {SUBENTRY_TYPE_REPOSITORY: RepositoryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None, @@ -124,7 +143,8 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): async def _wait_for_login() -> None: if TYPE_CHECKING: - # mypy is not aware that we can't get here without having these set already + # mypy is not aware that we can't get here + # without having these set already assert self._device is not None assert self._login_device is not None @@ -153,7 +173,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): if self.login_task.done(): if self.login_task.exception(): return self.async_show_progress_done(next_step_id="could_not_register") - return self.async_show_progress_done(next_step_id="repositories") + return self.async_show_progress_done(next_step_id="done") if TYPE_CHECKING: # mypy is not aware that we can't get here without having this set already @@ -169,33 +189,18 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): progress_task=self.login_task, ) - async def async_step_repositories( + async def async_step_done( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: - """Handle repositories step.""" + """Create the config entry after successful device authentication.""" if TYPE_CHECKING: - # mypy is not aware that we can't get here without having this set already assert self._login is not None - if not user_input: - repositories = await get_repositories(self.hass, self._login.access_token) - return self.async_show_form( - step_id="repositories", - data_schema=vol.Schema( - { - vol.Required(CONF_REPOSITORIES): cv.multi_select( - {k: k for k in repositories} - ), - } - ), - ) - return self.async_create_entry( title="", data={CONF_ACCESS_TOKEN: self._login.access_token}, - options={CONF_REPOSITORIES: user_input[CONF_REPOSITORIES]}, ) async def async_step_could_not_register( @@ -205,46 +210,31 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): """Handle issues that need transition await from progress step.""" return self.async_abort(reason="could_not_register") - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlowHandler: - """Get the options flow for this handler.""" - return OptionsFlowHandler() +class RepositoryFlowHandler(ConfigSubentryFlow): + """Handle repository subentry flow.""" -class OptionsFlowHandler(OptionsFlowWithReload): - """Handle a option flow for GitHub.""" - - async def async_step_init( - self, - user_input: dict[str, Any] | None = None, - ) -> ConfigFlowResult: - """Handle options flow.""" + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle repository subentry flow.""" if not user_input: - configured_repositories: list[str] = self.config_entry.options[ - CONF_REPOSITORIES - ] repositories = await get_repositories( - self.hass, self.config_entry.data[CONF_ACCESS_TOKEN] + self.hass, self._get_entry().data[CONF_ACCESS_TOKEN] ) - # In case the user has removed a starred repository that is already tracked - for repository in configured_repositories: - if repository not in repositories: - repositories.append(repository) - return self.async_show_form( - step_id="init", + step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_REPOSITORIES, - default=configured_repositories, - ): cv.multi_select({k: k for k in repositories}), + vol.Required(CONF_REPOSITORY): SelectSelector( + SelectSelectorConfig(sort=True, options=repositories) + ), } ), ) + repository = user_input[CONF_REPOSITORY] - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry( + title=repository, data=user_input, unique_id=repository + ) diff --git a/homeassistant/components/github/const.py b/homeassistant/components/github/const.py index df44860b780..018f302018b 100644 --- a/homeassistant/components/github/const.py +++ b/homeassistant/components/github/const.py @@ -1,7 +1,5 @@ """Constants for the GitHub integration.""" -from __future__ import annotations - from datetime import timedelta from logging import Logger, getLogger @@ -15,6 +13,9 @@ DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"] FALLBACK_UPDATE_INTERVAL = timedelta(hours=1, minutes=30) CONF_REPOSITORIES = "repositories" +CONF_REPOSITORY = "repository" + +SUBENTRY_TYPE_REPOSITORY = "repository" REFRESH_EVENT_TYPES = ( diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py index d50728d47c3..fbb5b20384f 100644 --- a/homeassistant/components/github/coordinator.py +++ b/homeassistant/components/github/coordinator.py @@ -1,7 +1,5 @@ """Custom data update coordinator for the GitHub integration.""" -from __future__ import annotations - from typing import Any from aiogithubapi import ( diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index 41fef9406a4..67a5fe233ce 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the GitHub integration.""" -from __future__ import annotations - from typing import Any from aiogithubapi import GitHubAPI, GitHubException @@ -21,7 +19,7 @@ async def async_get_config_entry_diagnostics( config_entry: GithubConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = {"options": {**config_entry.options}} + data: dict[str, Any] = {} client = GitHubAPI( token=config_entry.data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass), @@ -38,7 +36,7 @@ async def async_get_config_entry_diagnostics( repositories = config_entry.runtime_data data["repositories"] = {} - for repository, coordinator in repositories.items(): - data["repositories"][repository] = coordinator.data + for coordinator in repositories.values(): + data["repositories"][coordinator.data["full_name"]] = coordinator.data return data diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 744fb23001e..5cc33a9636c 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the GitHub integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any @@ -150,13 +148,14 @@ async def async_setup_entry( ) -> None: """Set up GitHub sensor based on a config entry.""" repositories = entry.runtime_data - async_add_entities( - ( - GitHubSensorEntity(coordinator, description) - for description in SENSOR_DESCRIPTIONS - for coordinator in repositories.values() - ), - ) + for subentry_id, coordinator in repositories.items(): + async_add_entities( + ( + GitHubSensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry_id, + ) class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorEntity): diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 808e87bfe3f..7c21e979441 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -7,12 +7,26 @@ "progress": { "wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```" }, - "step": { - "repositories": { - "data": { - "repositories": "Select repositories to track." - }, - "title": "Configure repositories" + "step": {} + }, + "config_subentries": { + "repository": { + "abort": { + "already_configured": "Repository is already configured" + }, + "entry_type": "[%key:component::github::config_subentries::repository::step::user::data::repository%]", + "initiate_flow": { + "user": "Add repository" + }, + "step": { + "user": { + "data": { + "repository": "Repository" + }, + "data_description": { + "repository": "The repository to track" + } + } } } }, diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 933ba0e482e..9cfe4485fd5 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -1,7 +1,5 @@ """Sensor for retrieving latest GitLab CI job information.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 950dc319da4..de2b3459402 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -1,7 +1,5 @@ """Support for displaying details about a Gitter.im chat room.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index fb314364d43..8961f2be72e 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Glances.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/glances/icons.json b/homeassistant/components/glances/icons.json index 0a9d2888339..2c0a845b95f 100644 --- a/homeassistant/components/glances/icons.json +++ b/homeassistant/components/glances/icons.json @@ -13,6 +13,9 @@ "disk_free": { "default": "mdi:harddisk" }, + "disk_size": { + "default": "mdi:harddisk" + }, "disk_usage": { "default": "mdi:harddisk" }, diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 1646b04cedb..1f04003802b 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances-api==0.8.0"] + "requirements": ["glances-api==0.10.0"] } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index c618c674a8b..f86befe762d 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -1,7 +1,5 @@ """Support gathering system information of hosts which are running Glances.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( @@ -49,6 +47,14 @@ SENSOR_TYPES = { device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), + ("fs", "disk_size"): GlancesSensorEntityDescription( + key="disk_size", + type="fs", + translation_key="disk_size", + native_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 3d90310366b..9242893a9dc 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -50,6 +50,9 @@ "disk_free": { "name": "{sensor_label} disk free" }, + "disk_size": { + "name": "{sensor_label} disk size" + }, "disk_usage": { "name": "{sensor_label} disk usage" }, diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index cde9b5c8367..de270c07fef 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,7 +1,5 @@ """The go2rtc component.""" -from __future__ import annotations - from dataclasses import dataclass import logging from secrets import token_hex @@ -67,7 +65,7 @@ from .const import ( RECOMMENDED_VERSION, ) from .server import Server -from .util import get_go2rtc_unix_socket_path +from .util import get_camera_identifier, get_go2rtc_unix_socket_path _LOGGER = logging.getLogger(__name__) @@ -76,7 +74,7 @@ _AUTH = "auth" def _validate_auth(config: dict) -> dict: - """Validate that username and password are only set when a URL is configured or when debug UI is enabled.""" + """Validate username/password only when URL is configured or debug UI enabled.""" auth_exists = CONF_USERNAME in config debug_ui_enabled = config.get(CONF_DEBUG_UI, False) @@ -85,7 +83,8 @@ def _validate_auth(config: dict) -> dict: if auth_exists and CONF_URL not in config and not debug_ui_enabled: raise vol.Invalid( - "Username and password can only be set when a URL is configured or debug_ui is true" + "Username and password can only be set when a URL is" + " configured or debug_ui is true" ) return config @@ -175,6 +174,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await server.start() except Exception: # noqa: BLE001 _LOGGER.warning("Could not start go2rtc server", exc_info=True) + await session.close() return False async def on_stop(event: Event) -> None: @@ -307,7 +307,7 @@ class WebRTCProvider(CameraWebRTCProvider): return self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._url, source=get_camera_identifier(camera) ) @callback @@ -353,7 +353,7 @@ class WebRTCProvider(CameraWebRTCProvider): """Get an image from the camera.""" await self._update_stream_source(camera) return await self._rest_client.get_jpeg_snapshot( - camera.entity_id, width, height + get_camera_identifier(camera), width, height ) async def _update_stream_source(self, camera: Camera) -> None: @@ -364,7 +364,8 @@ class WebRTCProvider(CameraWebRTCProvider): if camera.platform.platform_name == "generic": # This is a workaround to use ffmpeg for generic cameras - # A proper fix will be added in the future together with supporting multiple streams per camera + # A proper fix will be added in the future together + # with supporting multiple streams per camera stream_source = "ffmpeg:" + stream_source if not self.async_is_supported(stream_source): @@ -398,18 +399,20 @@ class WebRTCProvider(CameraWebRTCProvider): stream_source += "#rotate=90" streams = await self._rest_client.streams.list() + identifier = get_camera_identifier(camera) - if (stream := streams.get(camera.entity_id)) is None or not any( + if (stream := streams.get(identifier)) is None or not any( stream_source == producer.url for producer in stream.producers ): await self._rest_client.streams.add( - camera.entity_id, + identifier, [ stream_source, # We are setting any ffmpeg rtsp related logs to debug - # Connection problems to the camera will be logged by the first stream + # Connection problems to the camera will be + # logged by the first stream # Therefore setting it to debug will not hide any important logs - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) diff --git a/homeassistant/components/go2rtc/config_flow.py b/homeassistant/components/go2rtc/config_flow.py index 02fdfb656a6..2cd887e9dfc 100644 --- a/homeassistant/components/go2rtc/config_flow.py +++ b/homeassistant/components/go2rtc/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the go2rtc integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 55316f71fff..6b11322cfe9 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,6 +6,5 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA) -# in script/hassfest/docker.py. +# Kept in sync with the go2rtc image pinned in the root Dockerfile by Renovate. RECOMMENDED_VERSION = "1.9.14" diff --git a/homeassistant/components/go2rtc/util.py b/homeassistant/components/go2rtc/util.py index 6e47075dbf9..a19f57f4383 100644 --- a/homeassistant/components/go2rtc/util.py +++ b/homeassistant/components/go2rtc/util.py @@ -1,8 +1,15 @@ """Go2rtc utility functions.""" from pathlib import Path +import string +from urllib.parse import quote + +from homeassistant.components.camera import Camera _HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock" +# Go2rtc is not validating the camera identifier, but some characters (e.g. : or #) +# have special meaning in URLs and could cause issues. +_SAFE_CHARS = string.ascii_letters + string.digits + "._-" def get_go2rtc_unix_socket_path(path: str | Path) -> str: @@ -10,3 +17,11 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str: if not isinstance(path, Path): path = Path(path) return str(path / _HA_MANAGED_UNIX_SOCKET_FILE) + + +def get_camera_identifier(camera: Camera) -> str: + """Get the Go2rtc camera identifier.""" + attr = camera.entity_id + if camera.unique_id is not None: + attr = f"{camera.platform.platform_name}_{camera.unique_id}" + return quote(attr, safe=_SAFE_CHARS) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 4a34927a585..b94ff7563a6 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,7 +1,5 @@ """The Goal Zero Yeti integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from goalzero import Yeti, exceptions diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 86287dc35eb..95f751b6e58 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Goal Zero Yeti Sensors.""" -from __future__ import annotations - from typing import cast from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 9764d36e42c..a988e8b703b 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Goal Zero Yeti integration.""" -from __future__ import annotations - import logging from typing import Any @@ -92,6 +90,8 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_HOST, default=user_input.get(CONF_HOST) or "" ): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional( CONF_NAME, default=user_input.get(CONF_NAME) or DEFAULT_NAME ): str, diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 67441930f7a..3f121ff4fc0 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -1,7 +1,5 @@ """Support for Goal Zero Yeti Sensors.""" -from __future__ import annotations - from typing import cast from homeassistant.components.sensor import ( diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index 00a1ad936d8..9c9c9fa1b71 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,7 +1,5 @@ """Support for Goal Zero Yeti Switches.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index a98e1194e5b..e912855a365 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -1,7 +1,5 @@ """Common code for GogoGate2 component.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta import logging diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index cebff656d5d..6d2555bbd23 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Gogogate2.""" -from __future__ import annotations - import dataclasses import logging import re @@ -75,7 +73,7 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._ip_address == self._ip_address # noqa: SLF001 + return other_flow._ip_address == self._ip_address async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py index 5f5a082084c..6fc1fcd2d6b 100644 --- a/homeassistant/components/gogogate2/coordinator.py +++ b/homeassistant/components/gogogate2/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for GogoGate2 component.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from datetime import timedelta import logging diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 539e53598fb..24b0ac8e66b 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,7 +1,5 @@ """Support for Gogogate2 garage Doors.""" -from __future__ import annotations - from typing import Any from ismartgate.common import ( diff --git a/homeassistant/components/gogogate2/entity.py b/homeassistant/components/gogogate2/entity.py index f82e4d1f150..6684807562e 100644 --- a/homeassistant/components/gogogate2/entity.py +++ b/homeassistant/components/gogogate2/entity.py @@ -1,7 +1,5 @@ """Common code for GogoGate2 component.""" -from __future__ import annotations - from typing import Any from ismartgate.common import AbstractDoor, get_door_by_id diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 4e4fa908b8f..3cce8d5e7ca 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -1,7 +1,5 @@ """Support for Gogogate2 garage Doors.""" -from __future__ import annotations - from itertools import chain from typing import Any diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index d191ecb15a2..abf95bd3a10 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo host=host, port=port, family=model_family, - retries=10, + retries=3, ) except InverterError as err: try: @@ -64,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo async def async_check_port( hass: HomeAssistant, entry: GoodweConfigEntry, host: str ) -> Inverter: - """Check the communication port of the inverter, it may have changed after a firmware update.""" + """Check the communication port of the inverter. + + It may have changed after a firmware update. + """ inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host) family = type(inverter).__name__ hass.config_entries.async_update_entry( diff --git a/homeassistant/components/goodwe/config_flow.py b/homeassistant/components/goodwe/config_flow.py index 5faa2b86768..b39ffb6d2a2 100644 --- a/homeassistant/components/goodwe/config_flow.py +++ b/homeassistant/components/goodwe/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Goodwe inverters using their local API.""" -from __future__ import annotations - import logging from typing import Any @@ -73,8 +71,8 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): """Detects the port of the Inverter.""" port = GOODWE_UDP_PORT try: - inverter = await connect(host=host, port=port, retries=10) + inverter = await connect(host=host, port=port, retries=3) except InverterError: port = GOODWE_TCP_PORT - inverter = await connect(host=host, port=port, retries=10) + inverter = await connect(host=host, port=port, retries=3) return inverter, port diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 3d3f2834197..d646c586820 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -1,7 +1,5 @@ """Update coordinator for Goodwe.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py index ece5f3b6507..1711245c74c 100644 --- a/homeassistant/components/goodwe/diagnostics.py +++ b/homeassistant/components/goodwe/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Goodwe.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index f11f8d9d97a..cde4977e370 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -1,7 +1,5 @@ """GoodWe PV inverter numeric settings entities.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index 7a28a632060..505708b518b 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -69,7 +69,8 @@ async def async_setup_entry( ) else: _LOGGER.warning( - "Active mode %s not found in Goodwe Inverter Operation Mode Entity. Skipping entity creation", + "Active mode %s not found in Goodwe Inverter Operation" + " Mode Entity. Skipping entity creation", active_mode, ) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 110e26ae5e2..c23e6ff0774 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -1,7 +1,5 @@ """Support for GoodWe inverter via UDP.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timedelta @@ -41,16 +39,25 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import GoodweConfigEntry, GoodweUpdateCoordinator +# Coordinator handles all data updates, so parallel updates are not needed +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) # Sensor name of battery SoC BATTERY_SOC = "battery_soc" # Sensors that are reset to 0 at midnight. -# The inverter is only powered by the solar panels and not mains power, so it goes dead when the sun goes down. -# The "_day" sensors are reset to 0 when the inverter wakes up in the morning when the sun comes up and power to the inverter is restored. -# This makes sure daily values are reset at midnight instead of at sunrise. -# When the inverter has a battery connected, HomeAssistant will not reset the values but let the inverter reset them by looking at the unavailable state of the inverter. +# The inverter is only powered by the solar panels and not +# mains power, so it goes dead when the sun goes down. +# The "_day" sensors are reset to 0 when the inverter wakes +# up in the morning when the sun comes up and power to the +# inverter is restored. +# This makes sure daily values are reset at midnight instead +# of at sunrise. +# When the inverter has a battery connected, HomeAssistant +# will not reset the values but let the inverter reset them +# by looking at the unavailable state of the inverter. DAILY_RESET = ["e_day", "e_load_day"] _MAIN_SENSORS = ( @@ -241,7 +248,8 @@ class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): Some sensors values like daily produced energy are kept available, even when the inverter is in sleep mode and no longer responds to request. - In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. + In contrast to "total" sensors, these "daily" sensors + need to be reset to 0 on midnight. """ if not self.coordinator.last_update_success: self.coordinator.reset_sensor(self._sensor.id_) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index edc7dc50967..0d70d872baa 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,7 +1,5 @@ """Support for Google - Calendar Event Devices.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index efbbec73017..fd3fef980e4 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -1,7 +1,5 @@ """Client library for talking to Google APIs.""" -from __future__ import annotations - import datetime import logging from typing import Any, cast diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 35b612cdc24..ef704999ef2 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -1,7 +1,5 @@ """Support for Google Calendar Search binary sensors.""" -from __future__ import annotations - from collections.abc import Mapping import dataclasses from datetime import datetime, timedelta @@ -145,7 +143,7 @@ def _get_entity_descriptions( local_sync = True if ( search := data.get(CONF_SEARCH) - ) or calendar_item.access_role == AccessRole.FREE_BUSY_READER: + ) or calendar_item.access_role is AccessRole.FREE_BUSY_READER: read_only = True local_sync = False entity_description = GoogleCalendarEntityDescription( @@ -388,14 +386,14 @@ class GoogleCalendarEntity( """Return True if the event is visible and not declined.""" if any( - attendee.is_self and attendee.response_status == ResponseStatus.DECLINED + attendee.is_self and attendee.response_status is ResponseStatus.DECLINED for attendee in event.attendees ): return False # Calendar enttiy may be limited to a specific event type if ( self.entity_description.event_type is not None - and self.entity_description.event_type != event.event_type + and self.entity_description.event_type is not event.event_type ): return False # Default calendar entity omits the special types but includes all the others @@ -512,7 +510,8 @@ class GoogleCalendarEntity( def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" rrule: str | None = None - # Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored + # Home Assistant expects a single RRULE: and all other + # rule types are unsupported or ignored if ( len(event.recurrence) == 1 and (raw_rule := event.recurrence[0]) @@ -539,18 +538,18 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if EVENT_IN in call.data: if EVENT_IN_DAYS in call.data[EVENT_IN]: - now = datetime.now().date() + today = dt_util.now().date() - start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) + start_in = today + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) end_in = start_in + timedelta(days=1) start = DateOrDatetime(date=start_in) end = DateOrDatetime(date=end_in) elif EVENT_IN_WEEKS in call.data[EVENT_IN]: - now = datetime.now().date() + today = dt_util.now().date() - start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + start_in = today + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) end_in = start_in + timedelta(days=1) start = DateOrDatetime(date=start_in) diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index a998ea70d00..12594979e6e 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -194,7 +192,8 @@ class OAuth2FlowHandler( primary_calendar = await calendar_service.async_get_calendar("primary") except ApiForbiddenException as err: _LOGGER.error( - "Error reading primary calendar, make sure Google Calendar API is enabled: %s", + "Error reading primary calendar, make sure" + " Google Calendar API is enabled: %s", err, ) return self.async_abort( diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6613668cf91..c8437cba655 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -1,7 +1,5 @@ """Constants for google integration.""" -from __future__ import annotations - from enum import Enum, StrEnum DOMAIN = "google" diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py index 9f51c60b069..5d80adef452 100644 --- a/homeassistant/components/google/coordinator.py +++ b/homeassistant/components/google/coordinator.py @@ -1,7 +1,5 @@ """Support for Google Calendar Search binary sensors.""" -from __future__ import annotations - from collections.abc import Iterable from datetime import datetime, timedelta import itertools diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 72bfd94ce73..7dcb3d48e91 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -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.2"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"] } diff --git a/homeassistant/components/google/store.py b/homeassistant/components/google/store.py index 4936a86f384..7fb8686e7c5 100644 --- a/homeassistant/components/google/store.py +++ b/homeassistant/components/google/store.py @@ -1,7 +1,5 @@ """Google Calendar local storage.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/google_air_quality/__init__.py b/homeassistant/components/google_air_quality/__init__.py index fdefc309ac7..026b9fc96c6 100644 --- a/homeassistant/components/google_air_quality/__init__.py +++ b/homeassistant/components/google_air_quality/__init__.py @@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType -from .const import CONF_REFERRER +from .const import CONF_REFERRER, DOMAIN from .coordinator import ( GoogleAirQualityConfigEntry, GoogleAirQualityRuntimeData, GoogleAirQualityUpdateCoordinator, ) +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.SENSOR, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Google Air Quality integration.""" + async_setup_services(hass) + return True + async def async_setup_entry( hass: HomeAssistant, entry: GoogleAirQualityConfigEntry diff --git a/homeassistant/components/google_air_quality/config_flow.py b/homeassistant/components/google_air_quality/config_flow.py index b0f1cd41826..29c577a8f9b 100644 --- a/homeassistant/components/google_air_quality/config_flow.py +++ b/homeassistant/components/google_air_quality/config_flow.py @@ -1,13 +1,15 @@ """Config flow for the Google Air Quality integration.""" -from __future__ import annotations - import logging from typing import Any from google_air_quality_api.api import GoogleAirQualityApi from google_air_quality_api.auth import Auth -from google_air_quality_api.exceptions import GoogleAirQualityApiError +from google_air_quality_api.exceptions import ( + GoogleAirQualityApiError, + InvalidCustomLAQIConfigurationError, +) +from google_air_quality_api.mapping import AQICategoryMapping import voluptuous as vol from homeassistant.config_entries import ( @@ -20,6 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, @@ -28,11 +31,28 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig +from homeassistant.helpers.selector import ( + CountrySelector, + LocationSelector, + LocationSelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS +from .const import ( + CONF_ENABLE_CUSTOM_LAQI, + CONF_REFERRER, + CUSTOM_LAQI, + CUSTOM_LOCAL_AQI_OPTIONS, + DOMAIN, + SECTION_API_KEY_OPTIONS, +) _LOGGER = logging.getLogger(__name__) +AIR_QUALITY_COVERAGE_URL = ( + "https://developers.google.com/maps/documentation/air-quality/coverage" +) STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -52,10 +72,31 @@ async def _validate_input( description_placeholders: dict[str, str], ) -> bool: try: - await api.async_get_current_conditions( - lat=user_input[CONF_LOCATION][CONF_LATITUDE], - lon=user_input[CONF_LOCATION][CONF_LONGITUDE], - ) + custom_options = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS) or {} + enable_custom_laqi = custom_options.get(CONF_ENABLE_CUSTOM_LAQI) + + if enable_custom_laqi: + country = custom_options.get(CONF_COUNTRY) + custom_laqi = custom_options.get(CUSTOM_LAQI) + + # When custom LAQI is enabled, both country and custom_laqi must be provided + if not country or not custom_laqi: + errors[CUSTOM_LOCAL_AQI_OPTIONS] = "missing_custom_laqi_options" + return False + + await api.async_get_current_conditions( + lat=user_input[CONF_LOCATION][CONF_LATITUDE], + lon=user_input[CONF_LOCATION][CONF_LONGITUDE], + region_code=country, + custom_local_aqi=custom_laqi, + ) + else: + await api.async_get_current_conditions( + lat=user_input[CONF_LOCATION][CONF_LATITUDE], + lon=user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except InvalidCustomLAQIConfigurationError: + errors["base"] = "mismatch_country_and_laqi" except GoogleAirQualityApiError as err: errors["base"] = "cannot_connect" description_placeholders["error_message"] = str(err) @@ -71,6 +112,8 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: """Return the schema for a location with default values from the hass config.""" return vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=hass.config.location_name): str, vol.Required( CONF_LOCATION, @@ -79,6 +122,25 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: CONF_LONGITUDE: hass.config.longitude, }, ): LocationSelector(LocationSelectorConfig(radius=False)), + vol.Optional(CUSTOM_LOCAL_AQI_OPTIONS): section( + vol.Schema( + { + vol.Required(CONF_ENABLE_CUSTOM_LAQI, default=False): bool, + vol.Optional( + CONF_COUNTRY, default=hass.config.country + ): CountrySelector(), + vol.Optional(CUSTOM_LAQI): SelectSelector( + SelectSelectorConfig( + options=sorted( + AQICategoryMapping.get_all_laq_indices() + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + SectionConfig(collapsed=True), + ), } ) @@ -91,7 +153,8 @@ def _is_location_already_configured( for subentry in entry.subentries.values(): # A more accurate way is to use the haversine formula, but for simplicity # we use a simple distance check. The epsilon value is small anyway. - # This is mostly to capture cases where the user has slightly moved the location pin. + # This is mostly to capture cases where the user + # has slightly moved the location pin. if ( abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE]) @@ -122,6 +185,7 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} description_placeholders: dict[str, str] = { "api_key_url": "https://developers.google.com/maps/documentation/air-quality/get-api-key", + "air_quality_coverage_url": AIR_QUALITY_COVERAGE_URL, "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", } if user_input is not None: @@ -131,10 +195,13 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN): if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): return self.async_abort(reason="already_configured") session = async_get_clientsession(self.hass) - referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) auth = Auth(session, user_input[CONF_API_KEY], referrer=referrer) api = GoogleAirQualityApi(auth) if await _validate_input(user_input, api, errors, description_placeholders): + subentry_data = dict(user_input[CONF_LOCATION]) + custom_opts = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS) + if custom_opts and custom_opts.get(CONF_ENABLE_CUSTOM_LAQI): + subentry_data[CUSTOM_LOCAL_AQI_OPTIONS] = custom_opts return self.async_create_entry( title="Google Air Quality", data={ @@ -144,7 +211,7 @@ class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "location", - "data": user_input[CONF_LOCATION], + "data": subentry_data, "title": user_input[CONF_NAME], "unique_id": None, }, @@ -180,11 +247,13 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): user_input: dict[str, Any] | None = None, ) -> SubentryFlowResult: """Handle the location step.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} + description_placeholders: dict[str, str] = { + "air_quality_coverage_url": AIR_QUALITY_COVERAGE_URL + } if user_input is not None: if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): errors["base"] = "location_already_configured" @@ -201,9 +270,13 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): description_placeholders=description_placeholders, ) if await _validate_input(user_input, api, errors, description_placeholders): + data = dict(user_input[CONF_LOCATION]) + custom_options = user_input.get(CUSTOM_LOCAL_AQI_OPTIONS) + if custom_options and custom_options.get(CONF_ENABLE_CUSTOM_LAQI): + data[CUSTOM_LOCAL_AQI_OPTIONS] = custom_options return self.async_create_entry( title=user_input[CONF_NAME], - data=user_input[CONF_LOCATION], + data=data, ) else: user_input = {} diff --git a/homeassistant/components/google_air_quality/const.py b/homeassistant/components/google_air_quality/const.py index 059a0dff583..1871cf2ff43 100644 --- a/homeassistant/components/google_air_quality/const.py +++ b/homeassistant/components/google_air_quality/const.py @@ -2,6 +2,9 @@ from typing import Final -DOMAIN = "google_air_quality" -SECTION_API_KEY_OPTIONS: Final = "api_key_options" +CONF_ENABLE_CUSTOM_LAQI: Final = "enable_custom_laqi" CONF_REFERRER: Final = "referrer" +CUSTOM_LAQI: Final = "custom_laqi" +CUSTOM_LOCAL_AQI_OPTIONS: Final = "custom_local_aqi_options" +DOMAIN: Final = "google_air_quality" +SECTION_API_KEY_OPTIONS: Final = "api_key_options" diff --git a/homeassistant/components/google_air_quality/coordinator.py b/homeassistant/components/google_air_quality/coordinator.py index 2c22214ebb1..aea0db7f8b7 100644 --- a/homeassistant/components/google_air_quality/coordinator.py +++ b/homeassistant/components/google_air_quality/coordinator.py @@ -10,11 +10,16 @@ from google_air_quality_api.exceptions import GoogleAirQualityApiError from google_air_quality_api.model import AirQualityCurrentConditionsData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import ( + CONF_ENABLE_CUSTOM_LAQI, + CUSTOM_LAQI, + CUSTOM_LOCAL_AQI_OPTIONS, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -49,11 +54,27 @@ class GoogleAirQualityUpdateCoordinator( subentry = config_entry.subentries[subentry_id] self.lat = subentry.data[CONF_LATITUDE] self.long = subentry.data[CONF_LONGITUDE] + self.custom_local_aqi: str | None = None + self.region_code: str | None = None + options = subentry.data.get(CUSTOM_LOCAL_AQI_OPTIONS) + + if isinstance(options, dict) and options.get(CONF_ENABLE_CUSTOM_LAQI): + custom_laqi = options.get(CUSTOM_LAQI) + region_code = options.get(CONF_COUNTRY) + + if custom_laqi is not None and region_code is not None: + self.custom_local_aqi = custom_laqi + self.region_code = region_code async def _async_update_data(self) -> AirQualityCurrentConditionsData: """Fetch air quality data for this coordinate.""" try: - return await self.client.async_get_current_conditions(self.lat, self.long) + return await self.client.async_get_current_conditions( + lat=self.lat, + lon=self.long, + region_code=self.region_code, + custom_local_aqi=self.custom_local_aqi, + ) except GoogleAirQualityApiError as ex: _LOGGER.debug("Cannot fetch air quality data: %s", str(ex)) raise UpdateFailed( diff --git a/homeassistant/components/google_air_quality/icons.json b/homeassistant/components/google_air_quality/icons.json index 9bb78ad365e..7ca0122cc71 100644 --- a/homeassistant/components/google_air_quality/icons.json +++ b/homeassistant/components/google_air_quality/icons.json @@ -11,5 +11,10 @@ "default": "mdi:molecule" } } + }, + "services": { + "get_forecast": { + "service": "mdi:clock-end" + } } } diff --git a/homeassistant/components/google_air_quality/sensor.py b/homeassistant/components/google_air_quality/sensor.py index 67661900506..8f734de5508 100644 --- a/homeassistant/components/google_air_quality/sensor.py +++ b/homeassistant/components/google_air_quality/sensor.py @@ -204,7 +204,6 @@ async def async_setup_entry( for subentry_id, subentry in entry.subentries.items(): coordinator = coordinators[subentry_id] - _LOGGER.debug("subentry.data: %s", subentry.data) async_add_entities( ( AirQualitySensorEntity(coordinator, description, subentry_id, subentry) @@ -234,7 +233,11 @@ class AirQualitySensorEntity( """Set up Air Quality Sensors.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}_{subentry.data[CONF_LATITUDE]}_{subentry.data[CONF_LONGITUDE]}" + self._attr_unique_id = ( + f"{description.key}" + f"_{subentry.data[CONF_LATITUDE]}" + f"_{subentry.data[CONF_LONGITUDE]}" + ) self._attr_device_info = DeviceInfo( identifiers={ (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{subentry_id}") diff --git a/homeassistant/components/google_air_quality/services.py b/homeassistant/components/google_air_quality/services.py new file mode 100644 index 00000000000..6fff8844b7f --- /dev/null +++ b/homeassistant/components/google_air_quality/services.py @@ -0,0 +1,107 @@ +"""Services for the Google Air Quality integration.""" + +from datetime import timedelta +from typing import Final, cast + +from google_air_quality_api.exceptions import GoogleAirQualityApiError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr, selector + +from .const import DOMAIN +from .coordinator import GoogleAirQualityConfigEntry + +ATTR_HOURS: Final = "hours" + +FORECAST_HOURS_MAX: Final = 96 + +SERVICE_GET_FORECAST: Final = "get_forecast" + +SERVICE_GET_FORECAST_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector({"integration": DOMAIN}), + vol.Required(ATTR_HOURS): vol.All( + vol.Coerce(int), vol.Range(min=1, max=FORECAST_HOURS_MAX) + ), + } +) + + +def _get_config_entry_and_subentry_id( + hass: HomeAssistant, device_id: str +) -> tuple[GoogleAirQualityConfigEntry, str]: + """Get the config entry and subentry from a selected location device.""" + device = dr.async_get(hass).async_get(device_id) + if device is not None: + for entry_id, subentry_ids in device.config_entries_subentries.items(): + config_entry: ConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if config_entry is None or config_entry.domain != DOMAIN: + continue + + gaq_config_entry = cast(GoogleAirQualityConfigEntry, config_entry) + for subentry_id in subentry_ids: + if ( + subentry_id is not None + and subentry_id + in gaq_config_entry.runtime_data.subentries_runtime_data + ): + return gaq_config_entry, subentry_id + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + ) + + +async def _async_get_forecast(call: ServiceCall) -> ServiceResponse: + """Fetch the air quality forecast for a configured location.""" + config_entry, subentry_id = _get_config_entry_and_subentry_id( + call.hass, call.data[ATTR_DEVICE_ID] + ) + + coordinator = config_entry.runtime_data.subentries_runtime_data[subentry_id] + + try: + forecast = await config_entry.runtime_data.api.async_get_forecast( + coordinator.lat, + coordinator.long, + timedelta(hours=call.data[ATTR_HOURS]), + ) + except GoogleAirQualityApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_fetch", + ) from err + + return cast( + ServiceResponse, + { + "forecast_time": forecast.hourly_forecasts[0].date_time, + "indexes": forecast.hourly_forecasts[0].indexes, + "pollutants": forecast.hourly_forecasts[0].pollutants, + }, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECAST, + _async_get_forecast, + schema=SERVICE_GET_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/google_air_quality/services.yaml b/homeassistant/components/google_air_quality/services.yaml new file mode 100644 index 00000000000..b0856bc017f --- /dev/null +++ b/homeassistant/components/google_air_quality/services.yaml @@ -0,0 +1,15 @@ +get_forecast: + fields: + device_id: + required: true + selector: + device: + integration: google_air_quality + hours: + required: true + selector: + number: + min: 1 + max: 96 + step: 1 + mode: box diff --git a/homeassistant/components/google_air_quality/strings.json b/homeassistant/components/google_air_quality/strings.json index 1f1f6fb0f80..5be5f699a7d 100644 --- a/homeassistant/components/google_air_quality/strings.json +++ b/homeassistant/components/google_air_quality/strings.json @@ -15,6 +15,8 @@ }, "error": { "cannot_connect": "Unable to connect to the Google Air Quality API:\n\n{error_message}", + "mismatch_country_and_laqi": "This local AQI is not available for the selected country. Please select an available combination.", + "missing_custom_laqi_options": "Please provide both country and custom local AQI when custom local AQI is enabled.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -39,6 +41,20 @@ "referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})." }, "name": "Optional API key options" + }, + "custom_local_aqi_options": { + "data": { + "country": "[%key:common::config_flow::data::country%]", + "custom_laqi": "Custom local AQI", + "enable_custom_laqi": "Enable custom local AQI" + }, + "data_description": { + "country": "Country of the location", + "custom_laqi": "The target air quality index", + "enable_custom_laqi": "Select to enable a custom local air quality index" + }, + "description": "Country and custom local AQI must match. You can find the available combinations here: {air_quality_coverage_url}", + "name": "Custom local AQI options" } } } @@ -51,8 +67,11 @@ }, "entry_type": "Air quality location", "error": { + "cannot_connect": "[%key:component::google_air_quality::config::error::cannot_connect%]", "location_already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "location_name_already_configured": "Location name already configured.", + "mismatch_country_and_laqi": "[%key:component::google_air_quality::config::error::mismatch_country_and_laqi%]", + "missing_custom_laqi_options": "[%key:component::google_air_quality::config::error::missing_custom_laqi_options%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "initiate_flow": { @@ -69,6 +88,22 @@ "name": "[%key:component::google_air_quality::config::step::user::data_description::name%]" }, "description": "Select the coordinates for which you want to create an entry.", + "sections": { + "custom_local_aqi_options": { + "data": { + "country": "[%key:common::config_flow::data::country%]", + "custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data::custom_laqi%]", + "enable_custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data::enable_custom_laqi%]" + }, + "data_description": { + "country": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::country%]", + "custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::custom_laqi%]", + "enable_custom_laqi": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::data_description::enable_custom_laqi%]" + }, + "description": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::description%]", + "name": "[%key:component::google_air_quality::config::step::user::sections::custom_local_aqi_options::name%]" + } + }, "title": "Air quality data location" } } @@ -235,8 +270,27 @@ } }, "exceptions": { + "device_not_found": { + "message": "Location not found." + }, "unable_to_fetch": { "message": "[%key:component::google_air_quality::common::unable_to_fetch%]" } + }, + "services": { + "get_forecast": { + "description": "Get an air quality forecast for a configured location.", + "fields": { + "device_id": { + "description": "The location to fetch the forecast for.", + "name": "Location" + }, + "hours": { + "description": "How many hours into the future to forecast.", + "name": "Hours" + } + }, + "name": "Get forecast" + } } } diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index cfcada03a5c..81746782832 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,6 +1,5 @@ """Support for Actions on Google Assistant Smart Home Control.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -165,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo # Register service only if key is provided if CONF_SERVICE_ACCOUNT in config: + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler ) diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 00d809a851c..ebc4e19af67 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -1,7 +1,5 @@ """Support for buttons.""" -from __future__ import annotations - from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -21,6 +19,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] google_config = config_entry.runtime_data diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 71738c9d13e..5269ed55f68 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -199,6 +199,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER, (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER, (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV, + (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV, (sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR, (sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR, (sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR, diff --git a/homeassistant/components/google_assistant/data_redaction.py b/homeassistant/components/google_assistant/data_redaction.py index 50bd6dabf4c..318906f0d45 100644 --- a/homeassistant/components/google_assistant/data_redaction.py +++ b/homeassistant/components/google_assistant/data_redaction.py @@ -1,7 +1,5 @@ """Helpers to redact Google Assistant data when logging.""" -from __future__ import annotations - from collections.abc import Callable from functools import partial from typing import Any diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py index 5121a68f35c..c594ea62172 100644 --- a/homeassistant/components/google_assistant/diagnostics.py +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Hue.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 929944cb489..ad36b4e83f7 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,7 +1,5 @@ """Helper classes for Google Assistant integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod from asyncio import gather from collections.abc import Callable, Collection, Mapping @@ -20,7 +18,6 @@ from homeassistant.components import webhook from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, - CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE, ) @@ -114,7 +111,7 @@ class AbstractConfig(ABC): """Sync entities to Google.""" await self.async_sync_entities_all() - self._on_deinitialize.append(start.async_at_start(self.hass, sync_google)) + self._on_deinitialize.append(start.async_at_started(self.hass, sync_google)) @callback def async_deinitialize(self) -> None: @@ -174,7 +171,7 @@ class AbstractConfig(ABC): @abstractmethod def get_local_webhook_id(self, agent_user_id): - """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" + """Return the webhook ID for a given agent user id via the local SDK.""" @abstractmethod def get_agent_user_id_from_context(self, context): @@ -188,7 +185,7 @@ class AbstractConfig(ABC): """ @abstractmethod - def should_expose(self, state) -> bool: + def should_expose(self, entity_id: str) -> bool: """Return if entity should be exposed.""" @abstractmethod @@ -427,7 +424,8 @@ class AbstractConfig(ABC): ) if (agent_user_id := self.get_agent_user_id_from_webhook(webhook_id)) is None: - # No agent user linked to this webhook, means that the user has somehow unregistered + # No agent user linked to this webhook, means that + # the user has somehow unregistered # removing webhook and stopping processing of this request. _LOGGER.error( ( @@ -534,7 +532,7 @@ class GoogleEntity: def __repr__(self) -> str: """Return the representation.""" - return f"" + return f"" @callback def traits(self) -> list[trait._Trait]: @@ -551,7 +549,7 @@ class GoogleEntity: @callback def should_expose(self): """If entity should be exposed.""" - return self.config.should_expose(self.state) + return self.config.should_expose(self.entity_id) @callback def should_expose_local(self) -> bool: @@ -735,7 +733,7 @@ class GoogleEntity: if not executed: raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, - f"Unable to execute {command} for {self.state.entity_id}", + f"Unable to execute {command} for {self.entity_id}", ) @callback @@ -804,8 +802,6 @@ def async_get_entities( is_supported_cache = config.is_supported_cache for state in hass.states.async_all(): entity_id = state.entity_id - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - continue # Check check inlined for performance to avoid # function calls for every entity since we enumerate # the entire state machine here diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 8d317292ab6..7b7e3f15fce 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,7 +1,5 @@ """Support for Google Actions Smart Home Control.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging @@ -14,8 +12,7 @@ import jwt from homeassistant.components import webhook from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -140,7 +137,7 @@ class GoogleConfig(AbstractConfig): return found_agent_user_id def get_local_webhook_id(self, agent_user_id): - """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" + """Return the webhook ID for a given agent user id via the local SDK.""" if data := self._store.agent_user_ids.get(agent_user_id): return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] return None @@ -160,20 +157,13 @@ class GoogleConfig(AbstractConfig): return None - def should_expose(self, state) -> bool: + def should_expose(self, entity_id: str) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) - if state.attributes.get("view") is not None: - # Ignore entities that are views - return False - - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - entity_registry = er.async_get(self.hass) - registry_entry = entity_registry.async_get(state.entity_id) + registry_entry = entity_registry.async_get(entity_id) if registry_entry: auxiliary_entity = ( registry_entry.entity_category is not None @@ -182,10 +172,10 @@ class GoogleConfig(AbstractConfig): else: auxiliary_entity = False - explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) + explicit_expose = self.entity_config.get(entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = ( - expose_by_default and state.domain in exposed_domains + expose_by_default and split_entity_id(entity_id)[0] in exposed_domains ) # Expose an entity by default if the entity's domain is exposed by default @@ -325,7 +315,8 @@ class GoogleConfigStore: if (data := await self._store.async_load()) is None: # if the store is not found create an empty one # Note that the first request is always a cloud request, - # and that will store the correct agent user id to be used for local requests + # and that will store the correct agent user id + # to be used for local requests data = { STORE_AGENT_USER_IDS: {}, } diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 7fbe4bab5a9..75602562847 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -1,7 +1,5 @@ """Google Report State implementation.""" -from __future__ import annotations - from collections import deque import logging from typing import TYPE_CHECKING, Any @@ -59,7 +57,8 @@ def async_enable_report_state( {"devices": {"states": pending.popleft()}} ) - # If things got queued up in last batch while we were reporting, schedule ourselves again + # If things got queued up in last batch while we were + # reporting, schedule ourselves again if pending[0]: unsub_pending = async_call_later( hass, REPORT_STATE_WINDOW, report_states_job @@ -74,7 +73,7 @@ def async_enable_report_state( return bool( hass.is_running and (new_state := data["new_state"]) - and google_config.should_expose(new_state) + and google_config.should_expose(new_state.entity_id) and async_get_google_entity_if_supported_cached( hass, google_config, new_state ) @@ -113,8 +112,8 @@ def async_enable_report_state( result = await google_config.async_sync_notification_all(event_id, payload) if result != 200: _LOGGER.error( - "Unable to send notification with result code: %s, check log for more" - " info", + "Unable to send notification with result" + " code: %s, check log for more info", result, ) @@ -131,7 +130,8 @@ def async_enable_report_state( _LOGGER.debug("Scheduling report state for %s: %s", changed_entity, entity_data) - # If a significant change is already scheduled and we have another significant one, + # If a significant change is already scheduled and we + # have another significant one, # let's create a new batch of changes if changed_entity in pending[-1]: pending.append({}) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 5ae72b7a41a..45b6fbf281d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,7 +1,5 @@ """Implement the Google Smart Home traits.""" -from __future__ import annotations - from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging @@ -303,7 +301,7 @@ class _Trait(ABC): """Return the attributes of this trait for this entity.""" raise NotImplementedError - def query_notifications(self) -> dict[str, Any] | None: + def query_notifications(self) -> dict[str, Any] | None: # noqa: B027 """Return notifications payload.""" def can_execute(self, command, params): @@ -1076,14 +1074,16 @@ class TemperatureControlTrait(_Trait): float(attrs[water_heater.ATTR_MIN_TEMP]), unit, UnitOfTemperature.CELSIUS, - ) + ), + 1, ) max_temp = round( TemperatureConverter.convert( float(attrs[water_heater.ATTR_MAX_TEMP]), unit, UnitOfTemperature.CELSIUS, - ) + ), + 1, ) response["temperatureRange"] = { "minThresholdCelsius": min_temp, @@ -1201,6 +1201,17 @@ class TemperatureSettingTrait(_Trait): preset_to_google = {climate.PRESET_ECO: "eco"} google_to_preset = {value: key for key, value in preset_to_google.items()} + action_to_google = { + climate.HVACAction.OFF: "off", + climate.HVACAction.HEATING: "heat", + climate.HVACAction.DEFROSTING: "heat", + climate.HVACAction.PREHEATING: "heat", + climate.HVACAction.COOLING: "cool", + climate.HVACAction.DRYING: "dry", + climate.HVACAction.FAN: "fan-only", + climate.HVACAction.IDLE: "none", + } + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" @@ -1236,14 +1247,16 @@ class TemperatureSettingTrait(_Trait): float(attrs[climate.ATTR_MIN_TEMP]), unit, UnitOfTemperature.CELSIUS, - ) + ), + 1, ) max_temp = round( TemperatureConverter.convert( float(attrs[climate.ATTR_MAX_TEMP]), unit, UnitOfTemperature.CELSIUS, - ) + ), + 1, ) response["thermostatTemperatureRange"] = { "minThresholdCelsius": min_temp, @@ -1282,6 +1295,11 @@ class TemperatureSettingTrait(_Trait): else: response["thermostatMode"] = self.hvac_to_google.get(operation, "none") + if ( + action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION)) + ) is not None: + response["activeThermostatMode"] = action + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) if current_temp is not None: response["thermostatTemperatureAmbient"] = round( @@ -1619,7 +1637,9 @@ class ArmDisArmTrait(_Trait): AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS: ( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ), AlarmControlPanelState.TRIGGERED: AlarmControlPanelEntityFeature.TRIGGER, } """The list of states to support in increasing security state.""" @@ -2708,7 +2728,11 @@ class ChannelTrait(_Trait): if ( domain == media_player.DOMAIN and (features & MediaPlayerEntityFeature.PLAY_MEDIA) - and device_class == media_player.MediaPlayerDeviceClass.TV + and device_class + in ( + media_player.MediaPlayerDeviceClass.TV, + media_player.MediaPlayerDeviceClass.PROJECTOR, + ) ): return True diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 5df6ba19217..042e4cf9bc3 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -1,7 +1,5 @@ """Support for Google Assistant SDK.""" -from __future__ import annotations - from aiohttp import ClientError from gassist_text import TextAssistant from google.oauth2.credentials import Credentials diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 466a11bfd3e..1a802daccb1 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Assistant SDK integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/google_assistant_sdk/diagnostics.py b/homeassistant/components/google_assistant_sdk/diagnostics.py index 45600f5010e..77a0f2620ca 100644 --- a/homeassistant/components/google_assistant_sdk/diagnostics.py +++ b/homeassistant/components/google_assistant_sdk/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Google Assistant SDK.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index 364756cd00a..9aecb871a90 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -1,7 +1,5 @@ """Helper classes for Google Assistant SDK integration.""" -from __future__ import annotations - from dataclasses import dataclass from http import HTTPStatus import logging @@ -139,7 +137,11 @@ def default_language_code(hass: HomeAssistant) -> str: def best_matching_language_code( hass: HomeAssistant, assist_language: str, agent_language: str | None = None ) -> str: - """Get the best matching language, based on the preferred assist language and the configured agent language.""" + """Get the best matching language. + + Based on the preferred assist language and the configured + agent language. + """ # Use the assist language if supported if assist_language in SUPPORTED_LANGUAGE_CODES: diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index 067f222ca50..73759d90ac9 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -1,7 +1,5 @@ """Support for Google Assistant SDK broadcast notifications.""" -from __future__ import annotations - from typing import Any from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService diff --git a/homeassistant/components/google_assistant_sdk/services.py b/homeassistant/components/google_assistant_sdk/services.py index 6e3e9212443..bb1b053ec8a 100644 --- a/homeassistant/components/google_assistant_sdk/services.py +++ b/homeassistant/components/google_assistant_sdk/services.py @@ -1,7 +1,5 @@ """Services for the Google Assistant SDK integration.""" -from __future__ import annotations - import dataclasses import voluptuous as vol diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py index 3fc225ad423..4d6e71c6b12 100644 --- a/homeassistant/components/google_cloud/__init__.py +++ b/homeassistant/components/google_cloud/__init__.py @@ -1,7 +1,5 @@ """The google_cloud component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index 34a42bd8b85..05b2ef2b44b 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Google Cloud integration.""" -from __future__ import annotations - import json import logging from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 3a0b2bc4832..a33d27a94c1 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -1,7 +1,5 @@ """Constants for the Google Cloud component.""" -from __future__ import annotations - DOMAIN = "google_cloud" TITLE = "Google Cloud" diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index 952a10482e7..8dae604a1cc 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -1,7 +1,5 @@ """Helper classes for Google Cloud integration.""" -from __future__ import annotations - from collections.abc import Mapping import functools import operator @@ -64,7 +62,8 @@ def tts_options_schema( """Return schema for TTS options with default values from config or constants.""" # If we are called from the config flow we want the defaults to be from constants # to allow clearing the current value (passed as suggested_value) in the UI. - # If we aren't called from the config flow we want the defaults to be from the config. + # If we aren't called from the config flow we want the + # defaults to be from the config. defaults = {} if from_config_flow else config_options return vol.Schema( { diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index ea438b01cdd..555ebdec964 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -1,7 +1,5 @@ """Support for the Google Cloud STT service.""" -from __future__ import annotations - from collections.abc import AsyncGenerator, AsyncIterable import logging diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 817c424d1fc..b009fb0dbbb 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -1,7 +1,5 @@ """Support for the Google Cloud TTS service.""" -from __future__ import annotations - import logging from pathlib import Path from typing import Any, cast @@ -195,7 +193,8 @@ class BaseGoogleCloudProvider: ssml_gender=gender, name=voice, ), - # Avoid: "This voice does not support speaking rate or pitch parameters at this time." + # Avoid: "This voice does not support speaking rate + # or pitch parameters at this time." # by not specifying the fields unless they differ from the defaults audio_config=texttospeech.AudioConfig( audio_encoding=encoding, diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index a566f57f7e0..9a2bf25c83b 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -1,7 +1,5 @@ """The Google Drive integration.""" -from __future__ import annotations - from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError @@ -44,7 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) OAuth2Session(hass, entry, implementation), ) - # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not + # Test we can refresh the token and raise + # ConfigEntryAuthFailed or ConfigEntryNotReady if not await auth.async_get_access_token() client = DriveClient(await instance_id.async_get(hass), auth) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 909b85bb713..177afe48a66 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -1,7 +1,5 @@ """API for Google Drive bound to Home Assistant OAuth.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from dataclasses import dataclass import json @@ -119,13 +117,13 @@ class DriveClient: """Get storage quota of the current user.""" res = await self._api.get_user(params={"fields": "storageQuota"}) - storageQuota = res["storageQuota"] - limit = storageQuota.get("limit") + storage_quota = res["storageQuota"] + limit = storage_quota.get("limit") return StorageQuotaData( limit=int(limit) if limit is not None else None, - usage=int(storageQuota.get("usage", 0)), - usage_in_drive=int(storageQuota.get("usageInDrive", 0)), - usage_in_trash=int(storageQuota.get("usageInTrash", 0)), + usage=int(storage_quota.get("usage", 0)), + usage_in_drive=int(storage_quota.get("usageInDrive", 0)), + usage_in_trash=int(storage_quota.get("usageInTrash", 0)), ) async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]: @@ -134,7 +132,8 @@ class DriveClient: query = " and ".join( [ "properties has { key='home_assistant' and value='root' }", - f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "properties has { key='instance_id'" + f" and value='{self._ha_instance_id}' }}", "trashed=false", ] ) @@ -198,7 +197,8 @@ class DriveClient: query = " and ".join( [ "properties has { key='home_assistant' and value='backup' }", - f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "properties has { key='instance_id'" + f" and value='{self._ha_instance_id}' }}", "trashed=false", ] ) @@ -222,7 +222,8 @@ class DriveClient: query = " and ".join( [ "properties has { key='home_assistant' and value='backup' }", - f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "properties has { key='instance_id'" + f" and value='{self._ha_instance_id}' }}", f"properties has {{ key='backup_id' and value='{backup_id}' }}", ] ) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 40ebc7c7cec..9d0ea11ddbc 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -1,7 +1,5 @@ """Backup platform for the Google Drive integration.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging @@ -102,6 +100,7 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(wrapped_open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: @@ -109,6 +108,7 @@ class GoogleDriveBackupAgent(BackupAgent): try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( @@ -121,6 +121,7 @@ class GoogleDriveBackupAgent(BackupAgent): for backup in backups: if backup.backup_id == backup_id: return backup + # pylint: disable-next=home-assistant-exception-not-translated raise BackupNotFound(f"Backup {backup_id} not found") async def async_download_backup( @@ -141,7 +142,9 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError(f"Failed to download backup: {err}") from err + # pylint: disable-next=home-assistant-exception-not-translated raise BackupNotFound(f"Backup {backup_id} not found") async def async_delete_backup( @@ -162,5 +165,7 @@ class GoogleDriveBackupAgent(BackupAgent): _LOGGER.debug("Deleted backup_id: %s", backup_id) return except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError(f"Failed to delete backup: {err}") from err + # pylint: disable-next=home-assistant-exception-not-translated raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py index ca117be7513..cdc3b6212cd 100644 --- a/homeassistant/components/google_drive/config_flow.py +++ b/homeassistant/components/google_drive/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Google Drive integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, cast diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py index f446b38e61a..3fa0f669f5e 100644 --- a/homeassistant/components/google_drive/const.py +++ b/homeassistant/components/google_drive/const.py @@ -1,7 +1,5 @@ """Constants for the Google Drive integration.""" -from __future__ import annotations - from datetime import timedelta DOMAIN = "google_drive" diff --git a/homeassistant/components/google_drive/coordinator.py b/homeassistant/components/google_drive/coordinator.py index c6f613ab763..34260416d17 100644 --- a/homeassistant/components/google_drive/coordinator.py +++ b/homeassistant/components/google_drive/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Google Drive.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/google_drive/diagnostics.py b/homeassistant/components/google_drive/diagnostics.py index 494ec52346f..20c334cc135 100644 --- a/homeassistant/components/google_drive/diagnostics.py +++ b/homeassistant/components/google_drive/diagnostics.py @@ -1,14 +1,9 @@ """Diagnostics support for Google Drive.""" -from __future__ import annotations - import dataclasses from typing import Any -from homeassistant.components.backup import ( - DATA_MANAGER as BACKUP_DATA_MANAGER, - BackupManager, -) +from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -26,7 +21,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator = entry.runtime_data - backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER] + backup_manager = hass.data[BACKUP_DATA_MANAGER] backups = await coordinator.client.async_list_backups() diff --git a/homeassistant/components/google_drive/sensor.py b/homeassistant/components/google_drive/sensor.py index 66137046fb1..bfb5ef01b5c 100644 --- a/homeassistant/components/google_drive/sensor.py +++ b/homeassistant/components/google_drive/sensor.py @@ -1,7 +1,5 @@ """Support for GoogleDrive sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index ddd9f20377d..407ede0c328 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -1,40 +1,28 @@ """The Google Generative AI Conversation integration.""" -from __future__ import annotations - from functools import partial -from pathlib import Path from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout -import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, - HomeAssistantError, ) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_PROMPT, DEFAULT_AI_TASK_NAME, DEFAULT_STT_NAME, DEFAULT_TITLE, @@ -47,11 +35,6 @@ from .const import ( RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) -from .entity import async_prepare_files_for_prompt - -SERVICE_GENERATE_CONTENT = "generate_content" -CONF_IMAGE_FILENAME = "image_filename" -CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( @@ -69,88 +52,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_migrate_integration(hass) - async def generate_content(call: ServiceCall) -> ServiceResponse: - """Generate content from text and optionally images.""" - LOGGER.warning( - "Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. " - "Please use the 'ai_task.generate_data' action instead", - DOMAIN, - SERVICE_GENERATE_CONTENT, - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_generate_content", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_generate_content", - ) - - prompt_parts = [call.data[CONF_PROMPT]] - - config_entry: GoogleGenerativeAIConfigEntry = ( - hass.config_entries.async_loaded_entries(DOMAIN)[0] - ) - - client = config_entry.runtime_data - - files = call.data[CONF_FILENAMES] - - if files: - for filename in files: - if not hass.config.is_allowed_path(filename): - raise HomeAssistantError( - f"Cannot read `{filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - - prompt_parts.extend( - await async_prepare_files_for_prompt( - hass, client, [(Path(filename), None) for filename in files] - ) - ) - - try: - response = await client.aio.models.generate_content( - model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts - ) - except ( - APIError, - ValueError, - ) as err: - raise HomeAssistantError(f"Error generating content: {err}") from err - - if response.prompt_feedback: - raise HomeAssistantError( - f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" - ) - - if ( - not response.candidates - or not response.candidates[0].content - or not response.candidates[0].content.parts - ): - raise HomeAssistantError("Unknown error generating content") - - return {"text": response.text} - - hass.services.async_register( - DOMAIN, - SERVICE_GENERATE_CONTENT, - generate_content, - schema=vol.Schema( - { - vol.Required(CONF_PROMPT): cv.string, - vol.Optional(CONF_FILENAMES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), - supports_response=SupportsResponse.ONLY, - description_placeholders={"example_image_path": "/config/www/image.jpg"}, - ) return True diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index b0007eac385..be34e09161d 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for Google Generative AI Conversation.""" -from __future__ import annotations - from json import JSONDecodeError from typing import TYPE_CHECKING @@ -88,7 +86,8 @@ class GoogleGenerativeAITaskEntity( if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + "Last content in chat log is not an AssistantContent: %s." + " This could be due to the model not returning a valid response", chat_log.content[-1], ) raise HomeAssistantError(ERROR_GETTING_RESPONSE) @@ -151,7 +150,9 @@ class GoogleGenerativeAITaskEntity( if response.prompt_feedback: raise HomeAssistantError( - f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" + "Error generating content due to content" + " violations, reason:" + f" {response.prompt_feedback.block_reason_message}" ) if ( diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 0572c63085a..34bc54114b4 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Generative AI Conversation integration.""" -from __future__ import annotations - from collections.abc import Mapping from functools import partial import logging @@ -21,7 +19,7 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( @@ -40,7 +38,6 @@ from .const import ( CONF_HARASSMENT_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD, CONF_MAX_TOKENS, - CONF_PROMPT, CONF_RECOMMENDED, CONF_SEXUAL_BLOCK_THRESHOLD, CONF_TEMPERATURE, @@ -227,7 +224,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Set conversation options.""" # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") errors: dict[str, str] = {} @@ -256,7 +253,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) - # Don't allow to save options that enable the Google Search tool with an Assist API + # Don't allow to save options that enable the + # Google Search tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True @@ -323,6 +321,8 @@ async def google_generative_ai_config_option_schema( else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=default_name): str, } else: diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index f509c4a52fa..804b6f543ad 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -2,7 +2,7 @@ import logging -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT from homeassistant.helpers import llm LOGGER = logging.getLogger(__package__) @@ -15,12 +15,11 @@ DEFAULT_STT_NAME = "Google AI STT" DEFAULT_TTS_NAME = "Google AI TTS" DEFAULT_AI_TASK_NAME = "Google AI Task" -CONF_PROMPT = "prompt" DEFAULT_STT_PROMPT = "Transcribe the attached audio" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" +RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite" RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d804073bfb4..6462274445a 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -1,16 +1,14 @@ """Conversation support for the Google Generative AI Conversation integration.""" -from __future__ import annotations - from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_PROMPT, DOMAIN +from .const import DOMAIN from .entity import GoogleGenerativeAILLMBaseEntity diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py index 34b9f762355..94f7e515e28 100644 --- a/homeassistant/components/google_generative_ai_conversation/diagnostics.py +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Google Generative AI Conversation.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fba51dcd7ef..396411f3a93 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -1,7 +1,5 @@ """Conversation support for the Google Generative AI Conversation integration.""" -from __future__ import annotations - import asyncio import base64 import codecs @@ -174,7 +172,7 @@ def _format_tool( def _escape_decode(value: Any) -> Any: """Recursively call codecs.escape_decode on all values.""" if isinstance(value, str): - return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") if isinstance(value, list): return [_escape_decode(item) for item in value] if isinstance(value, dict): @@ -338,6 +336,7 @@ def _convert_content( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True @@ -346,6 +345,19 @@ async def _transform_stream( async for response in result: LOGGER.debug("Received response chunk: %s", response) + if (usage := response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage.prompt_token_count, + "cached_input_tokens": ( + usage.cached_content_token_count or 0 + ), + "output_tokens": usage.candidates_token_count, + } + } + ) + if new_message: if part_details: yield {"native": ContentDetails(part_details=part_details)} @@ -356,7 +368,9 @@ async def _transform_stream( thinking_content_index = 0 tool_call_index = 0 - # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. + # According to the API docs, this would mean no + # candidate is returned, so we can safely throw + # an error here. if response.prompt_feedback or not response.candidates: reason = ( response.prompt_feedback.block_reason_message @@ -364,7 +378,8 @@ async def _transform_stream( else "unknown" ) raise HomeAssistantError( - f"The message got blocked due to content violations, reason: {reason}" + "The message got blocked due to content" + f" violations, reason: {reason}" ) candidate = response.candidates[0] @@ -498,9 +513,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity): for tool in chat_log.llm_api.tools ] - # Using search grounding allows the model to retrieve information from the web, - # however, it may interfere with how the model decides to use some tools, or entities - # for example weather entity may be disregarded if the model chooses to Google it. + # Using search grounding allows the model to retrieve + # information from the web, however, it may interfere + # with how the model decides to use some tools, or + # entities for example weather entity may be + # disregarded if the model chooses to Google it. if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: tools = tools or [] tools.append(Tool(google_search=GoogleSearch())) @@ -536,8 +553,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity): not isinstance(chat_content, conversation.ToolResultContent) and chat_content.content == "" ): - # Skipping is not possible since the number of function calls need to match the number of function responses - # and skipping one would mean removing the other and hence this would prevent a proper chat log + # Skipping is not possible since the number of + # function calls need to match the number of + # function responses and skipping one would + # mean removing the other and hence this would + # prevent a proper chat log chat_content = replace(chat_content, content=" ") if tool_results: @@ -560,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity): if tool_results: messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = self.create_generate_content_config() - generateContentConfig.tools = tools or None - generateContentConfig.system_instruction = ( + generate_content_config = self.create_generate_content_config() + generate_content_config.tools = tools or None + generate_content_config.system_instruction = ( prompt if supports_system_instruction else None ) - generateContentConfig.automatic_function_calling = ( + generate_content_config.automatic_function_calling = ( AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) ) if structure: - generateContentConfig.response_mime_type = "application/json" - generateContentConfig.response_schema = _format_schema( + generate_content_config.response_mime_type = "application/json" + generate_content_config.response_schema = _format_schema( convert( structure, custom_serializer=( @@ -588,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): *messages, ] chat = self._genai_client.aio.chats.create( - model=model_name, history=messages, config=generateContentConfig + model=model_name, history=messages, config=generate_content_config ) user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) @@ -623,7 +643,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): content async for content in chat_log.async_add_delta_content_stream( self.entity_id, - _transform_stream(chat_response_generator), + _transform_stream(chat_log, chat_response_generator), ) if isinstance(content, conversation.ToolResultContent) ] @@ -734,9 +754,11 @@ async def async_prepare_files_for_prompt( config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) - if uploaded_file.state == FileState.FAILED: + if uploaded_file.state is FileState.FAILED: raise HomeAssistantError( - f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message if uploaded_file.error else 'unknown'}" + f"File `{uploaded_file.name}` processing" + " failed, reason:" + f" {uploaded_file.error.message if uploaded_file.error else 'unknown'}" ) prompt_parts = await hass.async_add_executor_job(upload_files) @@ -744,7 +766,7 @@ async def async_prepare_files_for_prompt( tasks = [ asyncio.create_task(wait_for_file_processing(part)) for part in prompt_parts - if part.state != FileState.ACTIVE + if part.state is not FileState.ACTIVE ] async with asyncio.timeout(TIMEOUT_MILLIS / 1000): await asyncio.gather(*tasks) diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py index 3d053aa9f1a..9ee6a367223 100644 --- a/homeassistant/components/google_generative_ai_conversation/helpers.py +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -1,7 +1,5 @@ """Helper classes for Google Generative AI integration.""" -from __future__ import annotations - from contextlib import suppress import io import wave @@ -49,7 +47,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: integers if found, otherwise None. """ - if not mime_type.startswith("audio/L"): + if not mime_type.lower().startswith("audio/l"): LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") @@ -61,13 +59,14 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: for param in parts: # Skip the main type part param = param.strip() if param.lower().startswith("rate="): - # Handle cases like "rate=" with no value or non-integer value and keep rate as default + # Handle cases like "rate=" with no value or + # non-integer value and keep rate as default with suppress(ValueError, IndexError): rate_str = param.split("=", 1)[1] rate = int(rate_str) - elif param.startswith("audio/L"): + elif param.lower().startswith("audio/l"): # Keep bits_per_sample as default if conversion fails with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) + bits_per_sample = int(param.upper().split("L", 1)[1]) return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_generative_ai_conversation/icons.json b/homeassistant/components/google_generative_ai_conversation/icons.json deleted file mode 100644 index 6ac3cc3b21c..00000000000 --- a/homeassistant/components/google_generative_ai_conversation/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "generate_content": { - "service": "mdi:receipt-text" - } - } -} diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml deleted file mode 100644 index 30077dec650..00000000000 --- a/homeassistant/components/google_generative_ai_conversation/services.yaml +++ /dev/null @@ -1,12 +0,0 @@ -generate_content: - fields: - prompt: - required: true - selector: - text: - multiline: true - filenames: - required: false - selector: - text: - multiple: true diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index b74babe7085..bd5ef1e968f 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -149,29 +149,5 @@ } } } - }, - "issues": { - "deprecated_generate_content": { - "description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead", - "title": "Deprecated 'generate_content' action" - } - }, - "services": { - "generate_content": { - "description": "Generate content from a prompt consisting of text and optionally images (deprecated)", - "fields": { - "filenames": { - "description": "Attachments to add to the prompt (images, PDFs, etc)", - "example": "{example_image_path}", - "name": "Attachment filenames" - }, - "prompt": { - "description": "The prompt", - "example": "Describe what you see in these images", - "name": "Prompt" - } - }, - "name": "Generate content (deprecated)" - } } } diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py index f9b91ff6685..4dbd05ce79b 100644 --- a/homeassistant/components/google_generative_ai_conversation/stt.py +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -1,7 +1,5 @@ """Speech to text support for Google Generative AI.""" -from __future__ import annotations - from collections.abc import AsyncIterable from google.genai.errors import APIError, ClientError @@ -9,16 +7,11 @@ from google.genai.types import Part from homeassistant.components import stt from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_PROMPT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_CHAT_MODEL, - CONF_PROMPT, - DEFAULT_STT_PROMPT, - LOGGER, - RECOMMENDED_STT_MODEL, -) +from .const import CONF_CHAT_MODEL, DEFAULT_STT_PROMPT, LOGGER, RECOMMENDED_STT_MODEL from .entity import GoogleGenerativeAILLMBaseEntity from .helpers import convert_to_wav @@ -218,8 +211,10 @@ class GoogleGenerativeAISttEntity( @property def supported_channels(self) -> list[stt.AudioChannels]: """Return a list of supported channels.""" - # Per https://ai.google.dev/gemini-api/docs/audio - # If the audio source contains multiple channels, Gemini combines those channels into a single channel. + # Per + # https://ai.google.dev/gemini-api/docs/audio + # If the audio source contains multiple channels, + # Gemini combines those channels into a single channel. return [stt.AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 84f8e86e562..78e5e54f7a5 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -1,7 +1,5 @@ """Text to speech support for Google Generative AI.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 844b5efb65e..3700e0fb890 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,7 +1,5 @@ """Support for Google Mail.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant @@ -54,6 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) - Platform.NOTIFY, DOMAIN, {DATA_AUTH: auth, CONF_NAME: entry.title}, + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN][DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py index b3a9a0e5d56..c2fea942c84 100644 --- a/homeassistant/components/google_mail/config_flow.py +++ b/homeassistant/components/google_mail/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Mail integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, cast diff --git a/homeassistant/components/google_mail/const.py b/homeassistant/components/google_mail/const.py index 816437b98c8..c03a3f71827 100644 --- a/homeassistant/components/google_mail/const.py +++ b/homeassistant/components/google_mail/const.py @@ -1,7 +1,5 @@ """Constants for Google Mail integration.""" -from __future__ import annotations - ATTR_BCC = "bcc" ATTR_CC = "cc" ATTR_ENABLED = "enabled" diff --git a/homeassistant/components/google_mail/entity.py b/homeassistant/components/google_mail/entity.py index d83b18b9a50..5ae145e0e3f 100644 --- a/homeassistant/components/google_mail/entity.py +++ b/homeassistant/components/google_mail/entity.py @@ -1,7 +1,5 @@ """Entity representing a Google Mail account.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index cc9dd59503a..b42b4afc745 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -1,7 +1,5 @@ """Notification service for Google Mail integration.""" -from __future__ import annotations - import base64 from email.mime.text import MIMEText from email.utils import formataddr diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index 781ea9192f0..bc3ea5a8bc3 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -1,7 +1,5 @@ """Support for Google Mail Sensors.""" -from __future__ import annotations - from datetime import UTC, datetime, timedelta from googleapiclient.http import HttpRequest diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py index d8287ea35a1..3421ab4072f 100644 --- a/homeassistant/components/google_mail/services.py +++ b/homeassistant/components/google_mail/services.py @@ -1,7 +1,5 @@ """Services for Google Mail integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index fd50295a6a1..2cb88d6bd95 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -1,7 +1,5 @@ """Support for Google Maps location sharing.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 115bd57f67c..0ce495fb044 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -1,7 +1,5 @@ """The Google Photos integration.""" -from __future__ import annotations - from aiohttp import ClientError, ClientResponseError from google_photos_library_api.api import GooglePhotosLibraryApi diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index ef6e2ef3e03..1b4dcb80d57 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -1,7 +1,5 @@ """Media source for Google Photos.""" -from __future__ import annotations - from dataclasses import dataclass from enum import StrEnum import logging @@ -113,7 +111,8 @@ class GooglePhotosMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media identifier to a url. - This will resolve a specific media item to a url for the full photo or video contents. + This will resolve a specific media item to a url for + the full photo or video contents. """ try: identifier = PhotosIdentifier.of(item.identifier) @@ -284,7 +283,9 @@ def _build_media_item( def _media_url(media_item: MediaItem, max_size: int) -> str: - """Return a media item url with the specified max thumbnail size on the longest edge. + """Return a media item url with the specified max thumbnail size. + + The size applies to the longest edge. See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index aaedd38cc7e..e6aee974cb2 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -1,7 +1,5 @@ """Google Photos services.""" -from __future__ import annotations - import asyncio import mimetypes from pathlib import Path @@ -89,7 +87,6 @@ async def _async_handle_upload(call: ServiceCall) -> ServiceResponse: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="missing_upload_permission", - translation_placeholders={"target": DOMAIN}, ) coordinator = config_entry.runtime_data client_api = coordinator.client diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index ace56bf9354..ad973ed154a 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -1,7 +1,5 @@ """Support for Google Cloud Pub/Sub.""" -from __future__ import annotations - import datetime import json import logging diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index de88c6028b9..49c9a2b7d84 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -1,7 +1,5 @@ """Support for Google Sheets.""" -from __future__ import annotations - import aiohttp from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/google_sheets/config_flow.py b/homeassistant/components/google_sheets/config_flow.py index 81c82bf1bc4..ced953ed628 100644 --- a/homeassistant/components/google_sheets/config_flow.py +++ b/homeassistant/components/google_sheets/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Sheets integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/google_sheets/const.py b/homeassistant/components/google_sheets/const.py index b93916db0a6..9f677d1afad 100644 --- a/homeassistant/components/google_sheets/const.py +++ b/homeassistant/components/google_sheets/const.py @@ -1,7 +1,5 @@ """Constants for Google Sheets integration.""" -from __future__ import annotations - DOMAIN = "google_sheets" DEFAULT_NAME = "Google Sheets" diff --git a/homeassistant/components/google_sheets/services.py b/homeassistant/components/google_sheets/services.py index e3b6dc5ceec..d79b2389ef8 100644 --- a/homeassistant/components/google_sheets/services.py +++ b/homeassistant/components/google_sheets/services.py @@ -1,7 +1,5 @@ """Support for Google Sheets.""" -from __future__ import annotations - from datetime import datetime from typing import TYPE_CHECKING diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 494295f69f2..66f0cab5530 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -1,7 +1,5 @@ """The Google Tasks integration.""" -from __future__ import annotations - import asyncio from aiohttp import ClientError, ClientResponseError diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index f51c5103b87..4b1cca8089d 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -116,7 +116,8 @@ class AsyncConfigEntryAuth: def response_handler(_, response, exception: HttpError) -> None: if exception is not None: raise GoogleTasksApiError( - f"Google Tasks API responded with error ({exception.reason or exception.status_code})" + "Google Tasks API responded with error" + f" ({exception.reason or exception.status_code})" ) from exception if response: data = json.loads(response) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 16bde96a5f9..1ce7739a941 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,5 @@ """Google Tasks todo platform.""" -from __future__ import annotations - from datetime import UTC, date, datetime from typing import Any, cast diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py index 17400bbd0e2..2b345be3a18 100644 --- a/homeassistant/components/google_translate/__init__.py +++ b/homeassistant/components/google_translate/__init__.py @@ -1,7 +1,5 @@ """The Google Translate text-to-speech integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/google_translate/config_flow.py b/homeassistant/components/google_translate/config_flow.py index 5c140167b81..fb93332bdd6 100644 --- a/homeassistant/components/google_translate/config_flow.py +++ b/homeassistant/components/google_translate/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Translate text-to-speech integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index ef293a71093..f5565ecb10b 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -1,7 +1,5 @@ """Support for the Google speech service.""" -from __future__ import annotations - from io import BytesIO import logging from typing import Any diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index c1b6a491981..d8ac5eeef49 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -57,7 +57,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) except ValueError: _LOGGER.error( - "Invalid time format found while migrating: %s. The old config never worked. Reset to default (empty)", + "Invalid time format found while migrating: %s." + " The old config never worked." + " Reset to default (empty)", options[CONF_TIME], ) options[CONF_TIME] = None diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index d27c0202f2d..80534b0dae8 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Google Maps Travel Time integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -69,6 +67,8 @@ RECONFIGURE_SCHEMA = vol.Schema( CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 6ae4dd419ed..7e98a4664bc 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -12,7 +12,6 @@ DOMAIN = "google_travel_time" ATTRIBUTION = "Powered by Google" CONF_DESTINATION = "destination" -CONF_OPTIONS = "options" CONF_ORIGIN = "origin" CONF_AVOID = "avoid" CONF_UNITS = "units" diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index cf156b7aa58..0537db420a9 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -1,7 +1,5 @@ """Support for Google travel time sensors.""" -from __future__ import annotations - import datetime import logging from typing import Any diff --git a/homeassistant/components/google_weather/__init__.py b/homeassistant/components/google_weather/__init__.py index 8f2c5a2b094..54d5c6c2bd2 100644 --- a/homeassistant/components/google_weather/__init__.py +++ b/homeassistant/components/google_weather/__init__.py @@ -1,7 +1,5 @@ """The Google Weather integration.""" -from __future__ import annotations - import asyncio from google_weather_api import GoogleWeatherApi diff --git a/homeassistant/components/google_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py index 661146ab01d..944d50163d6 100644 --- a/homeassistant/components/google_weather/config_flow.py +++ b/homeassistant/components/google_weather/config_flow.py @@ -1,7 +1,6 @@ """Config flow for the Google Weather integration.""" -from __future__ import annotations - +from collections.abc import Mapping import logging from typing import Any @@ -9,6 +8,9 @@ from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -68,6 +70,8 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: """Return the schema for a location with default values from the hass config.""" return vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=hass.config.location_name): str, vol.Required( CONF_LOCATION, @@ -81,14 +85,20 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: def _is_location_already_configured( - hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4 + hass: HomeAssistant, + new_data: dict[str, float], + epsilon: float = 1e-4, + exclude_subentry_id: str | None = None, ) -> bool: """Check if the location is already configured.""" for entry in hass.config_entries.async_entries(DOMAIN): for subentry in entry.subentries.values(): + if exclude_subentry_id and subentry.subentry_id == exclude_subentry_id: + continue # A more accurate way is to use the haversine formula, but for simplicity # we use a simple distance check. The epsilon value is small anyway. - # This is mostly to capture cases where the user has slightly moved the location pin. + # This is mostly to capture cases where the user + # has slightly moved the location pin. if ( abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE]) @@ -106,7 +116,7 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle a flow initialized by the user, reauth or reconfigure.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = { "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", @@ -116,21 +126,45 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) self._async_abort_entries_match({CONF_API_KEY: api_key}) - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): - return self.async_abort(reason="already_configured") + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + subentry = next(iter(entry.subentries.values()), None) + if subentry: + latitude = subentry.data[CONF_LATITUDE] + longitude = subentry.data[CONF_LONGITUDE] + else: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + validation_input = { + CONF_LOCATION: {CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude} + } + else: + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION] + ): + return self.async_abort(reason="already_configured") + validation_input = user_input + api = GoogleWeatherApi( session=async_get_clientsession(self.hass), api_key=api_key, referrer=referrer, language_code=self.hass.config.language, ) - if await _validate_input(user_input, api, errors, description_placeholders): + if await _validate_input( + validation_input, api, errors, description_placeholders + ): + data = {CONF_API_KEY: api_key, CONF_REFERRER: referrer} + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + return self.async_update_reload_and_abort(entry, data=data) + return self.async_create_entry( title="Google Weather", - data={ - CONF_API_KEY: api_key, - CONF_REFERRER: referrer, - }, + data=data, subentries=[ { "subentry_type": "location", @@ -140,19 +174,47 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): }, ], ) + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + if user_input is None: + user_input = { + CONF_API_KEY: entry.data.get(CONF_API_KEY), + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: entry.data.get(CONF_REFERRER) + }, + } + schema = STEP_USER_DATA_SCHEMA else: - user_input = {} - schema = STEP_USER_DATA_SCHEMA.schema.copy() - schema.update(_get_location_schema(self.hass).schema) + if user_input is None: + user_input = {} + schema_dict = STEP_USER_DATA_SCHEMA.schema.copy() + schema_dict.update(_get_location_schema(self.hass).schema) + schema = vol.Schema(schema_dict) + return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema(schema), user_input - ), + data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, description_placeholders=description_placeholders, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + return await self.async_step_user(user_input) + @classmethod @callback def async_get_supported_subentry_types( @@ -165,27 +227,51 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): class LocationSubentryFlowHandler(ConfigSubentryFlow): """Handle a subentry flow for location.""" + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + async def async_step_location( self, user_input: dict[str, Any] | None = None, ) -> SubentryFlowResult: """Handle the location step.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} if user_input is not None: - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + exclude_id = ( + None if self._is_new else self._get_reconfigure_subentry().subentry_id + ) + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION], exclude_subentry_id=exclude_id + ): return self.async_abort(reason="already_configured") api: GoogleWeatherApi = self._get_entry().runtime_data.api if await _validate_input(user_input, api, errors, description_placeholders): - return self.async_create_entry( + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input[CONF_LOCATION], + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), title=user_input[CONF_NAME], data=user_input[CONF_LOCATION], ) - else: + elif self._is_new: user_input = {} + else: + subentry = self._get_reconfigure_subentry() + user_input = { + CONF_NAME: subentry.title, + CONF_LOCATION: dict(subentry.data), + } + return self.async_show_form( step_id="location", data_schema=self.add_suggested_values_to_schema( @@ -196,3 +282,4 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): ) async_step_user = async_step_location + async_step_reconfigure = async_step_location diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py index 695dc5ea191..9d6f33e1975 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -1,7 +1,5 @@ """The Google Weather coordinator.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta @@ -12,6 +10,7 @@ from google_weather_api import ( CurrentConditionsResponse, DailyForecastResponse, GoogleWeatherApi, + GoogleWeatherApiAuthError, GoogleWeatherApiError, HourlyForecastResponse, ) @@ -19,6 +18,7 @@ from google_weather_api import ( from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( TimestampDataUpdateCoordinator, UpdateFailed, @@ -92,6 +92,14 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]): self.subentry.data[CONF_LATITUDE], self.subentry.data[CONF_LONGITUDE], ) + except GoogleWeatherApiAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "error": str(err), + }, + ) from err except GoogleWeatherApiError as err: _LOGGER.error( "Error fetching %s for %s: %s", diff --git a/homeassistant/components/google_weather/diagnostics.py b/homeassistant/components/google_weather/diagnostics.py index c8ae724a23b..36755b9a5de 100644 --- a/homeassistant/components/google_weather/diagnostics.py +++ b/homeassistant/components/google_weather/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Google Weather.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -36,9 +34,11 @@ async def async_get_config_entry_diagnostics( "daily_forecast_data": subentry_rt.coordinator_daily_forecast.data.to_dict() if subentry_rt.coordinator_daily_forecast.data else None, - "hourly_forecast_data": subentry_rt.coordinator_hourly_forecast.data.to_dict() - if subentry_rt.coordinator_hourly_forecast.data - else None, + "hourly_forecast_data": ( + subentry_rt.coordinator_hourly_forecast.data.to_dict() + if subentry_rt.coordinator_hourly_forecast.data + else None + ), } return async_redact_data(diag_data, TO_REDACT) diff --git a/homeassistant/components/google_weather/entity.py b/homeassistant/components/google_weather/entity.py index 3b3da6e0c5e..5080c98c417 100644 --- a/homeassistant/components/google_weather/entity.py +++ b/homeassistant/components/google_weather/entity.py @@ -1,7 +1,5 @@ """Base entity for Google Weather.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigSubentry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json index 4f22a57d875..e7ec2e05563 100644 --- a/homeassistant/components/google_weather/manifest.json +++ b/homeassistant/components/google_weather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google_weather_api"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["python-google-weather-api==0.0.6"] } diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml index ec5e4edbb41..4ae4a8358a3 100644 --- a/homeassistant/components/google_weather/quality_scale.yaml +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repairs. diff --git a/homeassistant/components/google_weather/sensor.py b/homeassistant/components/google_weather/sensor.py index 12b3b4bcce2..23176789108 100644 --- a/homeassistant/components/google_weather/sensor.py +++ b/homeassistant/components/google_weather/sensor.py @@ -1,7 +1,5 @@ """Support for Google Weather sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json index 977adb306fc..7b8ab5b060c 100644 --- a/homeassistant/components/google_weather/strings.json +++ b/homeassistant/components/google_weather/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}", @@ -38,7 +40,8 @@ "location": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Location", "error": { @@ -46,6 +49,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "initiate_flow": { + "reconfigure": "Reconfigure location", "user": "Add location" }, "step": { @@ -100,6 +104,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed: {error}" + }, "update_error": { "message": "Error fetching weather data: {error}" } diff --git a/homeassistant/components/google_weather/weather.py b/homeassistant/components/google_weather/weather.py index 0c906abee40..63b3b53b32c 100644 --- a/homeassistant/components/google_weather/weather.py +++ b/homeassistant/components/google_weather/weather.py @@ -1,7 +1,5 @@ """Weather entity.""" -from __future__ import annotations - from google_weather_api import ( DailyForecastResponse, HourlyForecastResponse, @@ -266,7 +264,9 @@ class GoogleWeatherEntity( ATTR_FORECAST_NATIVE_APPARENT_TEMP: ( item.feels_like_max_temperature.degrees ), - ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees, + ATTR_FORECAST_WIND_BEARING: ( + item.daytime_forecast.wind.direction.degrees + ), ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max( item.daytime_forecast.wind.gust.value, item.nighttime_forecast.wind.gust.value, @@ -294,10 +294,14 @@ class GoogleWeatherEntity( ), ATTR_FORECAST_TIME: item.interval.start_time, ATTR_FORECAST_HUMIDITY: item.relative_humidity, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + item.precipitation.probability.percent + ), ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover, ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity, - ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars, + ATTR_FORECAST_NATIVE_PRESSURE: ( + item.air_pressure.mean_sea_level_millibars + ), ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees, ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees, ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees, @@ -328,11 +332,17 @@ class GoogleWeatherEntity( ), ATTR_FORECAST_TIME: day_forecast.interval.start_time, ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + day_forecast.precipitation.probability.percent + ), ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover, - ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_PRECIPITATION: ( + day_forecast.precipitation.qpf.quantity + ), ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: ( + item.feels_like_max_temperature.degrees + ), ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees, ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value, ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value, @@ -350,13 +360,21 @@ class GoogleWeatherEntity( ), ATTR_FORECAST_TIME: night_forecast.interval.start_time, ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + night_forecast.precipitation.probability.percent + ), ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover, - ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_PRECIPITATION: ( + night_forecast.precipitation.qpf.quantity + ), ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: ( + item.feels_like_min_temperature.degrees + ), ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: ( + night_forecast.wind.gust.value + ), ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value, ATTR_FORECAST_UV_INDEX: night_forecast.uv_index, ATTR_FORECAST_IS_DAYTIME: False, diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index b409ca09046..9da1a9089a2 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -1,7 +1,5 @@ """Support for retrieving status info from Google Wifi/OnHub routers.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/govee_ble/__init__.py b/homeassistant/components/govee_ble/__init__.py index 07f7ded5447..d801ec7230e 100644 --- a/homeassistant/components/govee_ble/__init__.py +++ b/homeassistant/components/govee_ble/__init__.py @@ -1,7 +1,5 @@ """The Govee Bluetooth BLE integration.""" -from __future__ import annotations - from functools import partial import logging diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index c3c71714e90..91292fe283e 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -1,7 +1,5 @@ """Support for govee-ble binary sensors.""" -from __future__ import annotations - from govee_ble import ( BinarySensorDeviceClass as GoveeBLEBinarySensorDeviceClass, SensorUpdate, @@ -59,7 +57,9 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[ description.device_class ] - for device_key, description in sensor_update.binary_entity_descriptions.items() + for device_key, description in ( + sensor_update.binary_entity_descriptions.items() + ) if description.device_class }, entity_data={ diff --git a/homeassistant/components/govee_ble/config_flow.py b/homeassistant/components/govee_ble/config_flow.py index d48fffdd633..cbf83be5350 100644 --- a/homeassistant/components/govee_ble/config_flow.py +++ b/homeassistant/components/govee_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for govee ble integration.""" -from __future__ import annotations - from typing import Any from govee_ble import GoveeBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -78,6 +77,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN): title=title, data={CONF_DEVICE_TYPE: device.device_type} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address @@ -96,7 +96,10 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_ADDRESS): vol.In( { - address: f"{device.get_device_name(None) or discovery_info.name} ({address})" + address: ( + f"{device.get_device_name(None) or discovery_info.name}" + f" ({address})" + ) for address, ( device, discovery_info, diff --git a/homeassistant/components/govee_ble/coordinator.py b/homeassistant/components/govee_ble/coordinator.py index 4408b7f3199..f75c0c95ff9 100644 --- a/homeassistant/components/govee_ble/coordinator.py +++ b/homeassistant/components/govee_ble/coordinator.py @@ -1,7 +1,5 @@ """The govee Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Callable from logging import Logger @@ -29,7 +27,10 @@ def process_service_info( entry: GoveeBLEConfigEntry, service_info: BluetoothServiceInfoBleak, ) -> SensorUpdate: - """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + """Process a BluetoothServiceInfoBleak. + + Runs side effects and returns sensor data. + """ coordinator = entry.runtime_data data = coordinator.device_data update = data.update(service_info) diff --git a/homeassistant/components/govee_ble/device.py b/homeassistant/components/govee_ble/device.py index 90b602780a2..5c0ff73e62c 100644 --- a/homeassistant/components/govee_ble/device.py +++ b/homeassistant/components/govee_ble/device.py @@ -1,7 +1,5 @@ """Support for govee-ble devices.""" -from __future__ import annotations - from govee_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py index 03f74f37f6a..1162a7f8696 100644 --- a/homeassistant/components/govee_ble/event.py +++ b/homeassistant/components/govee_ble/event.py @@ -1,7 +1,5 @@ """Support for govee_ble event entities.""" -from __future__ import annotations - from govee_ble import ModelInfo, SensorType from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index 848268ae61f..e54c9e5616e 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -1,7 +1,5 @@ """Support for govee ble sensors.""" -from __future__ import annotations - from datetime import date, datetime from decimal import Decimal @@ -147,6 +145,6 @@ class GoveeBluetoothSensorEntity( ) @property - def native_value(self) -> _SensorValueType: # pylint: disable=hass-return-type + def native_value(self) -> _SensorValueType: # pylint: disable=home-assistant-return-type """Return the native value.""" return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 509a8c0137f..a48534afd81 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -1,7 +1,5 @@ """The Govee Light local integration.""" -from __future__ import annotations - import asyncio from contextlib import suppress from errno import EADDRINUSE diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index cd1dc00f9e0..9c49662e9ef 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Govee light local.""" -from __future__ import annotations - import asyncio from contextlib import suppress import logging diff --git a/homeassistant/components/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py index a90a1ff1ff1..41ae13d7563 100644 --- a/homeassistant/components/govee_light_local/const.py +++ b/homeassistant/components/govee_light_local/const.py @@ -11,4 +11,8 @@ CONF_LISTENING_PORT_DEFAULT = 4002 CONF_DISCOVERY_INTERVAL_DEFAULT = 60 SCAN_INTERVAL = timedelta(seconds=30) +# A device is considered unavailable if we have not heard a status response +# from it for three consecutive poll cycles. This tolerates a single dropped +# UDP response plus some jitter before flapping the entity state. +DEVICE_TIMEOUT = SCAN_INTERVAL * 3 DISCOVERY_TIMEOUT = 5 diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 0f6ec98814a..3f85131bdf3 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -1,7 +1,6 @@ """Govee light local.""" -from __future__ import annotations - +from datetime import datetime import logging from typing import Any @@ -22,7 +21,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DEVICE_TIMEOUT, DOMAIN, MANUFACTURER from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry _LOGGER = logging.getLogger(__name__) @@ -118,6 +117,19 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): serial_number=device.fingerprint, ) + @property + def available(self) -> bool: + """Return if the device is reachable. + + The underlying library updates ``lastseen`` whenever the device + replies to a status request. The coordinator polls every + ``SCAN_INTERVAL``, so if we have not heard back within + ``DEVICE_TIMEOUT`` we consider the device offline. + """ + if not super().available: + return False + return datetime.now() - self._device.lastseen < DEVICE_TIMEOUT + @property def is_on(self) -> bool: """Return true if device is on (brightness above 0).""" @@ -205,8 +217,8 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): @callback def _update_callback(self, device: GoveeDevice) -> None: - if self.hass: - self.async_write_ha_state() + """Handle device state updates pushed by the library.""" + self.async_write_ha_state() def _save_last_color_state(self) -> None: color_mode = self.color_mode diff --git a/homeassistant/components/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py index 0550148d2a7..d1b010b0680 100644 --- a/homeassistant/components/gpsd/__init__.py +++ b/homeassistant/components/gpsd/__init__.py @@ -1,7 +1,5 @@ """The GPSD integration.""" -from __future__ import annotations - from gps3.agps3threaded import AGPS3mechanism from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/gpsd/config_flow.py b/homeassistant/components/gpsd/config_flow.py index ac41324f857..648bfb70db7 100644 --- a/homeassistant/components/gpsd/config_flow.py +++ b/homeassistant/components/gpsd/config_flow.py @@ -1,7 +1,5 @@ """Config flow for GPSD integration.""" -from __future__ import annotations - import socket from typing import Any diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index cc2257c88f7..70a91a8eb9b 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for GPSD integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -16,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + ATTR_ELEVATION, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_MODE, @@ -36,7 +35,6 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" -ATTR_ELEVATION = "elevation" ATTR_SPEED = "speed" ATTR_TOTAL_SATELLITES = "total_satellites" ATTR_USED_SATELLITES = "used_satellites" diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index 2b5a38082fc..2587b58a4f7 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -1,7 +1,5 @@ """The Gree Climate integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index e3549973f43..e2ba7673a49 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -1,7 +1,5 @@ """Support for interface with a Gree climate systems.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d697398fc0..21a66dffb42 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -1,7 +1,5 @@ """Helper and wrapper classes for Gree module.""" -from __future__ import annotations - import copy from dataclasses import dataclass from datetime import datetime, timedelta @@ -96,7 +94,8 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"Device {self.name} is unavailable, could not send update request" ) from error else: - # raise update failed if time for more than MAX_ERRORS has passed since last update + # raise update failed if time for more than + # MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time if self.update_interval and elapsed_success >= timedelta( @@ -117,7 +116,8 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( - f"Device {self.name} is unresponsive for too long and now unavailable" + f"Device {self.name} is unresponsive for" + " too long and now unavailable" ) self._last_response_time = utcnow() diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index ab138ea3be6..a59dda276a4 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,7 +1,5 @@ """Support for interface with a Gree climate systems.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/green_planet_energy/__init__.py b/homeassistant/components/green_planet_energy/__init__.py index 0eb00ee8523..589294bb624 100644 --- a/homeassistant/components/green_planet_energy/__init__.py +++ b/homeassistant/components/green_planet_energy/__init__.py @@ -1,7 +1,5 @@ """Green Planet Energy integration for Home Assistant.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/green_planet_energy/config_flow.py b/homeassistant/components/green_planet_energy/config_flow.py index ef5a273ae4a..275a458a374 100644 --- a/homeassistant/components/green_planet_energy/config_flow.py +++ b/homeassistant/components/green_planet_energy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Green Planet Energy integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/green_planet_energy/coordinator.py b/homeassistant/components/green_planet_energy/coordinator.py index 52376d682d5..151220ffc60 100644 --- a/homeassistant/components/green_planet_energy/coordinator.py +++ b/homeassistant/components/green_planet_energy/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Green Planet Energy.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/green_planet_energy/sensor.py b/homeassistant/components/green_planet_energy/sensor.py index dac92b8c4e1..71712d433e3 100644 --- a/homeassistant/components/green_planet_energy/sensor.py +++ b/homeassistant/components/green_planet_energy/sensor.py @@ -1,10 +1,8 @@ """Green Planet Energy sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta import logging from typing import Any @@ -36,6 +34,40 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): value_fn: Callable[[GreenPlanetEnergyAPI, dict[str, Any]], float | datetime | None] +def _get_lowest_price_day_time( + api: GreenPlanetEnergyAPI, data: dict[str, Any] +) -> datetime | None: + """Return timestamp of the lowest-priced day hour (06:00-18:00).""" + now = dt_util.now() + now_h = now.hour + hour = api.get_lowest_price_day_with_hour(data, now_h)[1] + if hour is None: + return None + # After 18:00 the day period is over; use tomorrow's date + base = dt_util.start_of_local_day(now + timedelta(days=1) if now_h >= 18 else now) + return base.replace(hour=hour) + + +def _get_lowest_price_night_time( + api: GreenPlanetEnergyAPI, data: dict[str, Any] +) -> datetime | None: + """Return timestamp of the lowest-priced night hour (18:00-06:00).""" + now = dt_util.now() + now_h = now.hour + hour = api.get_lowest_price_night_with_hour(data)[1] + if hour is None: + return None + + if now_h < 6: + base = dt_util.start_of_local_day( + now - timedelta(days=1) if hour >= 18 else now + ) + else: + base = dt_util.start_of_local_day(now + timedelta(days=1) if hour < 6 else now) + + return base.replace(hour=hour) + + SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [ # Statistical sensors only - hourly prices available via service GreenPlanetEnergySensorEntityDescription( @@ -67,7 +99,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [ translation_placeholders={"time_range": "(06:00-18:00)"}, value_fn=lambda api, data: ( price / 100 - if (price := api.get_lowest_price_day(data)) is not None + if (price := api.get_lowest_price_day(data, dt_util.now().hour)) is not None else None ), ), @@ -76,11 +108,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [ translation_key="lowest_price_day_time", device_class=SensorDeviceClass.TIMESTAMP, translation_placeholders={"time_range": "(06:00-18:00)"}, - value_fn=lambda api, data: ( - dt_util.start_of_local_day().replace(hour=hour) - if (hour := api.get_lowest_price_day_with_hour(data)[1]) is not None - else None - ), + value_fn=_get_lowest_price_day_time, ), GreenPlanetEnergySensorEntityDescription( key="gpe_lowest_price_night", @@ -99,11 +127,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [ translation_key="lowest_price_night_time", device_class=SensorDeviceClass.TIMESTAMP, translation_placeholders={"time_range": "(18:00-06:00)"}, - value_fn=lambda api, data: ( - dt_util.start_of_local_day().replace(hour=hour) - if (hour := api.get_lowest_price_night_with_hour(data)[1]) is not None - else None - ), + value_fn=_get_lowest_price_night_time, ), GreenPlanetEnergySensorEntityDescription( key="gpe_current_price", diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index e3acbcd56e9..6b95dca6eed 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -1,7 +1,5 @@ """Support for monitoring a GreenEye Monitor energy monitor.""" -from __future__ import annotations - import logging import greeneye diff --git a/homeassistant/components/greeneye_monitor/const.py b/homeassistant/components/greeneye_monitor/const.py index 02c6d9845b0..c92b5727f00 100644 --- a/homeassistant/components/greeneye_monitor/const.py +++ b/homeassistant/components/greeneye_monitor/const.py @@ -1,7 +1,5 @@ """Shared constants for the greeneye_monitor integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index b2a16ded0bc..01ff185879d 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,7 +1,5 @@ """Support for the sensors in a GreenEye Monitor.""" -from __future__ import annotations - from typing import Any import greeneye diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 3512595b53a..f7fed337174 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -1,7 +1,5 @@ """Support for Greenwave Reality (TCP Connected) lights.""" -from __future__ import annotations - from datetime import timedelta import logging import os diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 5e199e5bcad..2061ce831cf 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -1,7 +1,5 @@ """Provide the functionality to group entities.""" -from __future__ import annotations - import asyncio from collections.abc import Collection import logging @@ -28,7 +26,6 @@ from homeassistant.helpers.group import ( ) from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass # # Below we ensure the config_flow is imported so it does not need the import @@ -103,7 +100,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Test if the group state is in its ON-state.""" if REG_KEY not in hass.data: @@ -117,11 +113,10 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: # expand_entity_ids and get_entity_ids are for backwards compatibility only -expand_entity_ids = bind_hass(_expand_entity_ids) -get_entity_ids = bind_hass(_get_entity_ids) +expand_entity_ids = _expand_entity_ids +get_entity_ids = _get_entity_ids -@bind_hass def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: """Get all groups that contain this entity. diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index fa1777d5510..0be934ecc9a 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,7 +1,5 @@ """Platform allowing several binary sensor to be grouped into one binary sensor.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/group/button.py b/homeassistant/components/group/button.py index c96d60067a1..0022c2adac0 100644 --- a/homeassistant/components/group/button.py +++ b/homeassistant/components/group/button.py @@ -1,7 +1,5 @@ """Platform allowing several button entities to be grouped into one single button.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index ea279a01dc6..cdde46c9322 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Group integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, cast diff --git a/homeassistant/components/group/const.py b/homeassistant/components/group/const.py index c706247ae01..c23e0c7277a 100644 --- a/homeassistant/components/group/const.py +++ b/homeassistant/components/group/const.py @@ -1,7 +1,5 @@ """Constants for the Group integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index e258c662bc7..e8ed6a76cbd 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,7 +1,5 @@ """Platform allowing several cover to be grouped into one cover.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 4b44de708b5..2493d591051 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -1,7 +1,5 @@ """Provide entity classes for group entities.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable, Collection, Mapping import logging diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index 4009c788362..ae36dcea0b9 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -1,7 +1,5 @@ """Platform allowing several event entities to be grouped into one event.""" -from __future__ import annotations - import itertools from typing import Any diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 621c00bb156..9495b9497ae 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -1,7 +1,5 @@ """Platform allowing several fans to be grouped into one fan.""" -from __future__ import annotations - from functools import reduce import logging from operator import ior diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 585398205f6..7ee020c1c62 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,7 +1,5 @@ """Platform allowing several lights to be grouped into one light.""" -from __future__ import annotations - from collections import Counter import itertools import logging diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 87e7474e03a..72539d882d9 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -1,7 +1,5 @@ """Platform allowing several locks to be grouped into one lock.""" -from __future__ import annotations - import logging from typing import Any @@ -144,7 +142,8 @@ class LockGroup(GroupEntity, LockEntity): self._attr_is_unlocking = None self._attr_is_locked = None else: - # Set attributes based on member states and let the lock entity sort out the correct state + # Set attributes based on member states and let the + # lock entity sort out the correct state self._attr_is_jammed = LockState.JAMMED in states self._attr_is_locking = LockState.LOCKING in states self._attr_is_opening = LockState.OPENING in states diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3371e56b1dc..8c5cc549bb7 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,5 @@ """Platform allowing several media players to be grouped into one media player.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from contextlib import suppress from typing import Any diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 096305c7689..fc47c804948 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -1,7 +1,5 @@ """Group platform for notify component.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from copy import deepcopy diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 2f3c4aa5221..1bb12bba697 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -3,8 +3,6 @@ Legacy group support will not be extended for new domains. """ -from __future__ import annotations - from dataclasses import dataclass from typing import Protocol diff --git a/homeassistant/components/group/reproduce_state.py b/homeassistant/components/group/reproduce_state.py index 06d4f95dee3..10cad191608 100644 --- a/homeassistant/components/group/reproduce_state.py +++ b/homeassistant/components/group/reproduce_state.py @@ -1,7 +1,5 @@ """Module that groups code required to handle state restore for component.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 7bc4b447292..5117a9096de 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,6 +1,7 @@ -"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" +"""Platform allowing several sensors to be grouped into one sensor. -from __future__ import annotations +Provides numeric combinations. +""" from collections.abc import Callable from datetime import datetime @@ -401,7 +402,8 @@ class SensorGroup(GroupEntity, SensorEntity): numeric_state = float(state.state) uom = state.attributes.get("unit_of_measurement") - # Convert the state to the native unit of measurement when we have valid units + # Convert the state to the native unit of + # measurement when we have valid units # and a correct device class if valid_units and uom in valid_units and self._can_convert is True: numeric_state = UNIT_CONVERTERS[self.device_class].convert( @@ -441,10 +443,14 @@ class SensorGroup(GroupEntity, SensorEntity): if entity_id not in self._state_incorrect: self._state_incorrect.add(entity_id) _LOGGER.warning( - "Unable to use state. Only entities with correct unit of measurement" + "Unable to use state. Only entities" + " with correct unit of measurement" " is supported," - " entity %s, value %s with device class %s" - " and unit of measurement %s excluded from calculation in %s", + " entity %s, value %s with" + " device class %s" + " and unit of measurement %s" + " excluded from calculation" + " in %s", entity_id, state.state, self.device_class, @@ -509,7 +515,8 @@ class SensorGroup(GroupEntity, SensorEntity): if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): - # Only return state class if all states are valid when not ignoring non numeric + # Only return state class if all states are valid + # when not ignoring non numeric return None state_classes: list[SensorStateClass] = [] @@ -564,7 +571,8 @@ class SensorGroup(GroupEntity, SensorEntity): if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): - # Only return device class if all states are valid when not ignoring non numeric + # Only return device class if all states are valid + # when not ignoring non numeric return None device_classes: list[SensorDeviceClass] = [] @@ -620,7 +628,8 @@ class SensorGroup(GroupEntity, SensorEntity): if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): - # Only return device class if all states are valid when not ignoring non numeric + # Only return device class if all states are valid + # when not ignoring non numeric return None unit_of_measurements: list[str] = [] @@ -633,7 +642,8 @@ class SensorGroup(GroupEntity, SensorEntity): return None unit_of_measurements.append(_unit_of_measurement) - # Ensure only valid unit of measurements for the specific device class can be used + # Ensure only valid unit of measurements for the + # specific device class can be used if ( ( # Test if uom's in device class is convertible diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 0a13e2cf205..5b3ffe66d3c 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -1,7 +1,5 @@ """Platform allowing several switches to be grouped into one switch.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py index 6d5f875713b..be79af99f7f 100644 --- a/homeassistant/components/group/util.py +++ b/homeassistant/components/group/util.py @@ -1,7 +1,5 @@ """Utility functions to combine state attributes from multiple entities.""" -from __future__ import annotations - from collections.abc import Callable, Iterator from itertools import groupby from math import atan2, cos, degrees, radians, sin @@ -34,7 +32,7 @@ def mean_tuple(*args: Any) -> tuple[float | Any, ...]: def mean_circle(*args: Any) -> tuple[float | Any, ...]: - """Return the circular mean of hue values and arithmetic mean of saturation values from HS color tuples.""" + """Return circular mean of hue and arithmetic mean of saturation from HS tuples.""" if not args: return () diff --git a/homeassistant/components/group/valve.py b/homeassistant/components/group/valve.py index 29fe72cb576..bc3a670f16d 100644 --- a/homeassistant/components/group/valve.py +++ b/homeassistant/components/group/valve.py @@ -1,7 +1,5 @@ """Platform allowing several valves to be grouped into one valve.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 1833d914de6..9467ccfea16 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -10,8 +10,8 @@ Classic API (username/password): Open API V1 (API token): - Stateless — no login call, token is sent as a Bearer header on every request. -- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011 - (V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently; +- Auth failure is signalled by raising GrowattV1ApiError with + error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE. The library NEVER returns a failure silently; any non-zero error_code raises an exception via _process_response(). - Because the library always raises on error, return-value validation after a successful V1 API call is unnecessary — if it returned, the token was valid. @@ -19,22 +19,30 @@ Open API V1 (API token): Error handling pattern for reauth: - Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE → raise ConfigEntryAuthFailed -- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE +- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE → raise ConfigEntryAuthFailed - All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator) """ from collections.abc import Mapping +import datetime from json import JSONDecodeError import logging import growattServer +from growattServer import GrowattV1ApiErrorCode from requests import RequestException from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -46,10 +54,12 @@ from .const import ( DEFAULT_PLANT_ID, DEFAULT_URL, DEPRECATED_URLS, + DEVICE_SCAN_INTERVAL, DOMAIN, LOGIN_INVALID_AUTH_CODE, PLATFORMS, - V1_API_ERROR_NO_PRIVILEGE, + SUPPORTED_DEVICE_TYPES, + V1_DEVICE_TYPES, ) from .coordinator import GrowattConfigEntry, GrowattCoordinator from .models import GrowattRuntimeData @@ -88,7 +98,8 @@ async def async_migrate_entry( achieve: Migration: login() → plant_list() → [cache API instance] Setup: [reuse cached API] → device_list() - This reduces to just 1 login() call during the migration+setup cycle and prevent account lockout. + This reduces to just 1 login() call during the + migration+setup cycle and prevent account lockout. """ _LOGGER.debug( "Migrating config entry from version %s.%s", @@ -125,7 +136,8 @@ async def async_migrate_entry( # Handle DEFAULT_PLANT_ID resolution if config.get(CONF_PLANT_ID) == DEFAULT_PLANT_ID: - # V1 API should never have DEFAULT_PLANT_ID (plant selection happens in config flow) + # V1 API should never have DEFAULT_PLANT_ID + # (plant selection happens in config flow) # If it does, this indicates a corrupted config entry if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: _LOGGER.error( @@ -226,22 +238,28 @@ def _login_classic_api( login_response = api.login(username, password) except (RequestException, JSONDecodeError) as ex: raise ConfigEntryError( - f"Error communicating with Growatt API during login: {ex}" + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(ex)}, ) from ex if not login_response.get("success"): msg = login_response.get("msg", "Unknown error") _LOGGER.debug("Growatt login failed: %s", msg) if msg == LOGIN_INVALID_AUTH_CODE: - raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!") - raise ConfigEntryError(f"Growatt login failed: {msg}") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="login_failed", + translation_placeholders={"message": msg}, + ) return login_response -V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"} - - def get_device_list_v1( api, config: Mapping[str, str] ) -> tuple[list[dict[str, str]], str]: @@ -255,12 +273,25 @@ def get_device_list_v1( try: devices_dict = api.device_list(plant_id) except growattServer.GrowattV1ApiError as e: - if e.error_code == V1_API_ERROR_NO_PRIVILEGE: + if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: raise ConfigEntryAuthFailed( - f"Authentication failed for Growatt API: {e.error_msg or str(e)}" + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"error": e.error_msg or str(e)}, + ) from e + if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="rate_limited", + translation_placeholders={"error": e.error_msg or str(e)}, ) from e raise ConfigEntryError( - f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})" + translation_domain=DOMAIN, + translation_key="api_error_with_code", + translation_placeholders={ + "error": e.error_msg or str(e), + "code": str(e.error_code), + }, ) from e devices = devices_dict.get("devices", []) supported_devices = [ @@ -334,10 +365,15 @@ async def async_setup_entry( devices = await hass.async_add_executor_job(api.device_list, plant_id) except (RequestException, JSONDecodeError) as ex: raise ConfigEntryError( - f"Error communicating with Growatt API during device list: {ex}" + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(ex)}, ) from ex else: - raise ConfigEntryError("Unknown authentication type in config entry.") + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unknown_auth_type", + ) # Create a coordinator for the total sensors total_coordinator = GrowattCoordinator( @@ -350,7 +386,7 @@ async def async_setup_entry( hass, config_entry, device["deviceSn"], device["deviceType"], plant_id ) for device in devices - if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"] + if device["deviceType"] in SUPPORTED_DEVICE_TYPES } # Perform the first refresh for the total coordinator @@ -369,6 +405,96 @@ async def async_setup_entry( # Set up all the entities await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + async def _async_scan_for_new_devices(_now: datetime.datetime) -> None: + """Scan for new or removed devices and update HA accordingly.""" + # Fetch current config (in case it was updated via reauth or options) + current_plant_id = config_entry.data[CONF_PLANT_ID] + + total_coordinator = config_entry.runtime_data.total_coordinator + # Signal the coordinator to also fetch the device list on its next + # _sync_update_data run, then force an immediate refresh. This keeps + # the device_list call in the same executor thread as the existing + # login() + plant-overview call, so for Classic API there is no extra + # login and no thread-safety concern with the shared session. + total_coordinator.request_device_list_scan() + await total_coordinator.async_refresh() + + if not total_coordinator.last_update_success: + _LOGGER.debug("Coordinator refresh failed during device scan, skipping") + return + + current_devices = total_coordinator.device_list + if current_devices is None: + _LOGGER.debug( + "Device list not populated after coordinator refresh, skipping scan" + ) + return + + runtime_data = config_entry.runtime_data + current_device_sns = {device["deviceSn"] for device in current_devices} + + # Remove stale devices + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + device_domain_ids = { + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + if not device_domain_ids: + continue + # Skip the plant "total" device + if current_plant_id in device_domain_ids: + continue + if device_domain_ids.isdisjoint(current_device_sns): + for device_sn in device_domain_ids: + if coordinator := runtime_data.devices.pop(device_sn, None): + await coordinator.async_shutdown() + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry.entry_id, + ) + + # Add new devices + new_coordinators: list[GrowattCoordinator] = [] + for device in current_devices: + device_sn = device["deviceSn"] + device_type = device["deviceType"] + if device_sn in runtime_data.devices: + continue + if device_type not in SUPPORTED_DEVICE_TYPES: + _LOGGER.debug( + "New device %s with type %s is not supported, skipping", + device_sn, + device_type, + ) + continue + coordinator = GrowattCoordinator( + hass, config_entry, device_sn, device_type, current_plant_id + ) + await coordinator.async_refresh() + if not coordinator.last_update_success: + _LOGGER.debug("Failed to refresh new device %s, skipping", device_sn) + await coordinator.async_shutdown() + continue + runtime_data.devices[device_sn] = coordinator + new_coordinators.append(coordinator) + + if new_coordinators: + async_dispatcher_send( + hass, + f"{DOMAIN}_new_device_{config_entry.entry_id}", + new_coordinators, + ) + + config_entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL + ) + ) + return True diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index 8476c16dcfb..73ac3ea51a5 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -5,6 +5,7 @@ import logging from typing import Any import growattServer +from growattServer import GrowattV1ApiErrorCode import requests import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResu from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, + CONF_REGION, CONF_TOKEN, CONF_URL, CONF_USERNAME, @@ -25,14 +27,12 @@ from .const import ( AUTH_PASSWORD, CONF_AUTH_TYPE, CONF_PLANT_ID, - CONF_REGION, DEFAULT_URL, DOMAIN, ERROR_CANNOT_CONNECT, ERROR_INVALID_AUTH, LOGIN_INVALID_AUTH_CODE, SERVER_URLS_NAMES, - V1_API_ERROR_NO_PRIVILEGE, ) _URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()} @@ -148,11 +148,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Network error during credential update: %s", ex) errors["base"] = ERROR_CANNOT_CONNECT except growattServer.GrowattV1ApiError as err: - if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: errors["base"] = ERROR_INVALID_AUTH else: _LOGGER.debug( - "Growatt V1 API error during credential update: %s (Code: %s)", + "Growatt V1 API error during credential" + " update: %s (Code: %s)", err.error_msg or str(err), err.error_code, ) @@ -256,11 +257,13 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Invalid response format during login: %s", ex) return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT}) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - return self._async_show_password_form({"base": ERROR_INVALID_AUTH}) + if not login_response.get("success"): + if login_response.get("msg") == LOGIN_INVALID_AUTH_CODE: + return self._async_show_password_form({"base": ERROR_INVALID_AUTH}) + _LOGGER.debug( + "Growatt login failed: %s", login_response.get("msg", "Unknown error") + ) + return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT}) self.user_id = login_response["user"]["id"] self.data = user_input @@ -298,7 +301,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): e.error_msg or str(e), e.error_code, ) - if e.error_code == V1_API_ERROR_NO_PRIVILEGE: + if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: return self._async_show_token_form({"base": ERROR_INVALID_AUTH}) return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT}) except (ValueError, KeyError, TypeError, AttributeError) as ex: diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 555a5e30547..508c3fdef45 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -1,13 +1,12 @@ """Define constants for the Growatt Server component.""" +from datetime import timedelta + from homeassistant.const import Platform +DEVICE_SCAN_INTERVAL = timedelta(hours=1) + CONF_PLANT_ID = "plant_id" -CONF_REGION = "region" - - -# API key support -CONF_API_KEY = "api_key" # Auth types for config flow AUTH_PASSWORD = "password" @@ -43,13 +42,6 @@ PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] # Growatt Classic API error codes LOGIN_INVALID_AUTH_CODE = "502" -# Growatt Open API V1 error codes -# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019 -V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain -V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token -V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call) -V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100 -V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250 # Config flow error types (also used as abort reasons) ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts @@ -67,3 +59,9 @@ BATT_MODE_GRID_FIRST = 2 # Used to pass logged-in session from async_migrate_entry to async_setup_entry # to avoid double login() calls that trigger API rate limiting CACHED_API_KEY = "_cached_api_" + +# Supported device types for coordinator creation +SUPPORTED_DEVICE_TYPES = ["inverter", "tlx", "storage", "mix", "min", "sph"] + +# Maps V1 API device type integers to coordinator device-type strings +V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"} diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 7fc81e9975d..8df4d8e3806 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -1,13 +1,13 @@ """Coordinator module for managing Growatt data fetching.""" -from __future__ import annotations - import datetime import json import logging from typing import TYPE_CHECKING, Any import growattServer +from growattServer import GrowattV1ApiErrorCode +from requests import RequestException from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntry @@ -28,7 +28,7 @@ from .const import ( DEFAULT_URL, DOMAIN, LOGIN_INVALID_AUTH_CODE, - V1_API_ERROR_NO_PRIVILEGE, + V1_DEVICE_TYPES, ) from .models import GrowattRuntimeData @@ -62,6 +62,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.plant_id = plant_id self.previous_values: dict[str, Any] = {} self._pre_reset_values: dict[str, float] = {} + # Populated during _sync_update_data when request_device_list_scan() was called. + # Consumed by _async_scan_for_new_devices to avoid a separate executor job + # and the extra login() call that would otherwise be required (Classic API). + # Thread safety: written in the executor thread, read on the event loop after + # async_refresh() awaits the executor job — ordering guarantees safe access. + self.device_list: list[dict[str, str]] | None = None + # Flag set on the event loop (request_device_list_scan) and consumed in the + # executor thread (_sync_update_data). Bool assignment is atomic under CPython's GIL. + self._fetch_device_list: bool = False if self.api_version == "v1": self.username = None @@ -89,10 +98,60 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry=config_entry, ) + def _sync_fetch_device_list(self) -> None: + """Fetch the device list for the current plant.""" + if self.api_version == "v1": + try: + devices_dict = self.api.device_list(self.plant_id) + devices = devices_dict.get("devices", []) + self.device_list = [ + { + "deviceSn": device.get("device_sn", ""), + "deviceType": V1_DEVICE_TYPES[device.get("type")], + } + for device in devices + if device.get("type") in V1_DEVICE_TYPES + ] + except growattServer.GrowattV1ApiError as err: + if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"error": err.error_msg or str(err)}, + ) from err + _LOGGER.debug("Failed to fetch V1 device list during scan: %s", err) + self.device_list = None + else: + try: + # login() was already called above; reuse the same session. + devices = self.api.device_list(self.plant_id) + self.device_list = [ + { + "deviceSn": device["deviceSn"], + "deviceType": device["deviceType"], + } + for device in devices + ] + except ( + RequestException, + json.JSONDecodeError, + KeyError, + TypeError, + ) as err: + _LOGGER.debug( + "Failed to fetch Classic device list during scan: %s", err + ) + self.device_list = None + def _sync_update_data(self) -> dict[str, Any]: """Update data via library synchronously.""" _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + # Consume the scan flag immediately so it is cleared even if an exception + # is raised later in this method. + fetch_device_list = self._fetch_device_list + self._fetch_device_list = False + # login only required for classic API if self.api_version == "classic": login_response = self.api.login(self.username, self.password) @@ -100,40 +159,60 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): msg = login_response.get("msg", "Unknown error") if msg == LOGIN_INVALID_AUTH_CODE: raise ConfigEntryAuthFailed( - "Username, password, or URL may be incorrect" + translation_domain=DOMAIN, + translation_key="invalid_credentials", ) - raise UpdateFailed(f"Growatt login failed: {msg}") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="login_failed", + translation_placeholders={"message": msg}, + ) if self.device_type == "total": if self.api_version == "v1": - # The V1 Plant APIs do not provide the same information as the classic plant_info() API + # The V1 Plant APIs do not provide the same + # information as the classic plant_info() API # More specifically: - # 1. There is no monetary information to be found, so today and lifetime money is not available - # 2. There is no nominal power, this is provided by inverter min_energy() - # This means, for the total coordinator we can only fetch and map the following: + # 1. There is no monetary information to be + # found, so today and lifetime money is not + # available + # 2. There is no nominal power, this is + # provided by inverter min_energy() + # This means, for the total coordinator we can + # only fetch and map the following: # todayEnergy -> today_energy # totalEnergy -> total_energy # invTodayPpv -> current_power try: total_info = self.api.plant_energy_overview(self.plant_id) except growattServer.GrowattV1ApiError as err: - if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: raise ConfigEntryAuthFailed( - f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={ + "error": err.error_msg or str(err) + }, ) from err raise UpdateFailed( - f"Error fetching plant energy overview: {err}" + translation_domain=DOMAIN, + translation_key="fetch_data_failed", + translation_placeholders={"error": str(err)}, ) from err total_info["todayEnergy"] = total_info["today_energy"] total_info["totalEnergy"] = total_info["total_energy"] total_info["invTodayPpv"] = total_info["current_power"] else: - # Classic API: use plant_info as before - total_info = self.api.plant_info(self.device_id) + # Classic API: use plant_info as before. + # Copy the response to avoid mutating the dict returned by the library + # (important for test mocks, harmless in production). + total_info = dict(self.api.plant_info(self.device_id)) del total_info["deviceList"] plant_money_text, currency = total_info["plantMoneyText"].split("/") total_info["plantMoneyText"] = plant_money_text total_info["currency"] = currency + if fetch_device_list: + self._sync_fetch_device_list() _LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info) self.data = total_info elif self.device_type == "inverter": @@ -145,11 +224,17 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): min_settings = self.api.min_settings(self.device_id) min_energy = self.api.min_energy(self.device_id) except growattServer.GrowattV1ApiError as err: - if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: raise ConfigEntryAuthFailed( - f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"error": err.error_msg or str(err)}, ) from err - raise UpdateFailed(f"Error fetching min device data: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_data_failed", + translation_placeholders={"error": str(err)}, + ) from err min_info = {**min_details, **min_settings, **min_energy} self.data = min_info @@ -172,11 +257,17 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): sph_detail = self.api.sph_detail(self.device_id) sph_energy = self.api.sph_energy(self.device_id) except growattServer.GrowattV1ApiError as err: - if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE: raise ConfigEntryAuthFailed( - f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"error": err.error_msg or str(err)}, ) from err - raise UpdateFailed(f"Error fetching SPH device data: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_data_failed", + translation_placeholders={"error": str(err)}, + ) from err combined = {**sph_detail, **sph_energy} @@ -209,14 +300,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): mix_chart_entries = mix_detail["chartData"] sorted_keys = sorted(mix_chart_entries) - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, - last_updated_time, # type: ignore[arg-type] - dt_util.get_default_time_zone(), - ) + if sorted_keys: + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) # Dashboard data for mix system dashboard_data = self.api.dashboard_data(self.plant_id) @@ -243,7 +335,20 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: return await self.hass.async_add_executor_job(self._sync_update_data) except json.decoder.JSONDecodeError as err: - raise UpdateFailed(f"Error fetching data: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_data_failed", + translation_placeholders={"error": str(err)}, + ) from err + + def request_device_list_scan(self) -> None: + """Request that the next _sync_update_data also fetches the device list. + + Setting this flag before async_refresh() keeps the device_list call in + the same executor thread as the existing login() + plant-overview fetch, + so no extra login is needed and there is no thread-safety concern. + """ + self._fetch_device_list = True def get_currency(self): """Get the currency.""" @@ -378,7 +483,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: # Use V1 API for token authentication - # The library's _process_response will raise GrowattV1ApiError if error_code != 0 + # The library's _process_response will raise + # GrowattV1ApiError if error_code != 0 await self.hass.async_add_executor_job( self.api.min_write_time_segment, self.device_id, @@ -395,7 +501,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): translation_placeholders={"error": str(err)}, ) from err - # Update coordinator's cached data without making an API call (avoids rate limit) + # Update coordinator's cached data without making an + # API call (avoids rate limit) if self.data: # Update the time segment data in the cache self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M") @@ -598,7 +705,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self.data: await self.async_refresh() - return self.api.sph_read_ac_charge_times(settings_data=self.data) + return self.api.sph_read_ac_charge_times( + self.device_id, settings_data=self.data + ) async def read_ac_discharge_times(self) -> dict: """Read AC discharge time settings from SPH device cache.""" @@ -611,4 +720,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self.data: await self.async_refresh() - return self.api.sph_read_ac_discharge_times(settings_data=self.data) + return self.api.sph_read_ac_discharge_times( + self.device_id, settings_data=self.data + ) diff --git a/homeassistant/components/growatt_server/diagnostics.py b/homeassistant/components/growatt_server/diagnostics.py index 210712220c9..d629fc42f0d 100644 --- a/homeassistant/components/growatt_server/diagnostics.py +++ b/homeassistant/components/growatt_server/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Growatt Server.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index b00983d7f2b..c83124293a1 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "quality_scale": "silver", - "requirements": ["growattServer==1.9.0"] + "quality_scale": "gold", + "requirements": ["growattServer==2.1.0"] } diff --git a/homeassistant/components/growatt_server/models.py b/homeassistant/components/growatt_server/models.py index 8c5f409616a..df53f29c7ec 100644 --- a/homeassistant/components/growatt_server/models.py +++ b/homeassistant/components/growatt_server/models.py @@ -1,7 +1,5 @@ """Models for the Growatt server integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py index 90bba2ac6f5..5356cc617eb 100644 --- a/homeassistant/components/growatt_server/number.py +++ b/homeassistant/components/growatt_server/number.py @@ -1,7 +1,5 @@ """Number platform for Growatt.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -9,9 +7,10 @@ from growattServer import GrowattV1ApiError from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -90,6 +89,17 @@ MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = ( ) +def _create_numbers_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattNumber]: + """Create number entities for a device coordinator.""" + if coordinator.device_type == "min" and coordinator.api_version == "v1": + return [ + GrowattNumber(coordinator, description) for description in MIN_NUMBER_TYPES + ] + return [] + + async def async_setup_entry( hass: HomeAssistant, entry: GrowattConfigEntry, @@ -98,15 +108,29 @@ async def async_setup_entry( """Set up Growatt number entities.""" runtime_data = entry.runtime_data - # Add number entities for each MIN device (only supported with V1 API) async_add_entities( - GrowattNumber(device_coordinator, description) - for device_coordinator in runtime_data.devices.values() - if ( - device_coordinator.device_type == "min" - and device_coordinator.api_version == "v1" + entity + for coordinator in runtime_data.devices.values() + for entity in _create_numbers_for_device(coordinator) + ) + + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add number entities for new devices.""" + new_entities = [ + entity + for coordinator in coordinators + for entity in _create_numbers_for_device(coordinator) + ] + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{entry.entry_id}", + _async_new_device, ) - for description in MIN_NUMBER_TYPES ) diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 5d29f1aa494..038066a5320 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -34,16 +34,24 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo + discovery-update-info: + status: exempt + comment: >- + Growatt data loggers use a generic OUI and serial-number DHCP hostname, + making reliable local discovery not implementable. + discovery: + status: exempt + comment: >- + Growatt data loggers use a generic OUI and serial-number DHCP hostname, + making reliable local discovery not implementable. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -54,7 +62,7 @@ rules: repair-issues: status: exempt comment: Integration does not raise repairable issues. - stale-devices: todo + stale-devices: done # Platinum async-dependency: todo diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index c52ff2515a5..bb91e73a168 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -1,13 +1,13 @@ """Read status of growatt inverters.""" - -from __future__ import annotations +# pylint: disable=home-assistant-missing-parallel-updates from datetime import date, datetime import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,15 +25,46 @@ from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) +def _create_sensors_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattSensor]: + """Create sensor entities for a device coordinator.""" + if coordinator.device_type == "inverter": + sensor_descriptions = INVERTER_SENSOR_TYPES + elif coordinator.device_type in ("tlx", "min"): + sensor_descriptions = TLX_SENSOR_TYPES + elif coordinator.device_type == "storage": + sensor_descriptions = STORAGE_SENSOR_TYPES + elif coordinator.device_type == "mix": + sensor_descriptions = MIX_SENSOR_TYPES + elif coordinator.device_type == "sph": + sensor_descriptions = SPH_SENSOR_TYPES + else: + _LOGGER.debug( + "Device type %s was found but is not supported right now", + coordinator.device_type, + ) + return [] + device_sn = coordinator.device_id + return [ + GrowattSensor( + coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - # Use runtime_data instead of hass.data data = config_entry.runtime_data - entities: list[GrowattSensor] = [] # Add total sensors @@ -49,38 +80,29 @@ async def async_setup_entry( for description in TOTAL_SENSOR_TYPES ) - # Add sensors for each device - for device_sn, device_coordinator in data.devices.items(): - sensor_descriptions: list = [] - if device_coordinator.device_type == "inverter": - sensor_descriptions = list(INVERTER_SENSOR_TYPES) - elif device_coordinator.device_type in ("tlx", "min"): - sensor_descriptions = list(TLX_SENSOR_TYPES) - elif device_coordinator.device_type == "storage": - sensor_descriptions = list(STORAGE_SENSOR_TYPES) - elif device_coordinator.device_type == "mix": - sensor_descriptions = list(MIX_SENSOR_TYPES) - elif device_coordinator.device_type == "sph": - sensor_descriptions = list(SPH_SENSOR_TYPES) - else: - _LOGGER.debug( - "Device type %s was found but is not supported right now", - device_coordinator.device_type, - ) - - entities.extend( - GrowattSensor( - device_coordinator, - name=device_sn, - serial_id=device_sn, - unique_id=f"{device_sn}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ) + # Add sensors for each existing device + for device_coordinator in data.devices.values(): + entities.extend(_create_sensors_for_device(device_coordinator)) async_add_entities(entities) + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add sensor entities for new devices.""" + new_entities: list[GrowattSensor] = [] + for coordinator in coordinators: + new_entities.extend(_create_sensors_for_device(coordinator)) + if new_entities: + async_add_entities(new_entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{config_entry.entry_id}", + _async_new_device, + ) + ) + class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" diff --git a/homeassistant/components/growatt_server/sensor/inverter.py b/homeassistant/components/growatt_server/sensor/inverter.py index dcefc394b87..a91e07068d3 100644 --- a/homeassistant/components/growatt_server/sensor/inverter.py +++ b/homeassistant/components/growatt_server/sensor/inverter.py @@ -1,7 +1,5 @@ """Growatt Sensor definitions for the Inverter type.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( EntityCategory, diff --git a/homeassistant/components/growatt_server/sensor/mix.py b/homeassistant/components/growatt_server/sensor/mix.py index 910ec447b23..a4223c5675f 100644 --- a/homeassistant/components/growatt_server/sensor/mix.py +++ b/homeassistant/components/growatt_server/sensor/mix.py @@ -1,7 +1,5 @@ """Growatt Sensor definitions for the Mix type.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, @@ -244,7 +242,8 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - # This sensor is manually created using the most recent X-Axis value from the chartData + # This sensor is manually created using the most recent + # X-Axis value from the chartData GrowattSensorEntityDescription( key="mix_last_update", translation_key="mix_last_update", @@ -255,7 +254,9 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="mix_import_from_grid_today_combined", translation_key="mix_import_from_grid_today_combined", - api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor + # This id is not present in the raw API data, + # it is added by the sensor + api_key="etouser_combined", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/growatt_server/sensor/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py index e1bb01c5d84..7c900e7a11b 100644 --- a/homeassistant/components/growatt_server/sensor/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py @@ -1,7 +1,5 @@ """Sensor Entity Description for the Growatt integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import SensorEntityDescription diff --git a/homeassistant/components/growatt_server/sensor/sph.py b/homeassistant/components/growatt_server/sensor/sph.py index af3e05da57a..1bce04f5d7f 100644 --- a/homeassistant/components/growatt_server/sensor/sph.py +++ b/homeassistant/components/growatt_server/sensor/sph.py @@ -1,7 +1,5 @@ """Growatt Sensor definitions for the SPH type.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, @@ -72,7 +70,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_export_to_grid", translation_key="mix_export_to_grid", api_key="pacToGridTotal", - native_unit_of_measurement=UnitOfPower.KILO_WATT, + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -80,7 +78,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( key="mix_import_from_grid", translation_key="mix_import_from_grid", api_key="pacToUserR", - native_unit_of_measurement=UnitOfPower.KILO_WATT, + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/growatt_server/sensor/storage.py b/homeassistant/components/growatt_server/sensor/storage.py index 0ad3584ed46..a7a9311bac7 100644 --- a/homeassistant/components/growatt_server/sensor/storage.py +++ b/homeassistant/components/growatt_server/sensor/storage.py @@ -1,7 +1,5 @@ """Growatt Sensor definitions for the Storage type.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, diff --git a/homeassistant/components/growatt_server/sensor/tlx.py b/homeassistant/components/growatt_server/sensor/tlx.py index 7307ac87933..82aec674cf9 100644 --- a/homeassistant/components/growatt_server/sensor/tlx.py +++ b/homeassistant/components/growatt_server/sensor/tlx.py @@ -3,8 +3,6 @@ TLX Type is also shown on the UI as: "MIN/MIC/MOD/NEO" """ -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index a1eb898ae1c..8ca7eb2a2c9 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -1,7 +1,5 @@ """Growatt Sensor definitions for Totals.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import UnitOfEnergy, UnitOfPower diff --git a/homeassistant/components/growatt_server/services.py b/homeassistant/components/growatt_server/services.py index 49728598179..283c36e67cb 100644 --- a/homeassistant/components/growatt_server/services.py +++ b/homeassistant/components/growatt_server/services.py @@ -1,7 +1,5 @@ """Service handlers for Growatt Server integration.""" -from __future__ import annotations - from datetime import datetime, time from typing import TYPE_CHECKING, Any @@ -28,7 +26,7 @@ def _get_coordinators( coordinators: dict[str, GrowattCoordinator] = {} for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: continue for coord in entry.runtime_data.devices.values(): diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 4160c5bac84..9a5648e56bd 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -595,6 +595,15 @@ "api_error": { "message": "Growatt API error: {error}" }, + "api_error_with_code": { + "message": "API error: {error} (Code: {code})" + }, + "auth_failed": { + "message": "Authentication failed for Growatt API: {error}" + }, + "communication_error": { + "message": "Error communicating with Growatt API: {error}" + }, "device_not_configured": { "message": "{device_type} device {serial_number} is not configured for actions." }, @@ -604,6 +613,9 @@ "device_not_growatt": { "message": "Device {device_id} is not a Growatt device." }, + "fetch_data_failed": { + "message": "Error fetching data from Growatt API: {error}" + }, "invalid_batt_mode": { "message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}." }, @@ -613,6 +625,9 @@ "invalid_charge_stop_soc": { "message": "'Charge stop SOC' must be between 0 and 100, got {value}." }, + "invalid_credentials": { + "message": "Username, password, or URL may be incorrect" + }, "invalid_discharge_power": { "message": "'Discharge power' must be between 0 and 100, got {value}." }, @@ -634,11 +649,20 @@ "invalid_time_format_start_time": { "message": "'Start time' must be in HH:MM or HH:MM:SS format." }, + "login_failed": { + "message": "Growatt login failed: {message}" + }, "no_devices_configured": { "message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access." }, + "rate_limited": { + "message": "Growatt API rate limited, will retry: {error}" + }, "token_auth_required": { "message": "This action requires token authentication (V1 API)." + }, + "unknown_auth_type": { + "message": "Unknown authentication type in config entry" } }, "selector": { diff --git a/homeassistant/components/growatt_server/switch.py b/homeassistant/components/growatt_server/switch.py index 8e44e5011ca..d5286bc5035 100644 --- a/homeassistant/components/growatt_server/switch.py +++ b/homeassistant/components/growatt_server/switch.py @@ -1,7 +1,5 @@ """Switch platform for Growatt.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any @@ -10,9 +8,10 @@ from growattServer import GrowattV1ApiError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,6 +46,17 @@ MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = ( ) +def _create_switches_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattSwitch]: + """Create switch entities for a device coordinator.""" + if coordinator.device_type == "min" and coordinator.api_version == "v1": + return [ + GrowattSwitch(coordinator, description) for description in MIN_SWITCH_TYPES + ] + return [] + + async def async_setup_entry( hass: HomeAssistant, entry: GrowattConfigEntry, @@ -55,15 +65,29 @@ async def async_setup_entry( """Set up Growatt switch entities.""" runtime_data = entry.runtime_data - # Add switch entities for each MIN device (only supported with V1 API) async_add_entities( - GrowattSwitch(device_coordinator, description) - for device_coordinator in runtime_data.devices.values() - if ( - device_coordinator.device_type == "min" - and device_coordinator.api_version == "v1" + entity + for coordinator in runtime_data.devices.values() + for entity in _create_switches_for_device(coordinator) + ) + + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add switch entities for new devices.""" + new_entities = [ + entity + for coordinator in coordinators + for entity in _create_switches_for_device(coordinator) + ] + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{entry.entry_id}", + _async_new_device, ) - for description in MIN_SWITCH_TYPES ) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 8c624e2cdd6..3afec3f7100 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -1,7 +1,5 @@ """Support for GTFS (Google/General Transport Format Schema).""" -from __future__ import annotations - import datetime import logging import os diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 192cb62f5df..afc47be08a3 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -1,7 +1,5 @@ """The Elexa Guardian integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass @@ -190,7 +188,8 @@ class PairedSensorManager: try: uids = set(self._sensor_pair_dump_coordinator.data["paired_uids"]) except KeyError: - # Sometimes the paired_uids key can fail to exist; the user can't do anything + # Sometimes the paired_uids key can fail to exist; + # the user can't do anything # about it, so in this case, we quietly abort and return: return diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index d6583abd843..79e23399ff7 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for the Elexa Guardian integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 2ecdbed38ea..ec1b23c0994 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -1,7 +1,5 @@ """Buttons for the Elexa Guardian integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index 81a036dd83c..221fe1f3634 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Elexa Guardian integration.""" -from __future__ import annotations - from typing import Any from aioguardian import Client diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index a49bf6803d9..93f5442da0e 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -1,7 +1,5 @@ """Define Guardian-specific utilities.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from datetime import timedelta diff --git a/homeassistant/components/guardian/diagnostics.py b/homeassistant/components/guardian/diagnostics.py index 22a1bde7817..c31951ed0b1 100644 --- a/homeassistant/components/guardian/diagnostics.py +++ b/homeassistant/components/guardian/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Guardian.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -38,7 +36,9 @@ async def async_get_config_entry_diagnostics( "data": { "valve_controller": { api_category: async_redact_data(coordinator.data, TO_REDACT) - for api_category, coordinator in data.valve_controller_coordinators.items() + for api_category, coordinator in ( + data.valve_controller_coordinators.items() + ) }, "paired_sensors": [ async_redact_data(coordinator.data, TO_REDACT) diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index 760b9423afd..6f4c4f964e4 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -1,7 +1,5 @@ """The Elexa Guardian integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index da4a78d7b7e..d8dd1b7a127 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,5 @@ """Sensors for the Elexa Guardian integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py index 927be7c54a5..119a06e1064 100644 --- a/homeassistant/components/guardian/services.py +++ b/homeassistant/components/guardian/services.py @@ -1,7 +1,5 @@ """Support for Guardian services.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 7640425d8c1..c2494177f04 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,7 +1,5 @@ """Switches for the Elexa Guardian integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index d05b6ef98d9..ed396c68504 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -1,7 +1,5 @@ """Define Guardian-specific utilities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index ad8cd9cae00..a31886d940b 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -1,7 +1,5 @@ """Valves for the Elexa Guardian integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py new file mode 100644 index 00000000000..c3f20729e99 --- /dev/null +++ b/homeassistant/components/guntamatic/__init__.py @@ -0,0 +1,26 @@ +"""The guntamatic integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator + +_LOGGER = logging.getLogger(__name__) +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: + """Set up guntamatic from a config entry.""" + coordinator = GuntamaticCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py new file mode 100644 index 00000000000..57c64f90719 --- /dev/null +++ b/homeassistant/components/guntamatic/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the guntamatic integration.""" + +import logging +from typing import Any + +from guntamatic.heater import Heater, NoSerialException +import requests +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for guntamatic.""" + + _discovered_ip: str + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + heater = Heater(discovery_info.ip) + try: + data = await self.hass.async_add_executor_job(heater.parse_data) + except requests.exceptions.RequestException: + return self.async_abort(reason="cannot_connect") + except NoSerialException: + return self.async_abort(reason="bad_data") + + await self.async_set_unique_id(data["serial"][0]) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._discovered_ip = discovery_info.ip + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title="Guntamatic Heater", + data={CONF_HOST: self._discovered_ip}, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + heater = Heater(user_input[CONF_HOST]) + data = await self.hass.async_add_executor_job(heater.parse_data) + except requests.exceptions.RequestException: + errors["base"] = "cannot_connect" + except NoSerialException: + errors["base"] = "bad_data" + + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # set serial as unique id for deduplication, ip isn't a good match + await self.async_set_unique_id(data["serial"][0]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Guntamatic Heater", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/guntamatic/const.py b/homeassistant/components/guntamatic/const.py new file mode 100644 index 00000000000..022fc4a2dd3 --- /dev/null +++ b/homeassistant/components/guntamatic/const.py @@ -0,0 +1,6 @@ +"""Constants for the guntamatic integration.""" + +from datetime import timedelta + +DOMAIN = "guntamatic" +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py new file mode 100644 index 00000000000..a144b85da5f --- /dev/null +++ b/homeassistant/components/guntamatic/coordinator.py @@ -0,0 +1,42 @@ +"""Coordinator for Guntamatic integration.""" + +import logging + +from guntamatic.heater import Heater +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] + + +class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): + """Guntamatic data coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: GuntamaticConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + self.heater = Heater(entry.data[CONF_HOST]) + + async def _async_update_data(self) -> dict[str, list[str]]: + """Fetch data from heater.""" + try: + data: dict[str, list[str]] = await self.hass.async_add_executor_job( + self.heater.parse_data + ) + except requests.exceptions.ConnectionError as err: + raise UpdateFailed(f"Cannot connect to heater: {err}") from err + return data diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json new file mode 100644 index 00000000000..1b062a9a073 --- /dev/null +++ b/homeassistant/components/guntamatic/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "guntamatic", + "name": "Guntamatic", + "codeowners": ["@JensTimmerman"], + "config_flow": true, + "dependencies": [], + "dhcp": [ + { + "hostname": "kessel*", + "macaddress": "0024BD*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/guntamatic", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["guntamatic==1.9.0"] +} diff --git a/homeassistant/components/guntamatic/quality_scale.yaml b/homeassistant/components/guntamatic/quality_scale.yaml new file mode 100644 index 00000000000..bc26329ff79 --- /dev/null +++ b/homeassistant/components/guntamatic/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Single device + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py new file mode 100644 index 00000000000..2ffd017bb89 --- /dev/null +++ b/homeassistant/components/guntamatic/sensor.py @@ -0,0 +1,151 @@ +"""Support for Guntamatic sensors in Home Assistant.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator + +PARALLEL_UPDATES = 0 + +GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="program", + translation_key="program", + device_class=SensorDeviceClass.ENUM, + options=[ + "off", + "timer", + "dhw", + "heat", + "hibernate", + "hibernate_to", + "dhw_boost", + ], + ), + SensorEntityDescription( + key="boiler_temperature", + translation_key="boiler_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_top_temperature", + translation_key="buffer_top_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_center_temperature", + translation_key="buffer_center_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_bottom_temperature", + translation_key="buffer_bottom_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="domestic_hot_water_0_temperature", + translation_key="domestic_hot_water_0_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_0_temperature", + translation_key="room_0_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_1_temperature", + translation_key="room_1_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_2_temperature", + translation_key="room_2_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_load", + translation_key="buffer_load", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GuntamaticConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Guntamatic sensors from config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + GuntamaticSensor(coordinator, description) + for description in GUNTAMATIC_SENSORS + if description.key in coordinator.data + ) + + +class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): + """Representation of a single Guntamatic sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GuntamaticCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + serial = coordinator.data["serial"][0] + + self._attr_unique_id = f"{serial.replace('.', '_')}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + manufacturer="Guntamatic", + serial_number=serial, + sw_version=coordinator.data["version"][0], + ) + + @property + def native_value(self) -> StateType: + """Return the current value of the sensor.""" + return self.coordinator.data[self.entity_description.key][0] diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json new file mode 100644 index 00000000000..45209405a4b --- /dev/null +++ b/homeassistant/components/guntamatic/strings.json @@ -0,0 +1,77 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to set up this Guntamatic heater?", + "title": "Discovered Guntamatic heater" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Guntamatic heater." + } + } + } + }, + "entity": { + "sensor": { + "boiler_temperature": { + "name": "Boiler temperature" + }, + "buffer_bottom_temperature": { + "name": "Buffer bottom temperature" + }, + "buffer_center_temperature": { + "name": "Buffer center temperature" + }, + "buffer_load": { + "name": "Buffer load" + }, + "buffer_top_temperature": { + "name": "Buffer top temperature" + }, + "domestic_hot_water_0_temperature": { + "name": "Domestic hot water circuit 0 temperature" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "program": { + "name": "Program", + "state": { + "dhw": "Domestic hot water", + "dhw_boost": "Domestic hot water boost", + "heat": "Heat", + "hibernate": "Setback mode", + "hibernate_to": "Away mode", + "off": "[%key:common::state::off%]", + "timer": "Timer" + } + }, + "room_0_temperature": { + "name": "Room 0 temperature" + }, + "room_1_temperature": { + "name": "Room 1 temperature" + }, + "room_2_temperature": { + "name": "Room 2 temperature" + }, + "status": { + "name": "Status" + } + } + } +} diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index 10464acaf17..aa355a465cf 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Habitica integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index e4a60452f9a..c8ab020c816 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -1,7 +1,5 @@ """Habitica button platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 7dd5d5b4675..c05125cbf91 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for Habitica integration.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import asdict from datetime import date, datetime, timedelta @@ -98,7 +96,9 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity): start_date, end_date - timedelta(days=1), inc=True ) # if no end_date is given, return only the next recurrence - return [recurrences.after(start_date, inc=True)] + if (next_date := recurrences.after(start_date, inc=True)) is None: + return [] + return [next_date] class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index b74371be15f..97cdb15a653 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -1,7 +1,5 @@ """Config flow for habitica integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -251,7 +249,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): if not errors and login is not None: await self.async_set_unique_id(str(login.id)) self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( + return self.async_update_and_abort( reauth_entry, data_updates={CONF_API_KEY: login.apiToken}, ) @@ -263,7 +261,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): } ) if not errors and user is not None: - return self.async_update_reload_and_abort( + return self.async_update_and_abort( reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY] ) else: @@ -311,7 +309,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): } ) if not errors and user is not None: - return self.async_update_reload_and_abort( + return self.async_update_and_abort( reconf_entry, data_updates={ CONF_API_KEY: user_input[CONF_API_KEY], diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index bb0c8e0577c..a5ba274a030 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Habitica integration.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py index 40a6d75b366..39189e484e8 100644 --- a/homeassistant/components/habitica/diagnostics.py +++ b/homeassistant/components/habitica/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Habitica integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import CONF_URL diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index e4fff926ace..9d0bb21ccae 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -1,7 +1,5 @@ """Base entity for Habitica.""" -from __future__ import annotations - from typing import TYPE_CHECKING from uuid import UUID diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index d227aa1f2f1..8f331cd029a 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -1,7 +1,5 @@ """Image platform for Habitica integration.""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING from uuid import UUID @@ -96,7 +94,10 @@ class HabiticaImage(HabiticaBase, ImageEntity): self._avatar = extract_avatar(self.user) def _handle_coordinator_update(self) -> None: - """Check if equipped gear and other things have changed since last avatar image generation.""" + """Check if equipped gear and other things have changed. + + Since last avatar image generation. + """ if self.user is not None and self._avatar != self.user: self._avatar = extract_avatar(self.user) diff --git a/homeassistant/components/habitica/notify.py b/homeassistant/components/habitica/notify.py index 8a29ac1d641..284642bd33f 100644 --- a/homeassistant/components/habitica/notify.py +++ b/homeassistant/components/habitica/notify.py @@ -1,7 +1,5 @@ """Notify platform for the Habitica integration.""" -from __future__ import annotations - from abc import abstractmethod from enum import StrEnum from typing import TYPE_CHECKING @@ -111,6 +109,7 @@ class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity): try: await self._send_message(message) except NotAuthorizedError as e: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="send_message_forbidden", @@ -120,6 +119,7 @@ class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity): }, ) from e except NotFoundError as e: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="send_message_not_found", diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index e4f32467329..b8019c082f4 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -1,7 +1,5 @@ """Support for Habitica sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index ee909d1177d..f394883e1f0 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -1,9 +1,7 @@ """Actions for the Habitica integration.""" -from __future__ import annotations - from dataclasses import asdict -from datetime import UTC, date, datetime, time +from datetime import UTC, datetime, time import logging from typing import TYPE_CHECKING, Any, cast from uuid import UUID, uuid4 @@ -305,7 +303,7 @@ async def _cast_skill(call: ServiceCall) -> ServiceResponse: ) from e else: await coordinator.async_request_refresh() - return asdict(response.data) + return asdict(response.data) if call.return_response is True else None async def _manage_quests(call: ServiceCall) -> ServiceResponse: @@ -315,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse: ) coordinator = entry.runtime_data - FUNC_MAP = { + func_map = { SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, @@ -324,7 +322,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse: SERVICE_START_QUEST: coordinator.habitica.start_quest, } - func = FUNC_MAP[call.service] + func = func_map[call.service] try: response = await func() @@ -355,7 +353,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse: translation_placeholders={"reason": str(e)}, ) from e else: - return asdict(response.data) + return asdict(response.data) if call.return_response is True else None async def _score_task(call: ServiceCall) -> ServiceResponse: @@ -420,7 +418,7 @@ async def _score_task(call: ServiceCall) -> ServiceResponse: ) from e else: await coordinator.async_request_refresh() - return asdict(response.data) + return asdict(response.data) if call.return_response is True else None async def _transformation(call: ServiceCall) -> ServiceResponse: @@ -505,7 +503,7 @@ async def _transformation(call: ServiceCall) -> ServiceResponse: translation_placeholders={"reason": str(e)}, ) from e else: - return asdict(response.data) + return asdict(response.data) if call.return_response is True else None async def _get_tasks(call: ServiceCall) -> ServiceResponse: @@ -742,7 +740,7 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: reminders.extend( Reminders( id=uuid4(), - time=datetime.combine(date.today(), r, tzinfo=UTC), + time=datetime.combine(dt_util.now().date(), r, tzinfo=UTC), ) for r in add_reminders if r not in existing_reminder_times @@ -808,10 +806,10 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: data["daysOfMonth"] = [start_date.day] data["weeksOfMonth"] = [] - if interval := call.data.get(ATTR_INTERVAL): + if (interval := call.data.get(ATTR_INTERVAL)) is not None: data["everyX"] = interval - if streak := call.data.get(ATTR_STREAK): + if (streak := call.data.get(ATTR_STREAK)) is not None: data["streak"] = streak try: @@ -841,7 +839,11 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: translation_placeholders={"reason": str(e)}, ) from e else: - return response.data.to_dict(omit_none=True) + return ( + response.data.to_dict(omit_none=True) + if call.return_response is True + else None + ) @callback @@ -861,7 +863,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service_name, _manage_quests, schema=SERVICE_MANAGE_QUEST_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) for service_name in ( @@ -875,7 +877,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service_name, _create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) for service_name in ( SERVICE_CREATE_DAILY, @@ -888,7 +890,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service_name, _create_or_update_task, schema=SERVICE_CREATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( @@ -896,7 +898,7 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_CAST_SKILL, _cast_skill, schema=SERVICE_CAST_SKILL_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( @@ -904,14 +906,14 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_SCORE_HABIT, _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_SCORE_REWARD, _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( @@ -919,7 +921,7 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_TRANSFORMATION, _transformation, schema=SERVICE_TRANSFORMATION_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index 826cd341bba..cd1f76c8a7d 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -1,7 +1,5 @@ """Switch platform for Habitica integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index b8641deb9c2..9622875b7c1 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -1,7 +1,5 @@ """Todo platform for the Habitica integration.""" -from __future__ import annotations - from enum import StrEnum import logging import math @@ -342,7 +340,8 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): which is a calculated value based on recurrence of the task. If a task is a yesterdaily, the due date is the last time a new day has been started. This allows to check off dailies from yesterday, - that have been completed but forgotten to mark as completed before resetting the dailies. + that have been completed but forgotten to mark as completed + before resetting the dailies. Changes of the date input field in Home Assistant will be ignored. """ if TYPE_CHECKING: diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 858b47d6017..a6b9f186d48 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -1,7 +1,5 @@ """Utility functions for Habitica.""" -from __future__ import annotations - from dataclasses import asdict, fields import datetime from math import floor @@ -192,7 +190,10 @@ def quest_attributes(party: GroupData, content: ContentData) -> dict[str, Any]: "quest_details": content.quests[party.quest.key].notes if party.quest.key else None, - "quest_participants": f"{sum(x is True for x in party.quest.members.values())} / {party.memberCount}", + "quest_participants": ( + f"{sum(x is True for x in party.quest.members.values())}" + f" / {party.memberCount}" + ), } diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index 4d32cfb3942..efde9273022 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -1,7 +1,5 @@ """The Hanna Instruments integration.""" -from __future__ import annotations - from typing import Any from hanna_cloud import HannaCloudClient diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 3696cbc31cc..b9fc539a173 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hanna Instruments integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 6845f1a7c10..ee9311877c9 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -5,8 +5,6 @@ including pH, ORP, temperature, and chemical sensors. It uses the Hanna API to fetch readings and updates them periodically. """ -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/hardkernel/__init__.py b/homeassistant/components/hardkernel/__init__.py index 66d2fa9d154..94bfe243089 100644 --- a/homeassistant/components/hardkernel/__init__.py +++ b/homeassistant/components/hardkernel/__init__.py @@ -1,8 +1,6 @@ """The Hardkernel integration.""" -from __future__ import annotations - -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import HassioNotReadyError, get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -16,9 +14,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - if (os_info := get_os_info(hass)) is None: - # The hassio integration has not yet fetched data from the supervisor - raise ConfigEntryNotReady + try: + os_info = get_os_info(hass) + except HassioNotReadyError as err: + raise ConfigEntryNotReady from err board: str | None if (board := os_info.get("board")) is None or not board.startswith("odroid"): diff --git a/homeassistant/components/hardkernel/config_flow.py b/homeassistant/components/hardkernel/config_flow.py index 5fa3611aa86..816dc8add63 100644 --- a/homeassistant/components/hardkernel/config_flow.py +++ b/homeassistant/components/hardkernel/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Hardkernel integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py index 45af8b4e146..d27de9f504e 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -1,7 +1,5 @@ """The Hardkernel hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import BoardInfo, HardwareInfo from homeassistant.components.hassio import get_os_info from homeassistant.core import HomeAssistant, callback @@ -22,8 +20,7 @@ BOARD_NAMES = { @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" - if (os_info := get_os_info(hass)) is None: - raise HomeAssistantError + os_info = get_os_info(hass) board: str | None if (board := os_info.get("board")) is None: raise HomeAssistantError diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index 7d616ef4cef..6246acc6fbb 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -1,7 +1,5 @@ """The Hardware integration.""" -from __future__ import annotations - import psutil_home_assistant as ha_psutil from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hardware/const.py b/homeassistant/components/hardware/const.py index 2bde218c19d..e7e5f18532b 100644 --- a/homeassistant/components/hardware/const.py +++ b/homeassistant/components/hardware/const.py @@ -1,7 +1,5 @@ """Constants for the Hardware integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/hardware/hardware.py b/homeassistant/components/hardware/hardware.py index 9fd257a14a7..5a49388f6cb 100644 --- a/homeassistant/components/hardware/hardware.py +++ b/homeassistant/components/hardware/hardware.py @@ -1,7 +1,5 @@ """The Hardware integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.integration_platform import ( diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py index a972b567db2..0c86b7f5927 100644 --- a/homeassistant/components/hardware/models.py +++ b/homeassistant/components/hardware/models.py @@ -1,7 +1,5 @@ """Models for Hardware.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Protocol diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 599eab34135..c01741589ee 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -1,7 +1,5 @@ """The Hardware websocket API.""" -from __future__ import annotations - import contextlib from dataclasses import asdict from datetime import datetime, timedelta diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py index 22bc1a6d529..c89f8fc567d 100644 --- a/homeassistant/components/harman_kardon_avr/media_player.py +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -1,7 +1,5 @@ """Support for interface with an Harman/Kardon or JBL AVR.""" -from __future__ import annotations - import hkavr import voluptuous as vol diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index ed956b07183..e4b6f1c7c2c 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1,7 +1,5 @@ """The Logitech Harmony Hub integration.""" -from __future__ import annotations - import logging from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index b507c0ae112..c54935cd6f7 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Logitech Harmony Hub integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -40,7 +38,10 @@ from .util import ( _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field + {vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, + extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 4dba412a17c..ce7b38c4e69 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -1,7 +1,5 @@ """Harmony data object which contains the Harmony Client.""" -from __future__ import annotations - from collections.abc import Iterable import logging diff --git a/homeassistant/components/harmony/entity.py b/homeassistant/components/harmony/entity.py index 8bfa9fbad4d..435791d8ca2 100644 --- a/homeassistant/components/harmony/entity.py +++ b/homeassistant/components/harmony/entity.py @@ -1,7 +1,5 @@ """Base class Harmony entities.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index abda5b74522..1d01a215614 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.5.3"], + "requirements": ["aioharmony==1.0.8"], "ssdp": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index d09dc3ff7e8..314384bf407 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -1,7 +1,5 @@ """Support for Harmony Hub devices.""" -from __future__ import annotations - from collections.abc import Iterable import json import logging @@ -261,7 +259,8 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): def write_config_file(self) -> None: """Write Harmony configuration file. - This is a handy way for users to figure out the available commands for automations. + This is a handy way for users to figure out the + available commands for automations. """ _LOGGER.debug( "%s: Writing hub configuration to file: %s", diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py index 3f45a23e26e..d2c742f4e20 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -1,7 +1,5 @@ """Support for Harmony Hub select activities.""" -from __future__ import annotations - import logging from homeassistant.components.select import SelectEntity diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py index ec42c47f9ff..ab08f1b5a50 100644 --- a/homeassistant/components/harmony/subscriber.py +++ b/homeassistant/components/harmony/subscriber.py @@ -1,7 +1,5 @@ """Mixin class for handling harmony callback subscriptions.""" -from __future__ import annotations - import asyncio import logging from typing import Any, NamedTuple diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 9e1ab66ab82..b65bea04e2a 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1,42 +1,31 @@ """Support for Hass.io.""" -from __future__ import annotations - import asyncio from dataclasses import replace -from datetime import datetime +from functools import partial import logging import os import struct -from typing import Any, cast +from typing import Any -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import ( GreenOptions, - HomeAssistantInfo, HomeAssistantOptions, - HostInfo, - InstalledAddon, - NetworkInfo, - OSInfo, - RootInfo, - StoreInfo, - SupervisorInfo, SupervisorOptions, YellowOptions, ) -import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.auth.models import RefreshToken -from homeassistant.components import frontend, panel_custom +from homeassistant.auth.models import RefreshToken, User +from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler from homeassistant.components.http import ( CONF_SERVER_HOST, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, - StaticPathConfig, ) +from homeassistant.components.onboarding import async_is_onboarded from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, @@ -44,7 +33,8 @@ from homeassistant.const import ( SERVER_PORT, Platform, ) -from homeassistant.core import Event, HassJob, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -52,10 +42,8 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import create_eager_task # config_flow, diagnostics, system_health, and entity platforms are imported to # ensure other dependencies that wait for hassio are not waiting @@ -78,23 +66,20 @@ from .auth import async_setup_auth_view from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, - ATTR_REPOSITORIES, - DATA_ADDONS_LIST, DATA_COMPONENT, DATA_CONFIG_STORE, - DATA_CORE_INFO, - DATA_HOST_INFO, - DATA_INFO, + DATA_HASSIO_HOST, + DATA_HASSIO_HTTP_CONFIG, + DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, - DATA_NETWORK_INFO, - DATA_OS_INFO, - DATA_STORE, - DATA_SUPERVISOR_INFO, DOMAIN, - HASSIO_UPDATE_INTERVAL, + MAIN_COORDINATOR, + STATS_COORDINATOR, ) from .coordinator import ( - HassioDataUpdateCoordinator, + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, get_addons_info, get_addons_list, get_addons_stats, @@ -109,6 +94,7 @@ from .coordinator import ( get_supervisor_stats, ) from .discovery import async_setup_discovery_view +from .exceptions import HassioNotReadyError from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view @@ -125,6 +111,7 @@ __all__ = [ "AddonManager", "AddonState", "GreenOptions", + "HassioNotReadyError", "SupervisorError", "YellowOptions", "async_update_diagnostics", @@ -152,12 +139,7 @@ _LOGGER = logging.getLogger(__name__) # wait for the import of the platforms PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] -CONF_FRONTEND_REPO = "development_repo" - -CONFIG_SCHEMA = vol.Schema( - {vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})}, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) DEPRECATION_URL = ( @@ -194,7 +176,60 @@ def hostname_from_addon_slug(addon_slug: str) -> str: return addon_slug.replace("_", "-") -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +@callback +def _check_deprecated_setup(hass: HomeAssistant) -> None: + """Create issues for deprecated installation types and architectures.""" + os_info = get_os_info(hass) + info = get_info(hass) + is_haos = info.get("hassos") is not None + board = os_info.get("board") + arch = info.get("arch", "unknown") + unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} + unsupported_os_on_board = board in {"rpi3", "rpi4"} + if is_haos and (unsupported_board or unsupported_os_on_board): + issue_id = "deprecated_os_" + if unsupported_os_on_board: + issue_id += "aarch64" + elif unsupported_board: + issue_id += "armv7" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_guide": "https://www.home-assistant.io/installation/", + }, + ) + bit32 = _is_32_bit() + deprecated_architecture = bit32 and not ( + unsupported_board or unsupported_os_on_board + ) + if not is_haos or deprecated_architecture: + issue_id = "deprecated" + if not is_haos: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": "OS" if is_haos else "Supervised", + "arch": arch, + }, + ) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hass.io component.""" # Check local setup for env in ("SUPERVISOR", "SUPERVISOR_TOKEN"): @@ -207,30 +242,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) return False - async_load_websocket_api(hass) - frontend.async_register_built_in_panel(hass, "app") - host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host) - supervisor_client = get_supervisor_client(hass) - - try: - await supervisor_client.supervisor.ping() - except SupervisorError: - _LOGGER.warning("Not connected with the supervisor / system too busy!") + hass.data[DATA_HASSIO_HOST] = host + hass.data[DATA_HASSIO_HTTP_CONFIG] = config.get("http", {}) # Load the store config_store = HassioConfig(hass) await config_store.load() hass.data[DATA_CONFIG_STORE] = config_store - refresh_token = None + # Cache the Supervisor user. Create one if necessary + user: User | None = None if (hassio_user := config_store.data.hassio_user) is not None: user = await hass.auth.async_get_user(hassio_user) - if user and user.refresh_tokens: - refresh_token = list(user.refresh_tokens.values())[0] - + if user: # Migrate old Hass.io users to be admin. if not user.is_admin: await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) @@ -239,62 +266,114 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: if user.name == "Hass.io": await hass.auth.async_update_user(user, name=HASSIO_USER_NAME) - if refresh_token is None: + if user is None: user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - refresh_token = await hass.auth.async_create_refresh_token(user) config_store.update(hassio_user=user.id) - # This overrides the normal API call that would be forwarded - development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) - if development_repo is not None: - await hass.http.async_register_static_paths( - [ - StaticPathConfig( - "/api/hassio/app", - os.path.join(development_repo, "hassio/build"), - False, - ) - ] - ) + assert user is not None + hass.data[DATA_HASSIO_SUPERVISOR_USER] = user + async_load_websocket_api(hass) hass.http.register_view(HassIOView(host, websession)) + async_setup_services(hass) + async_setup_discovery_view(hass) + async_setup_auth_view(hass) + async_setup_ingress_view(hass) + async_setup_addon_panel(hass) + frontend.async_register_built_in_panel(hass, "app") - await panel_custom.async_register_panel( - hass, - frontend_url_path="hassio", - webcomponent_name="hassio-main", - js_url="/api/hassio/app/entrypoint.js", - embed_iframe=True, - require_admin=True, + discovery_flow.async_create_flow( + hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) + return True - async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken): - """Update Home Assistant API data on Hass.io.""" - options = HomeAssistantOptions( - ssl=CONF_SSL_CERTIFICATE in http_config, - port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT, - refresh_token=refresh_token.token, - ) - if http_config.get(CONF_SERVER_HOST) is not None: - options = replace(options, watchdog=False) - _LOGGER.warning( - "Found incompatible HTTP option 'server_host'. Watchdog feature" - " disabled" - ) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + supervisor_client = get_supervisor_client(hass) + try: + await supervisor_client.supervisor.ping() + except SupervisorError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_not_connected", + ) from err + + # During onboarding, Supervisor may be out of date. Attempt an update now + # so that core loads against an up-to-date Supervisor. A + # SupervisorBadRequestError means there is no update available, proceed + # normally. No exception means an update was triggered and we must wait for + # it to complete. Any other SupervisorError means something unexpected went + # wrong and we cannot proceed right now. + if not async_is_onboarded(hass): try: - await supervisor_client.homeassistant.set_options(options) + await supervisor_client.supervisor.update() + except SupervisorBadRequestError: + pass # No update available, proceed normally. except SupervisorError as err: - _LOGGER.warning( - "Failed to update Home Assistant options in Supervisor: %s", err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_not_connected", + ) from err + else: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_update_pending", ) - update_hass_api_task = hass.async_create_task( - update_hass_api(config.get("http", {}), refresh_token), eager_start=True + # Get or create a refresh token for the Supervisor user + user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + if user.refresh_tokens: + refresh_token = list(user.refresh_tokens.values())[0] + else: + refresh_token = await hass.auth.async_create_refresh_token(user) + + # Set up coordinators — these can raise ConfigEntryNotReady. + # Register listeners only after all refreshes succeed to avoid accumulation + # across retries. + dev_reg = dr.async_get(hass) + + coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg) + await coordinator.async_config_entry_first_refresh() + hass.data[MAIN_COORDINATOR] = coordinator + + addon_coordinator = HassioAddOnDataUpdateCoordinator( + hass, entry, dev_reg, coordinator.jobs ) + await addon_coordinator.async_config_entry_first_refresh() + hass.data[ADDONS_COORDINATOR] = addon_coordinator + + stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry) + await stats_coordinator.async_config_entry_first_refresh() + hass.data[STATS_COORDINATOR] = stats_coordinator + + # All coordinators refreshed successfully. Start the issues listener and + # install the stop handler now so they are never left in a partial state + # if a coordinator refresh raises ConfigEntryNotReady. + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) + + def _unload_supervisor_issues() -> None: + if ( + supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None) + ) is not None: + supervisor_issues.unload() + + entry.async_on_unload(_unload_supervisor_issues) + + async def _async_stop(hass: HomeAssistant, restart: bool) -> None: + """Stop or restart home assistant.""" + if restart: + await supervisor_client.homeassistant.restart() + else: + await supervisor_client.homeassistant.stop() + + # Install a custom handler for the homeassistant.restart / stop services, + # and restore the default one when this entry unloads. + async_set_stop_handler(hass, _async_stop) + entry.async_on_unload(partial(async_set_stop_handler, hass)) last_timezone = None last_country = None @@ -318,208 +397,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: except SupervisorError as err: _LOGGER.warning("Failed to update Supervisor options: %s", err) - hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) + entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)) - push_config_task = hass.async_create_task(push_config(None), eager_start=True) - # Start listening for problems with supervisor and making issues - hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) - issues_task = hass.async_create_task(issues.setup(), eager_start=True) + http_config: dict[str, Any] = hass.data.get(DATA_HASSIO_HTTP_CONFIG, {}) - # Register services - async_setup_services(hass, supervisor_client) - - async def update_info_data(_: datetime | None = None) -> None: - """Update last available supervisor information.""" - supervisor_client = get_supervisor_client(hass) - - try: - ( - root_info, - host_info, - store_info, - homeassistant_info, - supervisor_info, - os_info, - network_info, - addons_list, - ) = cast( - tuple[ - RootInfo, - HostInfo, - StoreInfo, - HomeAssistantInfo, - SupervisorInfo, - OSInfo, - NetworkInfo, - list[InstalledAddon], - ], - await asyncio.gather( - create_eager_task(supervisor_client.info()), - create_eager_task(supervisor_client.host.info()), - create_eager_task(supervisor_client.store.info()), - create_eager_task(supervisor_client.homeassistant.info()), - create_eager_task(supervisor_client.supervisor.info()), - create_eager_task(supervisor_client.os.info()), - create_eager_task(supervisor_client.network.info()), - create_eager_task(supervisor_client.addons.list()), - ), - ) - - except SupervisorError as err: - _LOGGER.warning("Can't read Supervisor data: %s", err) - else: - hass.data[DATA_INFO] = root_info.to_dict() - hass.data[DATA_HOST_INFO] = host_info.to_dict() - hass.data[DATA_STORE] = store_info.to_dict() - hass.data[DATA_CORE_INFO] = homeassistant_info.to_dict() - hass.data[DATA_SUPERVISOR_INFO] = supervisor_info.to_dict() - hass.data[DATA_OS_INFO] = os_info.to_dict() - hass.data[DATA_NETWORK_INFO] = network_info.to_dict() - hass.data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in addons_list] - - # Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility - # Can drop this after removal period - hass.data[DATA_SUPERVISOR_INFO]["repositories"] = hass.data[DATA_STORE][ - ATTR_REPOSITORIES - ] - hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST] - - async_call_later( - hass, - HASSIO_UPDATE_INTERVAL, - HassJob(update_info_data, cancel_on_shutdown=True), + async def update_hass_api(refresh_token: RefreshToken) -> None: + """Update Home Assistant API data on Hass.io.""" + options = HomeAssistantOptions( + ssl=CONF_SSL_CERTIFICATE in http_config, + port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT, + refresh_token=refresh_token.token, ) - # Fetch data - update_info_task = hass.async_create_task(update_info_data(), eager_start=True) + if http_config.get(CONF_SERVER_HOST) is not None: + options = replace(options, watchdog=False) + _LOGGER.warning( + "Found incompatible HTTP option 'server_host'. Watchdog feature" + " disabled" + ) - async def _async_stop(hass: HomeAssistant, restart: bool) -> None: - """Stop or restart home assistant.""" - if restart: - await supervisor_client.homeassistant.restart() - else: - await supervisor_client.homeassistant.stop() + try: + await supervisor_client.homeassistant.set_options(options) + except SupervisorError as err: + _LOGGER.warning( + "Failed to update Home Assistant options in Supervisor: %s", err + ) - # Set a custom handler for the homeassistant.restart and homeassistant.stop services - async_set_stop_handler(hass, _async_stop) - - # Init discovery Hass.io feature - async_setup_discovery_view(hass) - - # Init auth Hass.io feature - assert user is not None - async_setup_auth_view(hass, user) - - # Init ingress Hass.io feature - async_setup_ingress_view(hass, host) - - # Init add-on ingress panels - panels_task = hass.async_create_task( - async_setup_addon_panel(hass), eager_start=True + # Push initial config to Supervisor and start issues listener + await asyncio.gather( + update_hass_api(refresh_token), push_config(None), issues.setup() ) - # Make sure to await the update_info task before - # _async_setup_hardware_integration is called - # so the hardware integration can be set up - # and does not fallback to calling later - await update_hass_api_task - await panels_task - await update_info_task - await push_config_task - await issues_task - # Setup hardware integration for the detected board type - @callback - def _async_setup_hardware_integration(_: datetime | None = None) -> None: - """Set up hardware integration for the detected board type.""" - if (os_info := get_os_info(hass)) is None: - # os info not yet fetched from supervisor, retry later - async_call_later( - hass, - HASSIO_UPDATE_INTERVAL, - async_setup_hardware_integration_job, - ) - return - if (board := os_info.get("board")) is None: - return - if (hw_integration := HARDWARE_INTEGRATIONS.get(board)) is None: - return + # This is done after the initial data refresh to ensure that + # the board info is available. + os_info = get_os_info(hass) + if (board := os_info.get("board")) is not None and ( + hw_integration := HARDWARE_INTEGRATIONS.get(board) + ) is not None: discovery_flow.async_create_flow( hass, hw_integration, context={"source": SOURCE_SYSTEM}, data={} ) - async_setup_hardware_integration_job = HassJob( - _async_setup_hardware_integration, cancel_on_shutdown=True - ) - - _async_setup_hardware_integration() - discovery_flow.async_create_flow( - hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} - ) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up a config entry.""" - dev_reg = dr.async_get(hass) - coordinator = HassioDataUpdateCoordinator(hass, entry, dev_reg) - await coordinator.async_config_entry_first_refresh() - hass.data[ADDONS_COORDINATOR] = coordinator - - def deprecated_setup_issue() -> None: - os_info = get_os_info(hass) - info = get_info(hass) - if os_info is None or info is None: - return - is_haos = info.get("hassos") is not None - board = os_info.get("board") - arch = info.get("arch", "unknown") - unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} - unsupported_os_on_board = board in {"rpi3", "rpi4"} - if is_haos and (unsupported_board or unsupported_os_on_board): - issue_id = "deprecated_os_" - if unsupported_os_on_board: - issue_id += "aarch64" - elif unsupported_board: - issue_id += "armv7" - ir.async_create_issue( - hass, - "homeassistant", - issue_id, - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_guide": "https://www.home-assistant.io/installation/", - }, - ) - bit32 = _is_32_bit() - deprecated_architecture = bit32 and not ( - unsupported_board or unsupported_os_on_board - ) - if not is_haos or deprecated_architecture: - issue_id = "deprecated" - if not is_haos: - issue_id += "_method" - if deprecated_architecture: - issue_id += "_architecture" - ir.async_create_issue( - hass, - "homeassistant", - issue_id, - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_type": "OS" if is_haos else "Supervised", - "arch": arch, - }, - ) - listener() - - listener = coordinator.async_add_listener(deprecated_setup_issue) + # Check for deprecated setup and create issues if needed. + # This is done after the initial data refresh to ensure that + # the info needed is available. + _check_deprecated_setup(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -530,11 +453,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - # Unload coordinator - coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR] - coordinator.unload() - - # Pop coordinator + # Pop coordinators and entry-level data + hass.data.pop(MAIN_COORDINATOR, None) hass.data.pop(ADDONS_COORDINATOR, None) + hass.data.pop(STATS_COORDINATOR, None) return unload_ok diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 9a4841b4bc9..dd4fe89669e 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -1,7 +1,5 @@ """Provide add-on management.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 2a88788a2b5..314b6ebd6d7 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -8,28 +8,34 @@ from aiohasupervisor.models import IngressPanel from aiohttp import web from homeassistant.components import frontend -from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant +from homeassistant.components.http import HomeAssistantView, require_admin +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import Event, HomeAssistant from .handler import get_supervisor_client _LOGGER = logging.getLogger(__name__) -async def async_setup_addon_panel(hass: HomeAssistant) -> None: +def async_setup_addon_panel(hass: HomeAssistant) -> None: """Add-on Ingress Panel setup.""" hassio_addon_panel = HassIOAddonPanel(hass) hass.http.register_view(hassio_addon_panel) - # If panels are exists - if not (panels := await hassio_addon_panel.get_panels()): - return + # Handle existing panels on startup + async def _async_panel_start_handler(event: Event) -> None: + """Process all existing panels on startup.""" + # Check if there are panels to register + if not (panels := await hassio_addon_panel.get_panels()): + return - # Register available panels - for addon, data in panels.items(): - if not data.enable: - continue - _register_panel(hass, addon, data) + # Register available panels + for addon, data in panels.items(): + if not data.enable: + continue + _register_panel(hass, addon, data) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_panel_start_handler) class HassIOAddonPanel(HomeAssistantView): @@ -43,6 +49,7 @@ class HassIOAddonPanel(HomeAssistantView): self.hass = hass self.client = get_supervisor_client(hass) + @require_admin async def post(self, request: web.Request, addon: str) -> web.Response: """Handle new add-on panel requests.""" panels = await self.get_panels() @@ -56,6 +63,7 @@ class HassIOAddonPanel(HomeAssistantView): _register_panel(self.hass, addon, panels[addon]) return web.Response() + @require_admin async def delete(self, request: web.Request, addon: str) -> web.Response: """Handle remove add-on panel requests.""" frontend.async_remove_panel(self.hass, addon) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 8589bc0f134..c64fe7db79f 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -6,52 +6,60 @@ import logging import os from aiohttp import web -from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized +from aiohttp.web_exceptions import ( + HTTPNotFound, + HTTPServiceUnavailable, + HTTPUnauthorized, +) import voluptuous as vol -from homeassistant.auth.models import User from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView +from homeassistant.components.http.const import is_supervisor_unix_socket_request from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER _LOGGER = logging.getLogger(__name__) @callback -def async_setup_auth_view(hass: HomeAssistant, user: User) -> None: +def async_setup_auth_view(hass: HomeAssistant) -> None: """Auth setup.""" - hassio_auth = HassIOAuth(hass, user) - hassio_password_reset = HassIOPasswordReset(hass, user) - - hass.http.register_view(hassio_auth) - hass.http.register_view(hassio_password_reset) + hass.http.register_view(HassIOAuth(hass)) + hass.http.register_view(HassIOPasswordReset(hass)) class HassIOBaseAuth(HomeAssistantView): """Hass.io view to handle auth requests.""" - def __init__(self, hass: HomeAssistant, user: User) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize WebView.""" self.hass = hass - self.user = user def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" - # Check caller IP - hassio_ip = os.environ["SUPERVISOR"].split(":")[0] - assert request.transport - if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address( - hassio_ip - ): - _LOGGER.error("Invalid auth request from %s", request.remote) - raise HTTPUnauthorized + user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + if user is None: + raise HTTPServiceUnavailable + + # Requests over the Supervisor Unix socket are authenticated by the + # http auth middleware as the Supervisor user, so the caller-IP check + # below does not apply (and would crash, since `peername` is empty for + # Unix sockets). The user-ID check still runs to ensure only the + # Supervisor user can reach this endpoint. + if not is_supervisor_unix_socket_request(request): + hassio_ip = os.environ["SUPERVISOR"].split(":")[0] + assert request.transport + peername = request.transport.get_extra_info("peername") + if not peername or ip_address(peername[0]) != ip_address(hassio_ip): + _LOGGER.error("Invalid auth request from %s", request.remote) + raise HTTPUnauthorized # Check caller token - if request[KEY_HASS_USER].id != self.user.id: + if request[KEY_HASS_USER].id != user.id: _LOGGER.error("Invalid auth request from %s", request[KEY_HASS_USER].name) raise HTTPUnauthorized diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b7702d9f3b9..7b789e50d43 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -1,7 +1,5 @@ """Backup functionality for supervised installations.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping from contextlib import suppress diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index dda9d92bf19..4c4819169b5 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -1,10 +1,9 @@ """Binary sensor platform for Hass.io addons.""" -from __future__ import annotations - +from collections.abc import Callable from dataclasses import dataclass -import itertools +from aiohasupervisor.models import AddonState from aiohasupervisor.models.mounts import MountState from homeassistant.components.binary_sensor import ( @@ -16,40 +15,46 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ADDONS_COORDINATOR, - ATTR_STARTED, - ATTR_STATE, - DATA_KEY_ADDONS, - DATA_KEY_MOUNTS, -) +from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR from .entity import HassioAddonEntity, HassioMountEntity -@dataclass(frozen=True) -class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): - """Hassio binary sensor entity description.""" +@dataclass(frozen=True, kw_only=True) +class HassioAddonBinarySensorEntityDescription(BinarySensorEntityDescription): + """Hass.io add-on binary sensor entity description.""" - target: str | None = None + value_fn: Callable[[HassioAddonBinarySensor], bool] + + +@dataclass(frozen=True, kw_only=True) +class HassioMountBinarySensorEntityDescription(BinarySensorEntityDescription): + """Hass.io mount binary sensor entity description.""" + + value_fn: Callable[[HassioMountBinarySensor], bool] ADDON_ENTITY_DESCRIPTIONS = ( - HassioBinarySensorEntityDescription( + HassioAddonBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, entity_registry_enabled_default=False, - key=ATTR_STATE, + key="state", translation_key="state", - target=ATTR_STARTED, + value_fn=lambda entity: ( + entity.coordinator.data.addons[entity.addon_slug].addon.state + == AddonState.STARTED + ), ), ) MOUNT_ENTITY_DESCRIPTIONS = ( - HassioBinarySensorEntityDescription( + HassioMountBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_registry_enabled_default=False, - key=ATTR_STATE, + key="state", translation_key="mount", - target=MountState.ACTIVE.value, + value_fn=lambda entity: ( + entity.coordinator.data.mounts[entity.mount_name].state == MountState.ACTIVE + ), ), ) @@ -60,60 +65,50 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Binary sensor set up for Hass.io config entry.""" - coordinator = hass.data[ADDONS_COORDINATOR] + addons_coordinator = hass.data[ADDONS_COORDINATOR] + coordinator = hass.data[MAIN_COORDINATOR] async_add_entities( - itertools.chain( - [ + [ + *[ HassioAddonBinarySensor( addon=addon, - coordinator=coordinator, + coordinator=addons_coordinator, entity_description=entity_description, ) - for addon in coordinator.data[DATA_KEY_ADDONS].values() + for addon in addons_coordinator.data.addons.values() for entity_description in ADDON_ENTITY_DESCRIPTIONS ], - [ + *[ HassioMountBinarySensor( mount=mount, coordinator=coordinator, entity_description=entity_description, ) - for mount in coordinator.data[DATA_KEY_MOUNTS].values() + for mount in coordinator.data.mounts.values() for entity_description in MOUNT_ENTITY_DESCRIPTIONS ], - ) + ] ) class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): """Binary sensor for Hass.io add-ons.""" - entity_description: HassioBinarySensorEntityDescription + entity_description: HassioAddonBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ - self.entity_description.key - ] - if self.entity_description.target is None: - return value - return value == self.entity_description.target + return self.entity_description.value_fn(self) class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity): """Binary sensor for Hass.io mount.""" - entity_description: HassioBinarySensorEntityDescription + entity_description: HassioMountBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - value = getattr( - self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name], - self.entity_description.key, - ) - if self.entity_description.target is None: - return value - return value == self.entity_description.target + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/hassio/config.py b/homeassistant/components/hassio/config.py index f277249ee94..e84ba5a476f 100644 --- a/homeassistant/components/hassio/config.py +++ b/homeassistant/components/hassio/config.py @@ -1,7 +1,5 @@ """Provide persistent configuration for the hassio integration.""" -from __future__ import annotations - from dataclasses import dataclass, replace from typing import Required, Self, TypedDict diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index e8bed912fd7..6f5c451d36f 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Home Assistant Supervisor integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 66ffeb9b3c7..c1ece85104e 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,16 +1,35 @@ """Hass.io const variables.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from aiohasupervisor.models import ( + AddonsStats, + HomeAssistantInfo, + HostInfo, + InstalledAddon, + InstalledAddonComplete, + NetworkInfo, + OSInfo, + RootInfo, + StoreInfo, + SupervisorInfo, + ) + + from homeassistant.auth.models import User + from .config import HassioConfig + from .coordinator import ( + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, + ) from .handler import HassIO + from .issues import SupervisorIssues DOMAIN = "hassio" @@ -55,8 +74,6 @@ ATTR_WS_EVENT = "event" X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" -X_HASS_USER_ID = "X-Hass-User-ID" -X_HASS_IS_ADMIN = "X-Hass-Is-Admin" X_HASS_SOURCE = "X-Hass-Source" WS_TYPE = "type" @@ -77,33 +94,45 @@ EVENT_JOB = "job" UPDATE_KEY_SUPERVISOR = "supervisor" STARTUP_COMPLETE = "complete" -ADDONS_COORDINATOR = "hassio_addons_coordinator" +MAIN_COORDINATOR: HassKey[HassioMainDataUpdateCoordinator] = HassKey( + "hassio_main_coordinator" +) +ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey( + "hassio_addons_coordinator" +) +STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey( + "hassio_stats_coordinator" +) DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store") -DATA_CORE_INFO = "hassio_core_info" +DATA_CORE_INFO: HassKey[HomeAssistantInfo] = HassKey("hassio_core_info") DATA_CORE_STATS = "hassio_core_stats" -DATA_HOST_INFO = "hassio_host_info" -DATA_STORE = "hassio_store" -DATA_INFO = "hassio_info" -DATA_OS_INFO = "hassio_os_info" -DATA_NETWORK_INFO = "hassio_network_info" -DATA_SUPERVISOR_INFO = "hassio_supervisor_info" +DATA_HOST_INFO: HassKey[HostInfo] = HassKey("hassio_host_info") +DATA_STORE: HassKey[StoreInfo] = HassKey("hassio_store") +DATA_INFO: HassKey[RootInfo] = HassKey("hassio_info") +DATA_OS_INFO: HassKey[OSInfo] = HassKey("hassio_os_info") +DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info") +DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info") DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" -DATA_ADDONS_INFO = "hassio_addons_info" -DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_ADDONS_LIST = "hassio_addons_list" -HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) +DATA_ADDONS_INFO: HassKey[dict[str, InstalledAddonComplete | None]] = HassKey( + "hassio_addons_info" +) +DATA_ADDONS_STATS: HassKey[dict[str, AddonsStats | None]] = HassKey( + "hassio_addons_stats" +) +DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list") +HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) +HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) +HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60) ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" -ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" -ATTR_STATE = "state" ATTR_STARTED = "started" ATTR_URL = "url" ATTR_REPOSITORY = "repository" @@ -114,8 +143,11 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" -DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" +DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues") DATA_KEY_MOUNTS = "mounts" +DATA_HASSIO_HTTP_CONFIG: HassKey[dict[str, Any]] = HassKey("hassio_http_config") +DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") +DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") PLACEHOLDER_KEY_ADDON = "addon" PLACEHOLDER_KEY_ADDON_INFO = "addon_info" @@ -133,6 +165,7 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned" ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space" ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon" ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon" +ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER = "legacy_homeassistant_folder" ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed" @@ -140,19 +173,6 @@ CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" CONTAINER_STATS = "stats" -CONTAINER_INFO = "info" - -# This is a mapping of which endpoint the key in the addon data -# is obtained from so we know which endpoint to update when the -# coordinator polls for updates. -KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { - ATTR_VERSION_LATEST: {CONTAINER_INFO}, - ATTR_MEMORY_PERCENT: {CONTAINER_STATS}, - ATTR_CPU_PERCENT: {CONTAINER_STATS}, - ATTR_VERSION: {CONTAINER_INFO}, - ATTR_STATE: {CONTAINER_INFO}, -} - REQUEST_REFRESH_DELAY = 10 HELP_URLS = { diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 679614acbec..6d15b4ad195 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -1,57 +1,57 @@ """Data for Hass.io.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Awaitable -from copy import deepcopy +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import ( + AddonsStats, AddonState, CIFSMountResponse, + HomeAssistantInfo, + HomeAssistantStats, + HostInfo, InstalledAddon, + InstalledAddonComplete, + NetworkInfo, NFSMountResponse, + OSInfo, + ResponseData, + RootInfo, StoreInfo, + SupervisorInfo, + SupervisorStats, ) -from aiohasupervisor.models.base import ResponseData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.const import ATTR_MANUFACTURER from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.loader import bind_hass from .const import ( - ATTR_AUTO_UPDATE, + ATTR_ADDONS, + ATTR_DATA, ATTR_REPOSITORIES, - ATTR_REPOSITORY, - ATTR_SLUG, - ATTR_URL, - ATTR_VERSION, - CONTAINER_INFO, + ATTR_STARTUP, + ATTR_UPDATE_KEY, + ATTR_WS_EVENT, CONTAINER_STATS, CORE_CONTAINER, DATA_ADDONS_INFO, DATA_ADDONS_LIST, DATA_ADDONS_STATS, - DATA_COMPONENT, DATA_CORE_INFO, DATA_CORE_STATS, DATA_HOST_INFO, DATA_INFO, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_MOUNTS, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DATA_NETWORK_INFO, DATA_OS_INFO, @@ -59,11 +59,18 @@ from .const import ( DATA_SUPERVISOR_INFO, DATA_SUPERVISOR_STATS, DOMAIN, - HASSIO_UPDATE_INTERVAL, + EVENT_SUPERVISOR_EVENT, + EVENT_SUPERVISOR_UPDATE, + HASSIO_ADDON_UPDATE_INTERVAL, + HASSIO_MAIN_UPDATE_INTERVAL, + HASSIO_STATS_UPDATE_INTERVAL, REQUEST_REFRESH_DELAY, + STARTUP_COMPLETE, SUPERVISOR_CONTAINER, + UPDATE_KEY_SUPERVISOR, SupervisorEntityModel, ) +from .exceptions import HassioNotReadyError from .handler import get_supervisor_client from .jobs import SupervisorJobs @@ -73,127 +80,270 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +@dataclass +class HassioMainData: + """Data class for HassioMainDataUpdateCoordinator.""" + + core: HomeAssistantInfo + supervisor: SupervisorInfo + host: HostInfo + mounts: dict[str, CIFSMountResponse | NFSMountResponse] + os: OSInfo | None + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "core": self.core.to_dict(), + "supervisor": self.supervisor.to_dict(), + "host": self.host.to_dict(), + "mounts": {name: mount.to_dict() for name, mount in self.mounts.items()}, + "os": self.os.to_dict() if self.os is not None else None, + } + + +@dataclass +class AddonData: + """Data for a single installed addon.""" + + addon: InstalledAddon + auto_update: bool + repository: str + + +@dataclass +class HassioAddonData: + """Data class for HassioAddOnDataUpdateCoordinator.""" + + addons: dict[str, AddonData] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "addons": { + slug: { + "addon": addon_data.addon.to_dict(), + "auto_update": addon_data.auto_update, + "repository": addon_data.repository, + } + for slug, addon_data in self.addons.items() + }, + } + + +@dataclass +class HassioStatsData: + """Data class for HassioStatsDataUpdateCoordinator.""" + + core: HomeAssistantStats | None + supervisor: SupervisorStats | None + addons: dict[str, AddonsStats | None] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "core": self.core.to_dict() if self.core is not None else None, + "supervisor": ( + self.supervisor.to_dict() if self.supervisor is not None else None + ), + "addons": { + slug: stats.to_dict() if stats is not None else None + for slug, stats in self.addons.items() + }, + } + + +def _installed_addon_from_complete(info: InstalledAddonComplete) -> InstalledAddon: + """Build an InstalledAddon from an InstalledAddonComplete object. + + InstalledAddonComplete contains a superset of InstalledAddon fields. + This helper extracts only the fields needed for InstalledAddon so fresh + data from an addon_info call can be stored in AddonData.addon. + """ + return InstalledAddon( + advanced=info.advanced, + available=info.available, + build=info.build, + description=info.description, + homeassistant=info.homeassistant, + icon=info.icon, + logo=info.logo, + name=info.name, + repository=info.repository, + slug=info.slug, + stage=info.stage, + update_available=info.update_available, + url=info.url, + version_latest=info.version_latest, + version=info.version, + detached=info.detached, + state=info.state, + ) + + @callback -@bind_hass -def get_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_info(hass: HomeAssistant) -> dict[str, Any]: """Return generic information from Supervisor. Async friendly. """ - return hass.data.get(DATA_INFO) + info = hass.data.get(DATA_INFO) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass -def get_host_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_host_info(hass: HomeAssistant) -> dict[str, Any]: """Return generic host information. Async friendly. """ - return hass.data.get(DATA_HOST_INFO) + info = hass.data.get(DATA_HOST_INFO) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass -def get_store(hass: HomeAssistant) -> dict[str, Any] | None: +def get_store(hass: HomeAssistant) -> dict[str, Any]: """Return store information. Async friendly. """ - return hass.data.get(DATA_STORE) + info = hass.data.get(DATA_STORE) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass -def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any]: """Return Supervisor information. Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_INFO) + info = hass.data.get(DATA_SUPERVISOR_INFO) + if info is None: + raise HassioNotReadyError + result = info.to_dict() + # Deprecated 2026.4.0: Folding repositories and addons into supervisor_info + # for backwards compatibility. Can be removed after deprecation period. + if (store := hass.data.get(DATA_STORE)) is not None: + result[ATTR_REPOSITORIES] = [repo.to_dict() for repo in store.repositories] + if (addons_list := hass.data.get(DATA_ADDONS_LIST)) is not None: + result[ATTR_ADDONS] = [addon.to_dict() for addon in addons_list] + return result @callback -@bind_hass -def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_network_info(hass: HomeAssistant) -> dict[str, Any]: """Return Host Network information. Async friendly. """ - return hass.data.get(DATA_NETWORK_INFO) + info = hass.data.get(DATA_NETWORK_INFO) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass -def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | None: +def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]: """Return Addons info. Async friendly. """ - return hass.data.get(DATA_ADDONS_INFO) + addons_info: dict[str, InstalledAddonComplete | None] | None = hass.data.get( + DATA_ADDONS_INFO + ) + if addons_info is None: + raise HassioNotReadyError + # Converting these fields for compatibility as that is what was returned here. + # We'll leave it this way as long as these component APIs continue to return + # dictionaries. If/when we switch to using the aiohasupervisor models for everything + # internally and externally that will be dropped. + return { + slug: dict( + hassio_api=info.supervisor_api, + hassio_role=info.supervisor_role, + **info.to_dict(), + ) + if info is not None + else None + for slug, info in addons_info.items() + } @callback -def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None: +def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]]: """Return list of installed addons and subset of details for each. Async friendly. """ - return hass.data.get(DATA_ADDONS_LIST) + addons = hass.data.get(DATA_ADDONS_LIST) + if addons is None: + raise HassioNotReadyError + return [addon.to_dict() for addon in addons] @callback -@bind_hass def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]: """Return Addons stats. Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) or {} + addons_stats: dict[str, AddonsStats | None] = hass.data.get(DATA_ADDONS_STATS) or {} + return { + slug: stats.to_dict() if stats is not None else None + for slug, stats in addons_stats.items() + } @callback -@bind_hass def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: """Return core stats. Async friendly. """ - return hass.data.get(DATA_CORE_STATS) or {} + stats = hass.data.get(DATA_CORE_STATS) + return stats.to_dict() if stats is not None else {} @callback -@bind_hass def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: """Return supervisor stats. Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) or {} + stats = hass.data.get(DATA_SUPERVISOR_STATS) + return stats.to_dict() if stats is not None else {} @callback -@bind_hass -def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_os_info(hass: HomeAssistant) -> dict[str, Any]: """Return OS information. Async friendly. """ - return hass.data.get(DATA_OS_INFO) + info = hass.data.get(DATA_OS_INFO) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass -def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None: +def get_core_info(hass: HomeAssistant) -> dict[str, Any]: """Return Home Assistant Core information from Supervisor. Async friendly. """ - return hass.data.get(DATA_CORE_INFO) + info = hass.data.get(DATA_CORE_INFO) + if info is None: + raise HassioNotReadyError + return info.to_dict() @callback -@bind_hass def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: """Return Supervisor issues info. @@ -204,19 +354,20 @@ def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: @callback def async_register_addons_in_dev_reg( - entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]] + entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[AddonData] ) -> None: """Register addons in the device registry.""" - for addon in addons: + for addon_data in addons: + addon = addon_data.addon params = DeviceInfo( - identifiers={(DOMAIN, addon[ATTR_SLUG])}, + identifiers={(DOMAIN, addon.slug)}, model=SupervisorEntityModel.ADDON, - sw_version=addon[ATTR_VERSION], - name=addon[ATTR_NAME], + sw_version=addon.version, + name=addon.name, entry_type=dr.DeviceEntryType.SERVICE, - configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}", + configuration_url=f"homeassistant://hassio/addon/{addon.slug}", ) - if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL): + if manufacturer := addon_data.repository or addon.url: params[ATTR_MANUFACTURER] = manufacturer dev_reg.async_get_or_create(config_entry_id=entry_id, **params) @@ -242,14 +393,14 @@ def async_register_mounts_in_dev_reg( @callback def async_register_os_in_dev_reg( - entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any] + entry_id: str, dev_reg: dr.DeviceRegistry, os_info: OSInfo ) -> None: """Register OS in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "OS")}, manufacturer="Home Assistant", model=SupervisorEntityModel.OS, - sw_version=os_dict[ATTR_VERSION], + sw_version=os_info.version, name="Home Assistant Operating System", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -276,14 +427,14 @@ def async_register_host_in_dev_reg( def async_register_core_in_dev_reg( entry_id: str, dev_reg: dr.DeviceRegistry, - core_dict: dict[str, Any], + core_info: HomeAssistantInfo, ) -> None: - """Register OS in the device registry.""" + """Register core in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "core")}, manufacturer="Home Assistant", model=SupervisorEntityModel.CORE, - sw_version=core_dict[ATTR_VERSION], + sw_version=core_info.version, name="Home Assistant Core", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -294,14 +445,14 @@ def async_register_core_in_dev_reg( def async_register_supervisor_in_dev_reg( entry_id: str, dev_reg: dr.DeviceRegistry, - supervisor_dict: dict[str, Any], + supervisor_info: SupervisorInfo, ) -> None: - """Register OS in the device registry.""" + """Register supervisor in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "supervisor")}, manufacturer="Home Assistant", model=SupervisorEntityModel.SUPERVISOR, - sw_version=supervisor_dict[ATTR_VERSION], + sw_version=supervisor_info.version, name="Home Assistant Supervisor", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -318,7 +469,310 @@ def async_remove_devices_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioDataUpdateCoordinator(DataUpdateCoordinator): +class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]): + """Class to retrieve Hass.io container stats.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=HASSIO_STATS_UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self.supervisor_client = get_supervisor_client(hass) + self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) + + async def _async_update_data(self) -> HassioStatsData: + """Update stats data via library.""" + try: + await self._fetch_stats() + except SupervisorError as err: + raise UpdateFailed(f"Error on Supervisor API: {err}") from err + + return HassioStatsData( + core=self.hass.data.get(DATA_CORE_STATS), + supervisor=self.hass.data.get(DATA_SUPERVISOR_STATS), + addons=self.hass.data.get(DATA_ADDONS_STATS) or {}, + ) + + async def _fetch_stats(self) -> None: + """Fetch container stats for subscribed entities.""" + container_updates = self._container_updates + data = self.hass.data + client = self.supervisor_client + + # Fetch core and supervisor stats + updates: dict[str, Awaitable] = {} + if container_updates.get(CORE_CONTAINER, {}).get(CONTAINER_STATS): + updates[DATA_CORE_STATS] = client.homeassistant.stats() + if container_updates.get(SUPERVISOR_CONTAINER, {}).get(CONTAINER_STATS): + updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats() + + if updates: + api_results: list[ResponseData] = await asyncio.gather(*updates.values()) + for key, result in zip(updates, api_results, strict=True): + data[key] = result + + # Fetch addon stats + addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or [] + started_addons = { + addon.slug + for addon in addons_list + if addon.state in {AddonState.STARTED, AddonState.STARTUP} + } + + addons_stats: dict[str, AddonsStats | None] = data.setdefault( + DATA_ADDONS_STATS, {} + ) + + # Clean up cache for stopped/removed addons + for slug in addons_stats.keys() - started_addons: + del addons_stats[slug] + + # Fetch stats for addons with subscribed entities + addon_stats_results = dict( + await asyncio.gather( + *[ + self._update_addon_stats(slug) + for slug in started_addons + if container_updates.get(slug, {}).get(CONTAINER_STATS) + ] + ) + ) + addons_stats.update(addon_stats_results) + + async def _update_addon_stats(self, slug: str) -> tuple[str, AddonsStats | None]: + """Update single addon stats.""" + try: + stats = await self.supervisor_client.addons.addon_stats(slug) + except SupervisorError as err: + _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) + return (slug, None) + return (slug, stats) + + @callback + def async_enable_container_updates( + self, slug: str, entity_id: str, types: set[str] + ) -> CALLBACK_TYPE: + """Enable stats updates for a container.""" + enabled_updates = self._container_updates[slug] + for key in types: + enabled_updates[key].add(entity_id) + + @callback + def _remove() -> None: + for key in types: + enabled_updates[key].discard(entity_id) + if not enabled_updates[key]: + del enabled_updates[key] + if not enabled_updates: + self._container_updates.pop(slug, None) + + return _remove + + +class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): + """Class to retrieve Hass.io Add-on status.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + dev_reg: dr.DeviceRegistry, + jobs: SupervisorJobs, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=HASSIO_ADDON_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # hammering the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self.entry_id = config_entry.entry_id + self.dev_reg = dev_reg + self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) + self.supervisor_client = get_supervisor_client(hass) + self.jobs = jobs + + async def _async_update_data(self) -> HassioAddonData: + """Update data via library.""" + is_first_update = not self.data + client = self.supervisor_client + + try: + installed_addons: list[InstalledAddon] = await client.addons.list() + all_addons = {addon.slug for addon in installed_addons} + + # Fetch addon info for all addons on first update, or only + # for addons with subscribed entities on subsequent updates. + addon_info_results: dict[str, InstalledAddonComplete | None] = dict( + await asyncio.gather( + *[ + self._update_addon_info(slug) + for slug in all_addons + if is_first_update or self._addon_info_subscriptions.get(slug) + ] + ) + ) + except SupervisorError as err: + raise UpdateFailed(f"Error on Supervisor API: {err}") from err + + # Update hass.data for legacy accessor functions + self.hass.data[DATA_ADDONS_LIST] = installed_addons + + # Update addon info cache in hass.data + addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) + for slug in addon_info_cache.keys() - all_addons: + del addon_info_cache[slug] + addon_info_cache.update(addon_info_results) + + # Build repository name lookup from store data + store = self.hass.data.get(DATA_STORE) + repositories: dict[str, str] = ( + {repo.slug: repo.name for repo in store.repositories} if store else {} + ) + + # Build clean coordinator data + new_addons: dict[str, AddonData] = {} + for addon in installed_addons: + addon_info = addon_info_cache.get(addon.slug) + auto_update = addon_info.auto_update if addon_info is not None else False + repo_slug = addon.repository + repository = repositories.get(repo_slug, repo_slug) + new_addons[addon.slug] = AddonData( + addon=addon, + auto_update=auto_update, + repository=repository, + ) + new_data = HassioAddonData(addons=new_addons) + + # If this is the initial refresh, register all addons + if is_first_update: + async_register_addons_in_dev_reg( + self.entry_id, self.dev_reg, list(new_data.addons.values()) + ) + + # Remove add-ons that are no longer installed from device registry + supervisor_addon_devices = { + list(device.identifiers)[0][1] + for device in self.dev_reg.devices.get_devices_for_config_entry_id( + self.entry_id + ) + if device.model == SupervisorEntityModel.ADDON + } + if stale_addons := supervisor_addon_devices - set(new_data.addons): + async_remove_devices_from_dev_reg(self.dev_reg, stale_addons) + + # If there are new add-ons, we should reload the config entry so we can + # create new devices and entities. We can return the new data because + # coordinator will be recreated. + if self.data and (set(new_data.addons) - set(self.data.addons)): + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry_id) + ) + + return new_data + + async def get_changelog(self, addon_slug: str) -> str | None: + """Get the changelog for an add-on.""" + try: + return await self.supervisor_client.store.addon_changelog(addon_slug) + except SupervisorNotFoundError: + return None + + async def _update_addon_info( + self, slug: str + ) -> tuple[str, InstalledAddonComplete | None]: + """Return the info for an addon.""" + try: + info = await self.supervisor_client.addons.addon_info(slug) + except SupervisorError as err: + _LOGGER.warning("Could not fetch info for %s: %s", slug, err) + return (slug, None) + return (slug, info) + + @callback + def async_enable_addon_info_updates( + self, slug: str, entity_id: str + ) -> CALLBACK_TYPE: + """Enable info updates for an add-on.""" + self._addon_info_subscriptions[slug].add(entity_id) + + @callback + def _remove() -> None: + self._addon_info_subscriptions[slug].discard(entity_id) + if not self._addon_info_subscriptions[slug]: + del self._addon_info_subscriptions[slug] + + return _remove + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + if not scheduled and not raise_on_auth_failed: + # Force reloading add-on updates for non-scheduled + # updates. + # + # If `raise_on_auth_failed` is set, it means this is + # the first refresh and we do not want to delay + # startup or cause a timeout so we only refresh the + # updates if this is not a scheduled refresh and + # we are not doing the first refresh. + try: + await self.supervisor_client.store.reload() + except SupervisorError as err: + _LOGGER.warning("Error on Supervisor API: %s", err) + + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: + """Force refresh of addon info data for a specific addon.""" + try: + slug, info = await self._update_addon_info(addon_slug) + except SupervisorError as err: + _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) + return + + if info is not None and self.data and slug in self.data.addons: + updated = AddonData( + addon=_installed_addon_from_complete(info), + auto_update=info.auto_update, + repository=self.data.addons[slug].repository, + ) + self.async_set_updated_data( + HassioAddonData(addons={**self.data.addons, slug: updated}) + ) + + # Update addon info cache in hass.data + addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) + addon_info_cache[slug] = info + + +class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Class to retrieve Hass.io status.""" config_entry: ConfigEntry @@ -332,107 +786,105 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=HASSIO_UPDATE_INTERVAL, + update_interval=HASSIO_MAIN_UPDATE_INTERVAL, # We don't want an immediate refresh since we want to avoid - # fetching the container stats right away and avoid hammering - # the Supervisor API on startup + # hammering the Supervisor API on startup request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) - self.hassio = hass.data[DATA_COMPONENT] - self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg - self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None - self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict( - lambda: defaultdict(set) - ) + self.is_hass_os = False self.supervisor_client = get_supervisor_client(hass) self.jobs = SupervisorJobs(hass) + self._dispatcher_disconnect = async_dispatcher_connect( + hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event + ) - async def _async_update_data(self) -> dict[str, Any]: + @callback + def _supervisor_event(self, event: dict[str, Any]) -> None: + """Refresh coordinator data when Supervisor restarts after an update.""" + if ( + event.get(ATTR_WS_EVENT) == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + + async def _async_update_data(self) -> HassioMainData: """Update data via library.""" is_first_update = not self.data + client = self.supervisor_client try: - await self.force_data_refresh(is_first_update) + # Cast is required here because asyncio.gather only has overloads to + # maintain typing for 6 arguments. It falls back to list[] + # after that which is what mypy sees here since we have 7 API calls. + ( + info, + core_info, + supervisor_info, + os_info, + host_info, + store_info, + network_info, + ) = cast( + tuple[ + RootInfo, + HomeAssistantInfo, + SupervisorInfo, + OSInfo, + HostInfo, + StoreInfo, + NetworkInfo, + ], + await asyncio.gather( + client.info(), + client.homeassistant.info(), + client.supervisor.info(), + client.os.info(), + client.host.info(), + client.store.info(), + client.network.info(), + ), + ) + mounts_info = await client.mounts.info() + await self.jobs.refresh_data(is_first_update) except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err - new_data: dict[str, Any] = {} - supervisor_info = get_supervisor_info(self.hass) or {} - addons_info = get_addons_info(self.hass) or {} - addons_stats = get_addons_stats(self.hass) - store_data = get_store(self.hass) - mounts_info = await self.supervisor_client.mounts.info() - addons_list = get_addons_list(self.hass) or [] + # Build clean coordinator data + self.is_hass_os = info.hassos is not None + new_data = HassioMainData( + core=core_info, + supervisor=supervisor_info, + host=host_info, + mounts={mount.name: mount for mount in mounts_info.mounts}, + os=os_info if self.is_hass_os else None, + ) - if store_data: - repositories = { - repo.slug: repo.name - for repo in StoreInfo.from_dict(store_data).repositories - } - else: - repositories = {} + # Update hass.data for legacy accessor functions + self.hass.data[DATA_INFO] = info + self.hass.data[DATA_CORE_INFO] = core_info + self.hass.data[DATA_OS_INFO] = os_info + self.hass.data[DATA_HOST_INFO] = host_info + self.hass.data[DATA_STORE] = store_info + self.hass.data[DATA_NETWORK_INFO] = network_info + self.hass.data[DATA_SUPERVISOR_INFO] = supervisor_info - new_data[DATA_KEY_ADDONS] = { - (slug := addon[ATTR_SLUG]): { - **addon, - **(addons_stats.get(slug) or {}), - ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get( - ATTR_AUTO_UPDATE, False - ), - ATTR_REPOSITORY: repositories.get( - repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug - ), - } - for addon in addons_list - } - if self.is_hass_os: - new_data[DATA_KEY_OS] = get_os_info(self.hass) - - new_data[DATA_KEY_CORE] = { - **(get_core_info(self.hass) or {}), - **get_core_stats(self.hass), - } - new_data[DATA_KEY_SUPERVISOR] = { - **supervisor_info, - **get_supervisor_stats(self.hass), - } - new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} - new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts} - - # If this is the initial refresh, register all addons and return the dict + # If this is the initial refresh, register all main components if is_first_update: - async_register_addons_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() - ) async_register_mounts_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values() - ) - async_register_core_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE] + self.entry_id, self.dev_reg, list(new_data.mounts.values()) ) + async_register_core_in_dev_reg(self.entry_id, self.dev_reg, new_data.core) async_register_supervisor_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] + self.entry_id, self.dev_reg, new_data.supervisor ) async_register_host_in_dev_reg(self.entry_id, self.dev_reg) if self.is_hass_os: - async_register_os_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] - ) - - # Remove add-ons that are no longer installed from device registry - supervisor_addon_devices = { - list(device.identifiers)[0][1] - for device in self.dev_reg.devices.get_devices_for_config_entry_id( - self.entry_id - ) - if device.model == SupervisorEntityModel.ADDON - } - if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]): - async_remove_devices_from_dev_reg(self.dev_reg, stale_addons) + async_register_os_in_dev_reg(self.entry_id, self.dev_reg, os_info) # Remove mounts that no longer exists from device registry supervisor_mount_devices = { @@ -442,7 +894,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ) if device.model == SupervisorEntityModel.MOUNT } - if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]): + if stale_mounts := supervisor_mount_devices - set(new_data.mounts): async_remove_devices_from_dev_reg( self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts} ) @@ -453,160 +905,16 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # Remove the OS device if it exists and the installation is not hassos self.dev_reg.async_remove_device(dev.id) - # If there are new add-ons or mounts, we should reload the config entry so we can - # create new devices and entities. We can return an empty dict because + # If there are new mounts, we should reload the config entry so we can + # create new devices and entities. We can return the new data because # coordinator will be recreated. - if self.data and ( - set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS]) - or set(new_data[DATA_KEY_MOUNTS]) - set(self.data[DATA_KEY_MOUNTS]) - ): + if self.data and (set(new_data.mounts) - set(self.data.mounts)): self.hass.async_create_task( self.hass.config_entries.async_reload(self.entry_id) ) - return {} return new_data - async def get_changelog(self, addon_slug: str) -> str | None: - """Get the changelog for an add-on.""" - try: - return await self.supervisor_client.store.addon_changelog(addon_slug) - except SupervisorNotFoundError: - return None - - async def force_data_refresh(self, first_update: bool) -> None: - """Force update of the addon info.""" - container_updates = self._container_updates - - data = self.hass.data - client = self.supervisor_client - - updates: dict[str, Awaitable[ResponseData]] = { - DATA_INFO: client.info(), - DATA_CORE_INFO: client.homeassistant.info(), - DATA_SUPERVISOR_INFO: client.supervisor.info(), - DATA_OS_INFO: client.os.info(), - DATA_STORE: client.store.info(), - } - if CONTAINER_STATS in container_updates[CORE_CONTAINER]: - updates[DATA_CORE_STATS] = client.homeassistant.stats() - if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: - updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats() - - # Pull off addons.list results for further processing before caching - addons_list, *results = await asyncio.gather( - client.addons.list(), *updates.values() - ) - for key, result in zip(updates, cast(list[ResponseData], results), strict=True): - data[key] = result.to_dict() - - installed_addons = cast(list[InstalledAddon], addons_list) - data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in installed_addons] - - # Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility - # Can drop this after removal period - data[DATA_SUPERVISOR_INFO].update( - { - "repositories": data[DATA_STORE][ATTR_REPOSITORIES], - "addons": [addon.to_dict() for addon in installed_addons], - } - ) - - all_addons = {addon.slug for addon in installed_addons} - started_addons = { - addon.slug - for addon in installed_addons - if addon.state in {AddonState.STARTED, AddonState.STARTUP} - } - - # - # Update addon info if its the first update or - # there is at least one entity that needs the data. - # - # When entities are added they call async_enable_container_updates - # to enable updates for the endpoints they need via - # async_added_to_hass. This ensures that we only update - # the data for the endpoints that are needed to avoid unnecessary - # API calls since otherwise we would fetch stats for all containers - # and throw them away. - # - for data_key, update_func, enabled_key, wanted_addons, needs_first_update in ( - ( - DATA_ADDONS_STATS, - self._update_addon_stats, - CONTAINER_STATS, - started_addons, - False, - ), - ( - DATA_ADDONS_INFO, - self._update_addon_info, - CONTAINER_INFO, - all_addons, - True, - ), - ): - container_data: dict[str, Any] = data.setdefault(data_key, {}) - - # Clean up cache - for slug in container_data.keys() - wanted_addons: - del container_data[slug] - - # Update cache from API - container_data.update( - dict( - await asyncio.gather( - *[ - update_func(slug) - for slug in wanted_addons - if (first_update and needs_first_update) - or enabled_key in container_updates[slug] - ] - ) - ) - ) - - # Refresh jobs data - await self.jobs.refresh_data(first_update) - - async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: - """Update single addon stats.""" - try: - stats = await self.supervisor_client.addons.addon_stats(slug) - except SupervisorError as err: - _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) - return (slug, None) - return (slug, stats.to_dict()) - - async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: - """Return the info for an addon.""" - try: - info = await self.supervisor_client.addons.addon_info(slug) - except SupervisorError as err: - _LOGGER.warning("Could not fetch info for %s: %s", slug, err) - return (slug, None) - # Translate to legacy hassio names for compatibility - info_dict = info.to_dict() - info_dict["hassio_api"] = info_dict.pop("supervisor_api") - info_dict["hassio_role"] = info_dict.pop("supervisor_role") - return (slug, info_dict) - - @callback - def async_enable_container_updates( - self, slug: str, entity_id: str, types: set[str] - ) -> CALLBACK_TYPE: - """Enable updates for an add-on.""" - enabled_updates = self._container_updates[slug] - for key in types: - enabled_updates[key].add(entity_id) - - @callback - def _remove() -> None: - for key in types: - enabled_updates[key].remove(entity_id) - - return _remove - async def _async_refresh( self, log_failures: bool = True, @@ -616,14 +924,16 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Refresh data.""" if not scheduled and not raise_on_auth_failed: - # Force refreshing updates for non-scheduled updates + # Force reloading updates of main components for + # non-scheduled updates. + # # If `raise_on_auth_failed` is set, it means this is # the first refresh and we do not want to delay # startup or cause a timeout so we only refresh the # updates if this is not a scheduled refresh and # we are not doing the first refresh. try: - await self.supervisor_client.refresh_updates() + await self.supervisor_client.reload_updates() except SupervisorError as err: _LOGGER.warning("Error on Supervisor API: %s", err) @@ -631,19 +941,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) - async def force_addon_info_data_refresh(self, addon_slug: str) -> None: - """Force refresh of addon info data for a specific addon.""" - try: - slug, info = await self._update_addon_info(addon_slug) - if info is not None and DATA_KEY_ADDONS in self.data: - if slug in self.data[DATA_KEY_ADDONS]: - data = deepcopy(self.data) - data[DATA_KEY_ADDONS][slug].update(info) - self.async_set_updated_data(data) - except SupervisorError as err: - _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) - - @callback - def unload(self) -> None: - """Clean up when config entry unloaded.""" + async def async_shutdown(self) -> None: + """Shut down and clean up when config entry unloaded.""" + await super().async_shutdown() + self._dispatcher_disconnect() self.jobs.unload() diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index 9002310bfcc..a3166d15888 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Supervisor.""" -from __future__ import annotations - from typing import Any from attr import asdict @@ -11,8 +9,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import ADDONS_COORDINATOR -from .coordinator import HassioDataUpdateCoordinator +from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR +from .coordinator import ( + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, +) async def async_get_config_entry_diagnostics( @@ -20,7 +22,9 @@ async def async_get_config_entry_diagnostics( config_entry: ConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR] + coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] + addons_coordinator: HassioAddOnDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR] + stats_coordinator: HassioStatsDataUpdateCoordinator = hass.data[STATS_COORDINATOR] device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) @@ -52,6 +56,8 @@ async def async_get_config_entry_diagnostics( devices.append({"device": asdict(device), "entities": entities}) return { - "coordinator_data": coordinator.data, + "coordinator_data": coordinator.data.to_dict(), + "addons_coordinator_data": addons_coordinator.data.to_dict(), + "stats_coordinator_data": stats_coordinator.data.to_dict(), "devices": devices, } diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 1973984d878..a7dbe5b92ff 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -1,7 +1,5 @@ """Implement the services discovery feature from Hass.io for Add-ons.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -13,7 +11,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ATTR_SERVICE, EVENT_HOMEASSISTANT_START from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import discovery_flow @@ -82,6 +80,7 @@ class HassIODiscovery(HomeAssistantView): self.hass = hass self._supervisor_client = get_supervisor_client(hass) + @require_admin async def post(self, request: web.Request, uuid: str) -> web.Response: """Handle new discovery requests.""" # Fetch discovery data and prevent injections @@ -94,6 +93,7 @@ class HassIODiscovery(HomeAssistantView): await self.async_process_new(data) return web.Response() + @require_admin async def delete(self, request: web.Request, uuid: str) -> web.Response: """Handle remove discovery requests.""" data: dict[str, Any] = await request.json() diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 44ae5a1db64..616862ed65e 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -1,81 +1,132 @@ """Base for Hass.io entities.""" -from __future__ import annotations +from collections.abc import Callable -from typing import Any - -from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse +from aiohasupervisor.models import CIFSMountResponse, HostInfo, NFSMountResponse, OSInfo +from aiohasupervisor.models.base import ContainerStats from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_SLUG, - CONTAINER_STATS, - CORE_CONTAINER, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_MOUNTS, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, - DOMAIN, - KEY_TO_UPDATE_TYPES, - SUPERVISOR_CONTAINER, +from .const import CONTAINER_STATS, DOMAIN +from .coordinator import ( + AddonData, + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsData, + HassioStatsDataUpdateCoordinator, ) -from .coordinator import HassioDataUpdateCoordinator -class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]): + """Base entity for container stats (CPU, memory).""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HassioStatsDataUpdateCoordinator, + entity_description: EntityDescription, + *, + container_id: str, + stats_fn: Callable[[HassioStatsData], ContainerStats | None], + device_id: str, + unique_id_prefix: str, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._container_id = container_id + self._stats_fn = stats_fn + self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) + + @property + def _stats(self) -> ContainerStats | None: + """Return the stats object for this entity's container.""" + return self._stats_fn(self.coordinator.data) + + @property + def stats(self) -> ContainerStats: + """Return the stats object, asserting it is available.""" + assert self._stats is not None + return self._stats + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._stats is not None + + async def async_added_to_hass(self) -> None: + """Subscribe to stats updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_enable_container_updates( + self._container_id, self.entity_id, {CONTAINER_STATS} + ) + ) + # Stats are only fetched for containers with subscribed entities. + # The first coordinator refresh (before entities exist) has no + # subscribers, so no stats are fetched. Schedule a debounced + # refresh so that all stats entities registering during platform + # setup are batched into a single API call. + await self.coordinator.async_request_refresh() + + +class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]): """Base entity for a Hass.io add-on.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioAddOnDataUpdateCoordinator, entity_description: EntityDescription, - addon: dict[str, Any], + addon: AddonData, ) -> None: """Initialize base entity.""" super().__init__(coordinator) self.entity_description = entity_description - self._addon_slug = addon[ATTR_SLUG] - self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}" - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])}) + self._addon_slug = addon.addon.slug + self._attr_unique_id = f"{addon.addon.slug}_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon.addon.slug)}) + + @property + def addon_slug(self) -> str: + """Return the add-on slug.""" + return self._addon_slug + + @property + def addon_data(self) -> AddonData: + """Return the add-on data, asserting it is available.""" + data = self.coordinator.data + assert self._addon_slug in data.addons + return data.addons[self._addon_slug] @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_ADDONS in self.coordinator.data - and self.entity_description.key - in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - ) + return super().available and self._addon_slug in self.coordinator.data.addons async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" + """Subscribe to addon info updates.""" await super().async_added_to_hass() - update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] self.async_on_remove( - self.coordinator.async_enable_container_updates( - self._addon_slug, self.entity_id, update_types + self.coordinator.async_enable_addon_info_updates( + self._addon_slug, self.entity_id ) ) - if CONTAINER_STATS in update_types: - await self.coordinator.async_request_refresh() -class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Hass.io OS.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioMainDataUpdateCoordinator, entity_description: EntityDescription, ) -> None: """Initialize base entity.""" @@ -87,21 +138,23 @@ class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_OS in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_OS] - ) + return super().available and self.coordinator.data.os is not None + + @property + def os(self) -> OSInfo: + """Return the OS info object, asserting it is available.""" + assert self.coordinator.data.os is not None + return self.coordinator.data.os -class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Hass.io host.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioMainDataUpdateCoordinator, entity_description: EntityDescription, ) -> None: """Initialize base entity.""" @@ -111,23 +164,20 @@ class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")}) @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_HOST in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST] - ) + def host(self) -> HostInfo: + """Return the host info, asserting it is available.""" + assert self.coordinator.data.host is not None + return self.coordinator.data.host -class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Supervisor.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioMainDataUpdateCoordinator, entity_description: EntityDescription, ) -> None: """Initialize base entity.""" @@ -136,37 +186,15 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")}) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_SUPERVISOR in self.coordinator.data - and self.entity_description.key - in self.coordinator.data[DATA_KEY_SUPERVISOR] - ) - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - await super().async_added_to_hass() - update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] - self.async_on_remove( - self.coordinator.async_enable_container_updates( - SUPERVISOR_CONTAINER, self.entity_id, update_types - ) - ) - if CONTAINER_STATS in update_types: - await self.coordinator.async_request_refresh() - - -class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Core.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioMainDataUpdateCoordinator, entity_description: EntityDescription, ) -> None: """Initialize base entity.""" @@ -175,36 +203,15 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): self._attr_unique_id = f"home_assistant_core_{entity_description.key}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")}) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_CORE in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE] - ) - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - await super().async_added_to_hass() - update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] - self.async_on_remove( - self.coordinator.async_enable_container_updates( - CORE_CONTAINER, self.entity_id, update_types - ) - ) - if CONTAINER_STATS in update_types: - await self.coordinator.async_request_refresh() - - -class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): +class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Mount.""" _attr_has_entity_name = True def __init__( self, - coordinator: HassioDataUpdateCoordinator, + coordinator: HassioMainDataUpdateCoordinator, entity_description: EntityDescription, mount: CIFSMountResponse | NFSMountResponse, ) -> None: @@ -219,10 +226,12 @@ class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): ) self._mount = mount + @property + def mount_name(self) -> str: + """Return the mount name.""" + return self._mount.name + @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS] - ) + return super().available and self.mount_name in self.coordinator.data.mounts diff --git a/homeassistant/components/hassio/exceptions.py b/homeassistant/components/hassio/exceptions.py new file mode 100644 index 00000000000..8a76839be90 --- /dev/null +++ b/homeassistant/components/hassio/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for the Hassio integration.""" + +from homeassistant.exceptions import HomeAssistantError + + +class HassioNotReadyError(HomeAssistantError): + """Raised when Hassio data is not yet available.""" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 63ce5ee0b9b..1c76cc62c62 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -1,7 +1,5 @@ """Handler for Hass.io.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index d0304e3f34d..f057744128c 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,7 +1,5 @@ """HTTP Support for Hass.io.""" -from __future__ import annotations - from http import HTTPStatus import logging import os @@ -14,7 +12,6 @@ from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( AUTHORIZATION, - CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, @@ -25,11 +22,9 @@ from aiohttp.web_exceptions import HTTPBadGateway from homeassistant.components.http import ( KEY_AUTHENTICATED, - KEY_HASS, KEY_HASS_USER, HomeAssistantView, ) -from homeassistant.components.onboarding import async_is_onboarded from .const import X_HASS_SOURCE @@ -54,16 +49,7 @@ NO_TIMEOUT = re.compile( r")$" ) -# fmt: off -# Onboarding can upload backups and restore it -PATHS_NOT_ONBOARDED = re.compile( - r"^(?:" - r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?" - r"|backups/new/upload" - r")$" -) - -# Authenticated users manage backups + download logs, changelog and documentation +# Admin users manage backups + download logs, changelog and documentation PATHS_ADMIN = re.compile( r"^(?:" r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" @@ -81,20 +67,13 @@ PATHS_ADMIN = re.compile( r")$" ) -# Unauthenticated requests come in for Supervisor panel + add-on images +# Unauthenticated requests come in for add-on images PATHS_NO_AUTH = re.compile( r"^(?:" - r"|app/.*" r"|(store/)?addons/[^/]+/(logo|icon)" r")$" ) -NO_STORE = re.compile( - r"^(?:" - r"|app/entrypoint.js" - r")$" -) - # Follow logs should not be compressed, to be able to get streamed by frontend NO_COMPRESS = re.compile( r"^(?:" @@ -150,27 +129,19 @@ class HassIOView(HomeAssistantView): """Return a client request with proxy origin for Hass.io supervisor. Use cases: - - Onboarding allows restoring backups - Load Supervisor panel and add-on logo unauthenticated - - User upload/restore backups + - Admin users upload/restore backups and access logs """ # No bullshit if path != unquote(path): return web.Response(status=HTTPStatus.BAD_REQUEST) - hass = request.app[KEY_HASS] is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin authorized = is_admin if is_admin: allowed_paths = PATHS_ADMIN - elif not async_is_onboarded(hass): - allowed_paths = PATHS_NOT_ONBOARDED - - # During onboarding we need the user to manage backups - authorized = True - else: # Either unauthenticated or not an admin allowed_paths = PATHS_NO_AUTH @@ -218,7 +189,7 @@ class HassIOView(HomeAssistantView): # Stream response response = web.StreamResponse( - status=client.status, headers=_response_header(client, path) + status=client.status, headers=_response_header(client) ) response.content_type = client.content_type @@ -243,16 +214,13 @@ class HassIOView(HomeAssistantView): post = _handle -def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: """Create response header.""" - headers = { + return { name: value for name, value in response.headers.items() if name not in RESPONSE_HEADERS_FILTER } - if NO_STORE.match(path): - headers[CACHE_CONTROL] = "no-store, max-age=0" - return headers def _get_timeout(path: str) -> ClientTimeout: diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 1df19226d5e..d2ca7316fa8 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -1,7 +1,5 @@ """Hass.io Add-on ingress service.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from functools import lru_cache @@ -22,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.async_ import create_eager_task -from .const import X_HASS_SOURCE, X_INGRESS_PATH +from .const import DATA_HASSIO_HOST, X_HASS_SOURCE, X_INGRESS_PATH from .http import should_compress _LOGGER = logging.getLogger(__name__) @@ -52,8 +50,9 @@ DISABLED_TIMEOUT = ClientTimeout(total=None) @callback -def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: - """Auth setup.""" +def async_setup_ingress_view(hass: HomeAssistant) -> None: + """Set up the Hass.io ingress HTTP view.""" + host = hass.data[DATA_HASSIO_HOST] websession = async_get_clientsession(hass) hassio_ingress = HassIOIngress(host, websession) @@ -198,7 +197,8 @@ class HassIOIngress(HomeAssistantView): # otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk # This also avoids setting content_type for empty responses. if must_be_empty_body(request.method, result.status): - # If upstream contains content-type, preserve it (e.g. for HEAD requests) + # If upstream contains content-type, preserve it + # (e.g. for HEAD requests) # Note: This still is omitting content-length. We can't simply forward # the upstream length since the proxy might change the body length # (e.g. due to compression). diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 3d8a9aed6cb..b8d14947c0c 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,8 +1,7 @@ """Supervisor events monitor.""" -from __future__ import annotations - import asyncio +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime import logging @@ -28,7 +27,6 @@ from homeassistant.helpers.issue_registry import ( ) from .const import ( - ADDONS_COORDINATOR, ATTR_DATA, ATTR_HEALTHY, ATTR_SLUG, @@ -54,6 +52,7 @@ from .const import ( ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_SYSTEM_FREE_SPACE, ISSUE_MOUNT_MOUNT_FAILED, + MAIN_COORDINATOR, PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_ADDON_URL, PLACEHOLDER_KEY_FREE_SPACE, @@ -62,7 +61,7 @@ from .const import ( STARTUP_COMPLETE, UPDATE_KEY_SUPERVISOR, ) -from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info +from .coordinator import HassioMainDataUpdateCoordinator, get_addons_list, get_host_info from .handler import get_supervisor_client ISSUE_KEY_UNHEALTHY = "unhealthy" @@ -182,6 +181,8 @@ class SupervisorIssues: self._unhealthy_reasons: set[str] = set() self._issues: dict[UUID, Issue] = {} self._supervisor_client = get_supervisor_client(hass) + self._disconnect: Callable[[], None] | None = None + self._cancel_update_retry: Callable[[], None] | None = None @property def unhealthy_reasons(self) -> set[str]: @@ -253,7 +254,10 @@ class SupervisorIssues: return set(self._issues.values()) def add_issue(self, issue: Issue) -> None: - """Add or update an issue in the list. Create or update a repair if necessary.""" + """Add or update an issue in the list. + + Create or update a repair if necessary. + """ if issue.key in ISSUE_KEYS_FOR_REPAIRS: if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() @@ -352,24 +356,41 @@ class SupervisorIssues: async def setup(self) -> None: """Create supervisor events listener.""" - await self._update() + await self.async_update() - async_dispatcher_connect( + self._disconnect = async_dispatcher_connect( self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) - async def _update(self, _: datetime | None = None) -> None: + def unload(self) -> None: + """Remove supervisor events listener.""" + if self._disconnect is not None: + self._disconnect() + self._disconnect = None + if self._cancel_update_retry is not None: + self._cancel_update_retry() + self._cancel_update_retry = None + + async def async_update(self) -> None: """Update issues from Supervisor resolution center.""" + if self._cancel_update_retry: + self._cancel_update_retry() + self._cancel_update_retry = None + await self._update() + + async def _update(self, _: datetime | None = None) -> None: + """Update issues from Supervisor resolution center with retry on failure.""" try: data = await self._supervisor_client.resolution.info() except SupervisorError as err: _LOGGER.error("Failed to update supervisor issues: %r", err) - async_call_later( + self._cancel_update_retry = async_call_later( self._hass, REQUEST_REFRESH_DELAY, HassJob(self._update, cancel_on_shutdown=True), ) return + self._cancel_update_retry = None self.unhealthy_reasons = set(data.unhealthy) self.unsupported_reasons = set(data.unsupported) @@ -393,7 +414,7 @@ class SupervisorIssues: and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE ): - self._hass.async_create_task(self._update()) + self._hass.async_create_task(self.async_update()) elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: self.unhealthy_reasons = ( @@ -417,8 +438,8 @@ class SupervisorIssues: def _async_coordinator_refresh(self) -> None: """Refresh coordinator to update latest data in entities.""" - coordinator: HassioDataUpdateCoordinator | None - if coordinator := self._hass.data.get(ADDONS_COORDINATOR): + coordinator: HassioMainDataUpdateCoordinator | None + if coordinator := self._hass.data.get(MAIN_COORDINATOR): coordinator.config_entry.async_create_task( self._hass, coordinator.async_refresh() ) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index cdb7764ab86..e53b8b1718f 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.4.3"], + "requirements": ["aiohasupervisor==0.5.0"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 11dbb939749..d693b6db93d 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,7 +1,5 @@ """Repairs implementation for supervisor integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -10,10 +8,13 @@ from aiohasupervisor import SupervisorError from aiohasupervisor.models import ContextType import voluptuous as vol -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult from . import get_addons_list from .const import ( @@ -24,6 +25,7 @@ from .const import ( ISSUE_KEY_ADDON_DEPRECATED_ARCH, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_ADDON_DOCUMENTATION, @@ -79,7 +81,7 @@ class SupervisorIssueRepairFlow(RepairsFlow): return placeholders or None - def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: + def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult: """Return form for suggestion.""" return self.async_show_form( step_id=suggestion.key, @@ -88,7 +90,7 @@ class SupervisorIssueRepairFlow(RepairsFlow): last_step=True, ) - async def async_step_init(self, _: None = None) -> FlowResult: + async def async_step_init(self, _: None = None) -> RepairsFlowResult: """Handle the first step of a fix flow.""" # Out of sync with supervisor, issue is resolved or not fixable. Remove it if not self.issue or not self.issue.suggestions: @@ -110,7 +112,7 @@ class SupervisorIssueRepairFlow(RepairsFlow): # Always show a form for one suggestion to explain to user what's happening return self._async_form_for_suggestion(self.issue.suggestions[0]) - async def async_step_fix_menu(self, _: None = None) -> FlowResult: + async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult: """Show the fix menu.""" assert self.issue @@ -122,8 +124,11 @@ class SupervisorIssueRepairFlow(RepairsFlow): async def _async_step_apply_suggestion( self, suggestion: Suggestion, confirmed: bool = False - ) -> FlowResult: - """Handle applying a suggestion as a flow step. Optionally request confirmation.""" + ) -> RepairsFlowResult: + """Handle applying a suggestion as a flow step. + + Optionally request confirmation. + """ if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: return self._async_form_for_suggestion(suggestion) @@ -139,13 +144,13 @@ class SupervisorIssueRepairFlow(RepairsFlow): suggestion: Suggestion, ) -> Callable[ [SupervisorIssueRepairFlow, dict[str, str] | None], - Coroutine[Any, Any, FlowResult], + Coroutine[Any, Any, RepairsFlowResult], ]: """Generate a step handler for a suggestion.""" async def _async_step( self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle a flow step for a suggestion.""" return await self._async_step_apply_suggestion( suggestion, confirmed=user_input is not None @@ -229,6 +234,8 @@ async def async_create_fix_flow( data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" + if issue_id == ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER: + return ConfirmRepairFlow() supervisor_issues = get_issues_info(hass) issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 9b62faaabcf..8acc4880388 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -1,6 +1,9 @@ """Sensor platform for Hass.io addons.""" -from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + +from aiohasupervisor.models.base import ContainerStats from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,93 +20,138 @@ from .const import ( ADDONS_COORDINATOR, ATTR_CPU_PERCENT, ATTR_MEMORY_PERCENT, - ATTR_VERSION, - ATTR_VERSION_LATEST, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, + CORE_CONTAINER, + MAIN_COORDINATOR, + STATS_COORDINATOR, + SUPERVISOR_CONTAINER, ) +from .coordinator import HassioStatsData from .entity import ( HassioAddonEntity, - HassioCoreEntity, HassioHostEntity, HassioOSEntity, - HassioSupervisorEntity, + HassioStatsEntity, ) -COMMON_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class HassioAddonSensorEntityDescription(SensorEntityDescription): + """Hass.io add-on sensor entity description.""" + + value_fn: Callable[[HassioAddonSensor], str | None] + + +@dataclass(frozen=True, kw_only=True) +class HassioStatsSensorEntityDescription(SensorEntityDescription): + """Hass.io stats sensor entity description.""" + + value_fn: Callable[[HassioStatsSensor], float] + + +@dataclass(frozen=True, kw_only=True) +class HassioOSSensorEntityDescription(SensorEntityDescription): + """Hass.io OS sensor entity description.""" + + value_fn: Callable[[HassioOSSensor], str | None] + + +@dataclass(frozen=True, kw_only=True) +class HassioHostSensorEntityDescription(SensorEntityDescription): + """Hass.io host sensor entity description.""" + + value_fn: Callable[[HostSensor], str | float | None] + + +ADDON_ENTITY_DESCRIPTIONS = ( + HassioAddonSensorEntityDescription( entity_registry_enabled_default=False, - key=ATTR_VERSION, + key="version", translation_key="version", + value_fn=lambda entity: entity.addon_data.addon.version, ), - SensorEntityDescription( + HassioAddonSensorEntityDescription( entity_registry_enabled_default=False, - key=ATTR_VERSION_LATEST, + key="version_latest", translation_key="version_latest", + value_fn=lambda entity: entity.addon_data.addon.version_latest, ), ) STATS_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + HassioStatsSensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, translation_key="cpu_percent", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.stats.cpu_percent, ), - SensorEntityDescription( + HassioStatsSensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_MEMORY_PERCENT, translation_key="memory_percent", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.stats.memory_percent, ), ) -ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + STATS_ENTITY_DESCRIPTIONS -CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS -OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS -SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS +OS_ENTITY_DESCRIPTIONS = ( + HassioOSSensorEntityDescription( + entity_registry_enabled_default=False, + key="version", + translation_key="version", + value_fn=lambda entity: entity.os.version, + ), + HassioOSSensorEntityDescription( + entity_registry_enabled_default=False, + key="version_latest", + translation_key="version_latest", + value_fn=lambda entity: entity.os.version_latest, + ), +) HOST_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="agent_version", translation_key="agent_version", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.agent_version, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="apparmor_version", translation_key="apparmor_version", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.apparmor_version, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_total", translation_key="disk_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_total, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_used", translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_used, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_free", translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_free, ), ) @@ -114,36 +162,75 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Sensor set up for Hass.io config entry.""" - coordinator = hass.data[ADDONS_COORDINATOR] + addons_coordinator = hass.data[ADDONS_COORDINATOR] + coordinator = hass.data[MAIN_COORDINATOR] + stats_coordinator = hass.data[STATS_COORDINATOR] - entities: list[ - HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor - ] = [ + entities: list[SensorEntity] = [] + + # Add-on non-stats sensors (version, version_latest) + entities.extend( HassioAddonSensor( addon=addon, - coordinator=coordinator, + coordinator=addons_coordinator, entity_description=entity_description, ) - for addon in coordinator.data[DATA_KEY_ADDONS].values() + for addon in addons_coordinator.data.addons.values() for entity_description in ADDON_ENTITY_DESCRIPTIONS - ] - - entities.extend( - CoreSensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in CORE_ENTITY_DESCRIPTIONS ) + # Add-on stats sensors (cpu_percent, memory_percent) + def stats_fn_factory( + addon_slug: str, + ) -> Callable[[HassioStatsData], ContainerStats | None]: + """Return a stats_fn for the given add-on slug.""" + + def stats_fn(data: HassioStatsData) -> ContainerStats | None: + """Return the stats for the given add-on.""" + return data.addons.get(addon_slug) + + return stats_fn + entities.extend( - SupervisorSensor( - coordinator=coordinator, + HassioStatsSensor( + coordinator=stats_coordinator, entity_description=entity_description, + container_id=addon.addon.slug, + stats_fn=stats_fn_factory(addon.addon.slug), + device_id=addon.addon.slug, + unique_id_prefix=addon.addon.slug, ) - for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS + for addon in addons_coordinator.data.addons.values() + for entity_description in STATS_ENTITY_DESCRIPTIONS ) + # Core stats sensors + entities.extend( + HassioStatsSensor( + coordinator=stats_coordinator, + entity_description=entity_description, + container_id=CORE_CONTAINER, + stats_fn=lambda data: data.core, + device_id="core", + unique_id_prefix="home_assistant_core", + ) + for entity_description in STATS_ENTITY_DESCRIPTIONS + ) + + # Supervisor stats sensors + entities.extend( + HassioStatsSensor( + coordinator=stats_coordinator, + entity_description=entity_description, + container_id=SUPERVISOR_CONTAINER, + stats_fn=lambda data: data.supervisor, + device_id="supervisor", + unique_id_prefix="home_assistant_supervisor", + ) + for entity_description in STATS_ENTITY_DESCRIPTIONS + ) + + # Host sensors entities.extend( HostSensor( coordinator=coordinator, @@ -152,6 +239,7 @@ async def async_setup_entry( for entity_description in HOST_ENTITY_DESCRIPTIONS ) + # OS sensors if coordinator.is_hass_os: entities.extend( HassioOSSensor( @@ -167,45 +255,42 @@ async def async_setup_entry( class HassioAddonSensor(HassioAddonEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" + entity_description: HassioAddonSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ - self.entity_description.key - ] + return self.entity_description.value_fn(self) + + +class HassioStatsSensor(HassioStatsEntity, SensorEntity): + """Sensor to track container stats.""" + + entity_description: HassioStatsSensorEntityDescription + + @property + def native_value(self) -> float: + """Return native value of entity.""" + return self.entity_description.value_fn(self) class HassioOSSensor(HassioOSEntity, SensorEntity): - """Sensor to track a Hass.io add-on attribute.""" + """Sensor to track a Hass.io OS attribute.""" + + entity_description: HassioOSSensorEntityDescription @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] - - -class CoreSensor(HassioCoreEntity, SensorEntity): - """Sensor to track a core attribute.""" - - @property - def native_value(self) -> str: - """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_CORE][self.entity_description.key] - - -class SupervisorSensor(HassioSupervisorEntity, SensorEntity): - """Sensor to track a supervisor attribute.""" - - @property - def native_value(self) -> str: - """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] + return self.entity_description.value_fn(self) class HostSensor(HassioHostEntity, SensorEntity): """Sensor to track a host attribute.""" + entity_description: HassioHostSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> str | float | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key] + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py index bd9076141d9..c86e3006ff2 100644 --- a/homeassistant/components/hassio/services.py +++ b/homeassistant/components/hassio/services.py @@ -7,6 +7,7 @@ from typing import Any from aiohasupervisor import SupervisorClient, SupervisorError from aiohasupervisor.models import ( + Folder, FullBackupOptions, FullRestoreOptions, PartialBackupOptions, @@ -14,25 +15,25 @@ from aiohasupervisor.models import ( ) import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, - async_get_hass_or_none, callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + issue_registry as ir, selector, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.dt import now from .const import ( - ADDONS_COORDINATOR, ATTR_ADDON, ATTR_ADDONS, ATTR_APP, @@ -42,13 +43,15 @@ from .const import ( ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, ATTR_INPUT, - ATTR_LOCATION, ATTR_PASSWORD, ATTR_SLUG, DOMAIN, + ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, + MAIN_COORDINATOR, SupervisorEntityModel, ) -from .coordinator import HassioDataUpdateCoordinator, get_addons_info +from .coordinator import HassioMainDataUpdateCoordinator +from .handler import get_supervisor_client SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_STOP = "addon_stop" @@ -69,26 +72,53 @@ SERVICE_MOUNT_RELOAD = "mount_reload" VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) +# Legacy alias used by the Supervisor API for the homeassistant flag, kept +# for backwards compatibility with existing automations. +LEGACY_FOLDER_HOMEASSISTANT = "homeassistant" -def valid_addon(value: Any) -> str: - """Validate value is a valid addon slug.""" - value = VALID_ADDON_SLUG(value) - hass = async_get_hass_or_none() - if hass and (addons := get_addons_info(hass)) is not None and value not in addons: - raise vol.Invalid("Not a valid app slug") - return value +def _normalize_partial_options_data( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: + """Map legacy aliases used by both partial backup and partial restore handlers.""" + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + if ATTR_FOLDERS in data: + folders: set[Any] = set(data[ATTR_FOLDERS]) + if LEGACY_FOLDER_HOMEASSISTANT in folders: + folders.discard(LEGACY_FOLDER_HOMEASSISTANT) + if data.get(ATTR_HOMEASSISTANT) is False: + raise ServiceValidationError( + f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy " + f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}" + ) + data[ATTR_HOMEASSISTANT] = True + ir.async_create_issue( + hass, + DOMAIN, + ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, + breaks_in_ha_version="2026.12.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER, + ) + if folders: + data[ATTR_FOLDERS] = folders + else: + data.pop(ATTR_FOLDERS) + return data SCHEMA_NO_DATA = vol.Schema({}) -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): VALID_ADDON_SLUG}) SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} ) -SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon}) +SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): VALID_ADDON_SLUG}) SCHEMA_APP_STDIN = SCHEMA_APP.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} @@ -112,7 +142,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All( - cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + cv.ensure_list, + [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], + vol.Unique(), + vol.Coerce(set), ), vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) @@ -135,7 +168,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All( - cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + cv.ensure_list, + [vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))], + vol.Unique(), + vol.Coerce(set), ), vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) @@ -162,10 +198,9 @@ SCHEMA_MOUNT_RELOAD = vol.Schema( @callback -def async_setup_services( - hass: HomeAssistant, supervisor_client: SupervisorClient -) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register the Supervisor services.""" + supervisor_client = get_supervisor_client(hass) async_register_app_services(hass, supervisor_client) async_register_host_services(hass, supervisor_client) async_register_backup_restore_services(hass, supervisor_client) @@ -196,8 +231,8 @@ def async_register_app_services( ) from err for service in simple_app_services: - hass.services.async_register( - DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP + async_register_admin_service( + hass, DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP ) async def async_app_stdin_service_handler(service: ServiceCall) -> None: @@ -220,7 +255,8 @@ def async_register_app_services( f"Failed to write stdin to app {app_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_APP_STDIN, async_app_stdin_service_handler, @@ -247,8 +283,12 @@ def async_register_app_services( ) from err for service in simple_addon_services: - hass.services.async_register( - DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON + async_register_admin_service( + hass, + DOMAIN, + service, + async_simple_addon_service_handler, + schema=SCHEMA_ADDON, ) async def async_addon_stdin_service_handler(service: ServiceCall) -> None: @@ -256,7 +296,8 @@ def async_register_app_services( addon_slug = service.data[ATTR_ADDON] data: dict | str = service.data[ATTR_INPUT] - # See explanation for why we make strings into json in async_app_stdin_service_handler + # See explanation for why we make strings into json + # in async_app_stdin_service_handler data = json.dumps(data) payload = data.encode(encoding="utf-8") @@ -267,7 +308,8 @@ def async_register_app_services( f"Failed to write stdin to app {addon_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_ADDON_STDIN, async_addon_stdin_service_handler, @@ -294,8 +336,12 @@ def async_register_host_services( raise HomeAssistantError(f"Failed to {action} the host: {err}") from err for service in simple_host_services: - hass.services.async_register( - DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA + async_register_admin_service( + hass, + DOMAIN, + service, + async_simple_host_service_handler, + schema=SCHEMA_NO_DATA, ) @@ -319,7 +365,8 @@ def async_register_backup_restore_services( return {"backup": backup.slug} - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_BACKUP_FULL, async_full_backup_service_handler, @@ -331,9 +378,7 @@ def async_register_backup_restore_services( service: ServiceCall, ) -> ServiceResponse: """Handler for create partial backup service. Returns the new backup's ID.""" - data = service.data.copy() - if ATTR_APPS in data: - data[ATTR_ADDONS] = data.pop(ATTR_APPS) + data = _normalize_partial_options_data(hass, service.data.copy()) options = PartialBackupOptions(**data) try: @@ -345,7 +390,8 @@ def async_register_backup_restore_services( return {"backup": backup.slug} - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_BACKUP_PARTIAL, async_partial_backup_service_handler, @@ -367,7 +413,8 @@ def async_register_backup_restore_services( f"Failed to full restore from backup {backup_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_RESTORE_FULL, async_full_restore_service_handler, @@ -378,8 +425,7 @@ def async_register_backup_restore_services( """Handler for partial restore service.""" data = service.data.copy() backup_slug = data.pop(ATTR_SLUG) - if ATTR_APPS in data: - data[ATTR_ADDONS] = data.pop(ATTR_APPS) + data = _normalize_partial_options_data(hass, data) options = PartialRestoreOptions(**data) try: @@ -389,7 +435,8 @@ def async_register_backup_restore_services( f"Failed to partial restore from backup {backup_slug}: {err}" ) from err - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_RESTORE_PARTIAL, async_partial_restore_service_handler, @@ -406,7 +453,7 @@ def async_register_network_storage_services( async def async_mount_reload(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" - coordinator: HassioDataUpdateCoordinator | None = None + coordinator: HassioMainDataUpdateCoordinator | None = None if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: raise ServiceValidationError( @@ -417,7 +464,7 @@ def async_register_network_storage_services( if ( device.name is None or device.model != SupervisorEntityModel.MOUNT - or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None + or (coordinator := hass.data.get(MAIN_COORDINATOR)) is None or coordinator.entry_id not in device.config_entries ): raise ServiceValidationError( @@ -434,6 +481,6 @@ def async_register_network_storage_services( translation_placeholders={"name": device.name, "error": str(error)}, ) from error - hass.services.async_register( - DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD + async_register_admin_service( + hass, DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD ) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index a0a36a7a4f0..533bf4d0a91 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -52,6 +52,12 @@ }, "mount_reload_unknown_device_id": { "message": "Device ID not found" + }, + "supervisor_not_connected": { + "message": "Not connected with the supervisor / system too busy" + }, + "supervisor_update_pending": { + "message": "Supervisor was out-of-date during onboarding. Update triggered, will retry when complete" } }, "issues": { @@ -203,6 +209,17 @@ }, "title": "Reboot required" }, + "legacy_homeassistant_folder": { + "fix_flow": { + "step": { + "confirm": { + "description": "An automation or script called the `hassio.backup_partial` or `hassio.restore_partial` action with `\"homeassistant\"` listed in `folders`. This is a legacy alias for the `homeassistant: true` option and will stop being accepted in a future release.\n\nUpdate the affected automations and scripts to set `homeassistant: true` and remove `\"homeassistant\"` from the `folders` list. When this is done, select **Submit** to mark this issue as resolved.", + "title": "Legacy \"homeassistant\" folder used in partial backup/restore" + } + } + }, + "title": "Legacy \"homeassistant\" folder used in partial backup/restore" + }, "unhealthy": { "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more.", "title": "Unhealthy system - {reason}" @@ -548,6 +565,7 @@ "info": { "agent_version": "Agent version", "board": "Board", + "disk_life_time": "Disk lifetime", "disk_total": "Disk total", "disk_used": "Disk used", "docker_version": "Docker version", diff --git a/homeassistant/components/hassio/switch.py b/homeassistant/components/hassio/switch.py index 4aa7813783a..9454b917e33 100644 --- a/homeassistant/components/hassio/switch.py +++ b/homeassistant/components/hassio/switch.py @@ -1,20 +1,18 @@ """Switch platform for Hass.io addons.""" -from __future__ import annotations - import logging from typing import Any from aiohasupervisor import SupervisorError +from aiohasupervisor.models import AddonState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS +from .const import ADDONS_COORDINATOR from .entity import HassioAddonEntity from .handler import get_supervisor_client @@ -22,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) ENTITY_DESCRIPTION = SwitchEntityDescription( - key=ATTR_STATE, + key="state", name=None, icon="mdi:puzzle", entity_registry_enabled_default=False, @@ -43,7 +41,7 @@ async def async_setup_entry( coordinator=coordinator, entity_description=ENTITY_DESCRIPTION, ) - for addon in coordinator.data[DATA_KEY_ADDONS].values() + for addon in coordinator.data.addons.values() ) @@ -51,19 +49,19 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity): """Switch for Hass.io add-ons.""" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the add-on is on.""" - addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - state = addon_data.get(self.entity_description.key) - return state == ATTR_STARTED + return ( + self.coordinator.data.addons[self._addon_slug].addon.state + == AddonState.STARTED + ) @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" if not self.available: return None - addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - if addon_data.get(ATTR_ICON): + if self.coordinator.data.addons[self._addon_slug].addon.icon: return f"/api/hassio/addons/{self._addon_slug}/icon" return None diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index ade621df933..4a1ecc834b0 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -1,7 +1,6 @@ """Provide info to system health.""" -from __future__ import annotations - +from collections.abc import Callable import os from typing import Any @@ -16,6 +15,7 @@ from .coordinator import ( get_os_info, get_supervisor_info, ) +from .exceptions import HassioNotReadyError SUPERVISOR_PING = "http://{ip_address}/supervisor/ping" OBSERVER_URL = "http://{ip_address}:4357" @@ -29,17 +29,30 @@ def async_register( register.async_register_info(system_health_info) +def _get_supervisor_data_if_available( + hass: HomeAssistant, get_info_dict: Callable[[HomeAssistant], dict[str, Any]] +) -> dict[str, Any]: + """Get data from supervisor if available.""" + try: + return get_info_dict(hass) + except HassioNotReadyError: + return {} + + async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" ip_address = os.environ["SUPERVISOR"] - info = get_info(hass) or {} - host_info = get_host_info(hass) or {} - supervisor_info = get_supervisor_info(hass) - network_info = get_network_info(hass) or {} - addons_list = get_addons_list(hass) or [] + info = _get_supervisor_data_if_available(hass, get_info) + host_info = _get_supervisor_data_if_available(hass, get_host_info) + supervisor_info = _get_supervisor_data_if_available(hass, get_supervisor_info) + network_info = _get_supervisor_data_if_available(hass, get_network_info) + try: + addons_list = get_addons_list(hass) + except HassioNotReadyError: + addons_list = [] healthy: bool | dict[str, str] - if supervisor_info is not None and supervisor_info.get("healthy"): + if supervisor_info and supervisor_info.get("healthy"): healthy = True else: healthy = { @@ -48,7 +61,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: } supported: bool | dict[str, str] - if supervisor_info is not None and supervisor_info.get("supported"): + if supervisor_info and supervisor_info.get("supported"): supported = True else: supported = { @@ -83,9 +96,12 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: } if info.get("hassos") is not None: - os_info = get_os_info(hass) or {} + os_info = get_os_info(hass) information["board"] = os_info.get("board") + if (disk_life_time := host_info.get("disk_life_time")) is not None: + information["disk_life_time"] = f"{disk_life_time:.0f} %" + # Not using aiohasupervisor for ping call below intentionally. Given system health # context, it seems preferable to do this check with minimal dependencies information["supervisor_api"] = system_health.async_check_can_reach_url( diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 5354f21e726..d9ed4f893a6 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -1,7 +1,5 @@ """Update platform for Supervisor.""" -from __future__ import annotations - import re from typing import Any @@ -15,21 +13,12 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ADDONS_COORDINATOR, - ATTR_AUTO_UPDATE, - ATTR_VERSION, - ATTR_VERSION_LATEST, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, -) +from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR +from .coordinator import AddonData from .entity import ( HassioAddonEntity, HassioCoreEntity, @@ -51,9 +40,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Supervisor update based on a config entry.""" - coordinator = hass.data[ADDONS_COORDINATOR] + coordinator = hass.data[MAIN_COORDINATOR] - entities = [ + entities: list[UpdateEntity] = [ SupervisorSupervisorUpdateEntity( coordinator=coordinator, entity_description=ENTITY_DESCRIPTION, @@ -64,15 +53,6 @@ async def async_setup_entry( ), ] - entities.extend( - SupervisorAddonUpdateEntity( - addon=addon, - coordinator=coordinator, - entity_description=ENTITY_DESCRIPTION, - ) - for addon in coordinator.data[DATA_KEY_ADDONS].values() - ) - if coordinator.is_hass_os: entities.append( SupervisorOSUpdateEntity( @@ -81,11 +61,32 @@ async def async_setup_entry( ) ) + addons_coordinator = hass.data[ADDONS_COORDINATOR] + entities.extend( + SupervisorAddonUpdateEntity( + addon=addon, + coordinator=addons_coordinator, + entity_description=ENTITY_DESCRIPTION, + ) + for addon in addons_coordinator.data.addons.values() + ) + async_add_entities(entities) class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): - """Update entity to handle updates for the Supervisor add-ons.""" + """Update entity to handle updates for the Supervisor add-ons. + + The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as + Supervisor finishes the container work, a few milliseconds before the + ``/store/addons//update`` HTTP call returns. If we clear + ``_attr_in_progress`` on that event while the coordinator data still + carries the pre-update version, the UI briefly flips back to + "Update available" before ``async_install`` can refresh. ``_update_ongoing`` + survives both the WS done event and the base ``UpdateEntity`` reset, so + the installing state remains until the coordinator confirms a new + ``installed_version``. + """ _attr_supported_features = ( UpdateEntityFeature.INSTALL @@ -93,38 +94,47 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.PROGRESS ) + _update_ongoing: bool = False + _version_before_update: str | None = None @property - def _addon_data(self) -> dict: + def _addon_data(self) -> AddonData: """Return the add-on data.""" - return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug] + return self.coordinator.data.addons[self._addon_slug] @property def auto_update(self) -> bool: """Return true if auto-update is enabled for the add-on.""" - return self._addon_data[ATTR_AUTO_UPDATE] + return self._addon_data.auto_update @property def title(self) -> str | None: """Return the title of the update.""" - return self._addon_data[ATTR_NAME] + return self._addon_data.addon.name @property def latest_version(self) -> str | None: """Latest version available for install.""" - return self._addon_data[ATTR_VERSION_LATEST] + return self._addon_data.addon.version_latest @property def installed_version(self) -> str | None: """Version installed and in use.""" - return self._addon_data[ATTR_VERSION] + return self._addon_data.addon.version + + @property + def in_progress(self) -> bool | None: + """Return combined progress from the update job and refresh phase.""" + if self._update_ongoing: + return True + return self._attr_in_progress @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" if not self.available: return None - if self._addon_data[ATTR_ICON]: + if self._addon_data.addon.icon: return f"/api/hassio/addons/{self._addon_slug}/icon" return None @@ -139,7 +149,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): return changelog regex_pattern = re.compile( - rf"^#* {re.escape(self.latest_version)}\n(?:^(?!#* {re.escape(self.installed_version)}).*\n)*", + rf"^#* {re.escape(self.latest_version)}\n" + rf"(?:^(?!#* {re.escape(self.installed_version)}).*\n)*", re.MULTILINE, ) match = regex_pattern.search(changelog) @@ -152,13 +163,34 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): **kwargs: Any, ) -> None: """Install an update.""" + self._version_before_update = self.installed_version + self._update_ongoing = True self._attr_in_progress = True self.async_write_ha_state() - await update_addon( - self.hass, self._addon_slug, backup, self.title, self.installed_version - ) + try: + await update_addon( + self.hass, self._addon_slug, backup, self.title, self.installed_version + ) + except HomeAssistantError: + self._update_ongoing = False + self._version_before_update = None + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + raise await self.coordinator.async_refresh() + @callback + def _handle_coordinator_update(self) -> None: + """Clear the ongoing flag once the installed version has changed.""" + if ( + self._update_ongoing + and self.installed_version != self._version_before_update + ): + self._update_ongoing = False + self._version_before_update = None + super()._handle_coordinator_update() + @callback def _update_job_changed(self, job: Job) -> None: """Process update for this entity's update job.""" @@ -195,14 +227,16 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): _attr_title = "Home Assistant Operating System" @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST] + assert self.coordinator.data.os is not None + return self.coordinator.data.os.version_latest @property - def installed_version(self) -> str: + def installed_version(self) -> str | None: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION] + assert self.coordinator.data.os is not None + return self.coordinator.data.os.version @property def entity_picture(self) -> str | None: @@ -213,7 +247,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" version = AwesomeVersion(self.latest_version) - if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN: + if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN: return "https://github.com/home-assistant/operating-system/commits/dev" return ( f"https://github.com/home-assistant/operating-system/releases/tag/{version}" @@ -227,31 +261,50 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): - """Update entity to handle updates for the Home Assistant Supervisor.""" + """Update entity to handle updates for the Home Assistant Supervisor. - _attr_supported_features = UpdateEntityFeature.INSTALL + The Supervisor update API blocks for the entire container download, then + Supervisor restarts itself. The base UpdateEntity always resets + ``_attr_in_progress`` after ``async_install`` returns, but at that point the + restart is still ongoing. ``_update_ongoing`` survives that reset so the UI + keeps showing the installing state until the coordinator refreshes with the + new version after Supervisor comes back. + """ + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) _attr_title = "Home Assistant Supervisor" + _update_ongoing: bool = False + _version_before_update: str | None = None @property - def latest_version(self) -> str: + def in_progress(self) -> bool | None: + """Return combined progress from the update job and restart phase.""" + if self._update_ongoing: + return True + return self._attr_in_progress + + @property + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST] + return self.coordinator.data.supervisor.version_latest @property def installed_version(self) -> str: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION] + return self.coordinator.data.supervisor.version @property def auto_update(self) -> bool: """Return true if auto-update is enabled for supervisor.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_AUTO_UPDATE] + return self.coordinator.data.supervisor.auto_update @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" version = AwesomeVersion(self.latest_version) - if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN: + if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN: return "https://github.com/home-assistant/supervisor/commits/main" return f"https://github.com/home-assistant/supervisor/releases/tag/{version}" @@ -264,13 +317,58 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._version_before_update = self.installed_version + self._update_ongoing = True + self._attr_in_progress = True + self.async_write_ha_state() try: await self.coordinator.supervisor_client.supervisor.update() except SupervisorError as err: + self._update_ongoing = False + self._version_before_update = None + self._attr_in_progress = False + self.async_write_ha_state() raise HomeAssistantError( f"Error updating Home Assistant Supervisor: {err}" ) from err + @callback + def _handle_coordinator_update(self) -> None: + """Clear the ongoing flag once the installed version has changed.""" + if ( + self._update_ongoing + and self.installed_version != self._version_before_update + ): + self._update_ongoing = False + self._version_before_update = None + super()._handle_coordinator_update() + + @callback + def _update_job_changed(self, job: Job) -> None: + """Process update for this entity's update job.""" + if job.done is False: + # Also covers updates not initiated via async_install (CLI, + # Supervisor self-update): capture the baseline so the installing + # state survives the Supervisor restart phase. + if not self._update_ongoing: + self._version_before_update = self.installed_version + self._update_ongoing = True + self._attr_in_progress = True + self._attr_update_percentage = job.progress + else: + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to progress updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.jobs.subscribe( + JobSubscription(self._update_job_changed, name="supervisor_update") + ) + ) + class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): """Update entity to handle updates for Home Assistant Core.""" @@ -284,14 +382,14 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): _attr_title = "Home Assistant Core" @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST] + return self.coordinator.data.core.version_latest @property - def installed_version(self) -> str: + def installed_version(self) -> str | None: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION] + return self.coordinator.data.core.version @property def entity_picture(self) -> str | None: @@ -304,7 +402,8 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): version = AwesomeVersion(self.latest_version) if version.dev: return "https://github.com/home-assistant/core/commits/dev" - return f"https://{'rc' if version.beta else 'www'}.home-assistant.io/latest-release-notes/" + subdomain = "rc" if version.beta else "www" + return f"https://{subdomain}.home-assistant.io/latest-release-notes/" async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index f44ee0700fc..5c0da77d2bd 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -1,7 +1,5 @@ """Update helpers for Supervisor.""" -from __future__ import annotations - from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( HomeAssistantUpdateOptions, diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 21b8dbf8e12..ed3034437e1 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -39,6 +39,7 @@ from .const import ( WS_TYPE_SUBSCRIBE, ) from .coordinator import get_addons_list +from .exceptions import HassioNotReadyError from .handler import HassioAPIError from .update_helper import update_addon, update_core @@ -47,15 +48,16 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` -# fmt: off +# Endpoints needed for ingress can't require admin because +# add-ons can set `panel_admin: false` +RE_ADDONS_INFO_ENDPOINT = r"/addons/[^/]+/info" +WS_ADDONS_INFO_ENDPOINT = re.compile(r"^" + RE_ADDONS_INFO_ENDPOINT + r"$") WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" - r"|/ingress/(session|validate_session)" - r"|/addons/[^/]+/info" + r"/ingress/(session|validate_session)" + f"|{RE_ADDONS_INFO_ENDPOINT}" r")$" ) -# fmt: on _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -92,6 +94,7 @@ def websocket_subscribe( @callback +@websocket_api.ws_require_user(only_supervisor=True) @websocket_api.websocket_command( { vol.Required(WS_TYPE): WS_TYPE_EVENT, @@ -131,8 +134,9 @@ async def websocket_supervisor_api( payload = msg.get(ATTR_DATA, {}) if command == "/ingress/session": - # Send user ID on session creation, so the supervisor can correlate session tokens with users - # for every request that is authenticated with the given ingress session token. + # Send user ID on session creation, so the supervisor can + # correlate session tokens with users for every request that + # is authenticated with the given ingress session token. payload[ATTR_SESSION_DATA_USER_ID] = connection.user.id try: @@ -150,7 +154,12 @@ async def websocket_supervisor_api( msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err) ) else: - connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {})) + data = result.get(ATTR_DATA, {}) + # Remove options from add-on info for non-admin users, as options can contain + # sensitive information and the frontend does not require it for ingress. + if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): + data.pop("options", None) + connection.send_result(msg[WS_ID], data) @websocket_api.require_admin @@ -168,7 +177,20 @@ async def websocket_update_addon( """Websocket handler to update an addon.""" addon_name: str | None = None addon_version: str | None = None - addons_list: list[dict[str, Any]] = get_addons_list(hass) or [] + try: + addons_list: list[dict[str, Any]] = get_addons_list(hass) + except HassioNotReadyError: + _LOGGER.error( + "Update command received for app %s but apps list is not available", + msg["addon"], + ) + connection.send_error( + msg[WS_ID], + code=websocket_api.ERR_UNKNOWN_ERROR, + message="Apps list is not available", + ) + return + for addon in addons_list: if addon[ATTR_SLUG] == msg["addon"]: addon_name = addon[ATTR_NAME] diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 0e8de64d7c6..fe629039bca 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -1,7 +1,5 @@ """Support for haveibeenpwned (email breaches) sensor.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 4d9bbeb9516..2e5b0f5c680 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -1,7 +1,5 @@ """Support for getting the disk temperature of a host.""" -from __future__ import annotations - from datetime import timedelta import logging import socket @@ -16,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( + ATTR_MODEL, CONF_DISKS, CONF_HOST, CONF_NAME, @@ -30,7 +29,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" -ATTR_MODEL = "model" DEFAULT_HOST = "localhost" DEFAULT_PORT = 7634 diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 3f948a4474f..140596319b3 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -1,7 +1,5 @@ """Support for HDMI CEC.""" -from __future__ import annotations - from functools import reduce import logging import multiprocessing diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py index cc10fd95531..4d16c2dbd46 100644 --- a/homeassistant/components/hdmi_cec/entity.py +++ b/homeassistant/components/hdmi_cec/entity.py @@ -1,9 +1,8 @@ """Support for HDMI CEC.""" -from __future__ import annotations - from typing import Any +from homeassistant.core import callback from homeassistant.helpers.entity import Entity from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE @@ -53,11 +52,16 @@ class CecEntity(Entity): elif self._device.osd_name is None: self._attr_name = f"{self._device.type_name} {self._logical_address}" else: - self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" + self._attr_name = ( + f"{self._device.type_name}" + f" {self._logical_address}" + f" ({self._device.osd_name})" + ) + @callback def _hdmi_cec_unavailable(self, callback_event): self._attr_available = False - self.schedule_update_ha_state(False) + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Register HDMI callbacks after initialization.""" diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 7ad06f0c45a..ac4ad314e33 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,12 +1,10 @@ """Support for HDMI CEC devices as media players.""" -from __future__ import annotations - import logging -from typing import Any from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand from pycec.const import ( + CMD_STANDBY, KEY_BACKWARD, KEY_FORWARD, KEY_MUTE_TOGGLE, @@ -31,7 +29,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, - MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,20 +42,20 @@ _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = MP_DOMAIN + ".{}" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Find and return HDMI devices as +switches.""" + """Find and return HDMI devices as media players.""" if discovery_info and ATTR_NEW in discovery_info: _LOGGER.debug("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data[DOMAIN][device] entities.append(CecPlayerEntity(hdmi_device, hdmi_device.logical_address)) - add_entities(entities, True) + async_add_entities(entities, True) class CecPlayerEntity(CecEntity, MediaPlayerEntity): @@ -79,78 +76,61 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): def send_playback(self, key): """Send playback status to CEC adapter.""" - self._device.async_send_command(CecCommand(key, dst=self._logical_address)) + self._device.send_command(CecCommand(key, dst=self._logical_address)) - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Mute volume.""" self.send_keypress(KEY_MUTE_TOGGLE) - def media_previous_track(self) -> None: + async def async_media_previous_track(self) -> None: """Go to previous track.""" self.send_keypress(KEY_BACKWARD) - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn device on.""" self._device.turn_on() self._attr_state = MediaPlayerState.ON + self.async_write_ha_state() - def clear_playlist(self) -> None: - """Clear players playlist.""" - raise NotImplementedError - - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn device off.""" - self._device.turn_off() + self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address)) self._attr_state = MediaPlayerState.OFF + self.async_write_ha_state() - def media_stop(self) -> None: + async def async_media_stop(self) -> None: """Stop playback.""" self.send_keypress(KEY_STOP) self._attr_state = MediaPlayerState.IDLE + self.async_write_ha_state() - def play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any - ) -> None: - """Not supported.""" - raise NotImplementedError - - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Skip to next track.""" self.send_keypress(KEY_FORWARD) - def media_seek(self, position: float) -> None: - """Not supported.""" - raise NotImplementedError - - def set_volume_level(self, volume: float) -> None: - """Set volume level, range 0..1.""" - raise NotImplementedError - - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Pause playback.""" self.send_keypress(KEY_PAUSE) self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() - def select_source(self, source: str) -> None: - """Not supported.""" - raise NotImplementedError - - def media_play(self) -> None: + async def async_media_play(self) -> None: """Start playback.""" self.send_keypress(KEY_PLAY) self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() - def volume_up(self) -> None: + async def async_volume_up(self) -> None: """Increase volume.""" _LOGGER.debug("%s: volume up", self._logical_address) self.send_keypress(KEY_VOLUME_UP) - def volume_down(self) -> None: + async def async_volume_down(self) -> None: """Decrease volume.""" _LOGGER.debug("%s: volume down", self._logical_address) self.send_keypress(KEY_VOLUME_DOWN) - def update(self) -> None: + async def async_update(self) -> None: """Update device status.""" device = self._device if device.power_status in [POWER_OFF, 3]: diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index d1bb603a938..a0dc6459b4d 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,11 +1,10 @@ """Support for HDMI CEC devices as switches.""" -from __future__ import annotations - import logging from typing import Any -from pycec.const import POWER_OFF, POWER_ON +from pycec.commands import CecCommand +from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.core import HomeAssistant @@ -20,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = SWITCH_DOMAIN + ".{}" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return HDMI devices as switches.""" @@ -33,7 +32,7 @@ def setup_platform( for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data[DOMAIN][device] entities.append(CecSwitchEntity(hdmi_device, hdmi_device.logical_address)) - add_entities(entities, True) + async_add_entities(entities, True) class CecSwitchEntity(CecEntity, SwitchEntity): @@ -44,19 +43,19 @@ class CecSwitchEntity(CecEntity, SwitchEntity): CecEntity.__init__(self, device, logical) self.entity_id = f"{SWITCH_DOMAIN}.hdmi_{hex(self._logical_address)[2:]}" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self._device.turn_on() self._attr_is_on = True - self.schedule_update_ha_state(force_refresh=False) + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" - self._device.turn_off() + self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address)) self._attr_is_on = False - self.schedule_update_ha_state(force_refresh=False) + self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Update device status.""" device = self._device if device.power_status in {POWER_OFF, 3}: diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index c6d10bc72b2..930dfde4d61 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -1,7 +1,5 @@ """Support for the PRT Heatmiser thermostats using the V3 protocol.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/hegel/__init__.py b/homeassistant/components/hegel/__init__.py index e732d215839..20c7e5177e3 100644 --- a/homeassistant/components/hegel/__init__.py +++ b/homeassistant/components/hegel/__init__.py @@ -1,7 +1,5 @@ """The Hegel integration.""" -from __future__ import annotations - import logging from hegel_ip_client import HegelClient diff --git a/homeassistant/components/hegel/config_flow.py b/homeassistant/components/hegel/config_flow.py index 8bbd0d3d0eb..b8b41632aaa 100644 --- a/homeassistant/components/hegel/config_flow.py +++ b/homeassistant/components/hegel/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hegel integration.""" -from __future__ import annotations - import logging from typing import Any @@ -11,10 +9,10 @@ import voluptuous as vol from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -from .const import CONF_MODEL, DEFAULT_PORT, DOMAIN, MODEL_INPUTS +from .const import DEFAULT_PORT, DOMAIN, MODEL_INPUTS _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hegel/const.py b/homeassistant/components/hegel/const.py index dd684d7db40..e77b563c6f4 100644 --- a/homeassistant/components/hegel/const.py +++ b/homeassistant/components/hegel/const.py @@ -3,7 +3,6 @@ DOMAIN = "hegel" DEFAULT_PORT = 50001 -CONF_MODEL = "model" CONF_MAX_VOLUME = "max_volume" # 1.0 means amp's internal max HEARTBEAT_TIMEOUT_MINUTES = 3 @@ -81,6 +80,7 @@ MODEL_INPUTS = { "XLR 2", "Analog 1", "Analog 2", + "Analog 3", "BNC", "Coaxial", "Optical 1", diff --git a/homeassistant/components/hegel/media_player.py b/homeassistant/components/hegel/media_player.py index 7ee8252270c..0b58ad943c5 100644 --- a/homeassistant/components/hegel/media_player.py +++ b/homeassistant/components/hegel/media_player.py @@ -1,7 +1,5 @@ """Hegel media player platform.""" -from __future__ import annotations - import asyncio from collections.abc import Callable import contextlib @@ -22,6 +20,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -29,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from . import HegelConfigEntry -from .const import CONF_MODEL, DOMAIN, HEARTBEAT_TIMEOUT_MINUTES, MODEL_INPUTS +from .const import DOMAIN, HEARTBEAT_TIMEOUT_MINUTES, MODEL_INPUTS _LOGGER = logging.getLogger(__name__) @@ -300,7 +299,7 @@ class HegelMediaPlayer(MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" vol = max(0.0, min(volume, 1.0)) - amp_vol = int(round(vol * 100)) + amp_vol = round(vol * 100) try: await self._client.send(COMMANDS["volume_set"](amp_vol), expect_reply=False) except (HegelConnectionError, TimeoutError, OSError) as err: diff --git a/homeassistant/components/helty/__init__.py b/homeassistant/components/helty/__init__.py new file mode 100644 index 00000000000..ce6edf055cc --- /dev/null +++ b/homeassistant/components/helty/__init__.py @@ -0,0 +1,26 @@ +"""The Helty Flow integration.""" + +from pyhelty import HeltyClient + +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.FAN, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool: + """Set up Helty Flow from a config entry.""" + client = HeltyClient(entry.data[CONF_HOST]) + coordinator = HeltyDataUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/helty/button.py b/homeassistant/components/helty/button.py new file mode 100644 index 00000000000..abe5b2a9338 --- /dev/null +++ b/homeassistant/components/helty/button.py @@ -0,0 +1,47 @@ +"""Button platform for the Helty Flow integration.""" + +from pyhelty import HeltyError + +from homeassistant.components.button import ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator +from .entity import HeltyEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HeltyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Helty buttons.""" + async_add_entities([HeltyResetFilterButton(entry.runtime_data)]) + + +class HeltyResetFilterButton(HeltyEntity, ButtonEntity): + """Resets the filter-life counter after the filter has been replaced.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_filter" + + def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{self._device_id}_reset_filter" + + async def async_press(self) -> None: + """Reset the filter-life counter.""" + try: + await self.coordinator.client.async_reset_filter() + except HeltyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reset_filter_failed", + ) from err + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/helty/config_flow.py b/homeassistant/components/helty/config_flow.py new file mode 100644 index 00000000000..144618786e2 --- /dev/null +++ b/homeassistant/components/helty/config_flow.py @@ -0,0 +1,40 @@ +"""Config flow for the Helty Flow integration.""" + +from typing import Any + +from pyhelty import HeltyClient, HeltyConnectionError, HeltyError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class HeltyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Helty Flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + client = HeltyClient(user_input[CONF_HOST]) + try: + name = await client.async_get_name() + except HeltyConnectionError: + errors["base"] = "cannot_connect" + except HeltyError: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=name or user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/helty/const.py b/homeassistant/components/helty/const.py new file mode 100644 index 00000000000..d6f554295a7 --- /dev/null +++ b/homeassistant/components/helty/const.py @@ -0,0 +1,13 @@ +"""Constants for the Helty Flow integration.""" + +from datetime import timedelta + +DOMAIN = "helty" + +#: How often the coordinator polls the unit. +SCAN_INTERVAL = timedelta(seconds=60) + +# Fan preset mode identifiers (also used as translation keys). +PRESET_BOOST = "boost" +PRESET_NIGHT = "night" +PRESET_FREE_COOLING = "free_cooling" diff --git a/homeassistant/components/helty/coordinator.py b/homeassistant/components/helty/coordinator.py new file mode 100644 index 00000000000..1305cfff2db --- /dev/null +++ b/homeassistant/components/helty/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for the Helty Flow integration.""" + +import logging + +from pyhelty import HeltyClient, HeltyConnectionError, HeltyData, HeltyError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type HeltyConfigEntry = ConfigEntry[HeltyDataUpdateCoordinator] + + +class HeltyDataUpdateCoordinator(DataUpdateCoordinator[HeltyData]): + """Coordinate a single poll of the Helty unit for all entities.""" + + config_entry: HeltyConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HeltyConfigEntry, + client: HeltyClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> HeltyData: + try: + return await self.client.async_get_data() + except HeltyConnectionError as err: + raise UpdateFailed(f"Error communicating with Helty unit: {err}") from err + except HeltyError as err: + raise UpdateFailed(f"Unexpected response from Helty unit: {err}") from err diff --git a/homeassistant/components/helty/entity.py b/homeassistant/components/helty/entity.py new file mode 100644 index 00000000000..92dc4078ab0 --- /dev/null +++ b/homeassistant/components/helty/entity.py @@ -0,0 +1,25 @@ +"""Base entity for the Helty Flow integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HeltyDataUpdateCoordinator + + +class HeltyEntity(CoordinatorEntity[HeltyDataUpdateCoordinator]): + """Common base for Helty entities sharing one device and coordinator.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None: + """Initialize the entity and its shared device info.""" + super().__init__(coordinator) + # The unit exposes no serial/MAC, so the config entry id identifies it. + self._device_id = coordinator.config_entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=coordinator.data.name, + manufacturer="Helty", + model="Flow", + ) diff --git a/homeassistant/components/helty/fan.py b/homeassistant/components/helty/fan.py new file mode 100644 index 00000000000..a3aab27212c --- /dev/null +++ b/homeassistant/components/helty/fan.py @@ -0,0 +1,127 @@ +"""Fan platform for the Helty Flow integration.""" + +from typing import Any + +from pyhelty import FanMode, HeltyError + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN, PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT +from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator +from .entity import HeltyEntity + +PARALLEL_UPDATES = 1 + +# Ordered list of discrete fan speeds, lowest to highest. +ORDERED_SPEEDS: list[FanMode] = [ + FanMode.LOW, + FanMode.MEDIUM, + FanMode.HIGH, + FanMode.MAX, +] + +PRESET_TO_MODE: dict[str, FanMode] = { + PRESET_BOOST: FanMode.BOOST, + PRESET_NIGHT: FanMode.NIGHT, + PRESET_FREE_COOLING: FanMode.FREE_COOLING, +} +MODE_TO_PRESET: dict[FanMode, str] = { + mode: preset for preset, mode in PRESET_TO_MODE.items() +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HeltyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Helty fan.""" + async_add_entities([HeltyFan(entry.runtime_data)]) + + +class HeltyFan(HeltyEntity, FanEntity): + """The ventilation unit's fan, the device's primary feature.""" + + _attr_name = None + _attr_speed_count = len(ORDERED_SPEEDS) + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None: + """Initialize the fan.""" + super().__init__(coordinator) + self._attr_unique_id = self._device_id + self._attr_preset_modes = list(PRESET_TO_MODE) + + @property + def _mode(self) -> FanMode: + return self.coordinator.data.fan_mode + + @property + def is_on(self) -> bool: + """Return whether the fan is running.""" + return self._mode is not FanMode.OFF + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage, or None when on a preset.""" + if self._mode in ORDERED_SPEEDS: + return ordered_list_item_to_percentage(ORDERED_SPEEDS, self._mode) + return None + + @property + def preset_mode(self) -> str | None: + """Return the active preset, or None when running on a discrete speed.""" + return MODE_TO_PRESET.get(self._mode) + + async def async_set_percentage(self, percentage: int) -> None: + """Set a discrete fan speed from a percentage.""" + if percentage == 0: + await self._async_set_mode(FanMode.OFF) + return + await self._async_set_mode( + percentage_to_ordered_list_item(ORDERED_SPEEDS, percentage) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set a preset mode.""" + await self._async_set_mode(PRESET_TO_MODE[preset_mode]) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + elif percentage is not None: + await self.async_set_percentage(percentage) + else: + await self._async_set_mode(FanMode.LOW) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self._async_set_mode(FanMode.OFF) + + async def _async_set_mode(self, mode: FanMode) -> None: + try: + await self.coordinator.client.async_set_fan_mode(mode) + except HeltyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_fan_mode_failed", + ) from err + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/helty/manifest.json b/homeassistant/components/helty/manifest.json new file mode 100644 index 00000000000..4bf4a3c4556 --- /dev/null +++ b/homeassistant/components/helty/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "helty", + "name": "Helty Flow", + "codeowners": ["@ebaschiera"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/helty", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pyhelty"], + "quality_scale": "bronze", + "requirements": ["pyhelty==0.2.0"] +} diff --git a/homeassistant/components/helty/quality_scale.yaml b/homeassistant/components/helty/quality_scale.yaml new file mode 100644 index 00000000000..b650a2fd1e5 --- /dev/null +++ b/homeassistant/components/helty/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not subscribe to external events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The device does not support discovery. + discovery: + status: exempt + comment: | + The device exposes no discovery protocol (no mDNS/SSDP) and no stable + identifier such as a serial number or MAC over its interface. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A config entry represents a single fixed device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: The fan entity uses the default fan icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration has no repairable issues to surface. + stale-devices: + status: exempt + comment: A config entry represents a single fixed device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + The device is controlled over a raw TCP socket, not HTTP, so there is no + web session to inject. + strict-typing: todo diff --git a/homeassistant/components/helty/sensor.py b/homeassistant/components/helty/sensor.py new file mode 100644 index 00000000000..573fa6c7c0e --- /dev/null +++ b/homeassistant/components/helty/sensor.py @@ -0,0 +1,87 @@ +"""Sensor platform for the Helty Flow integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyhelty import HeltyData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator +from .entity import HeltyEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HeltySensorEntityDescription(SensorEntityDescription): + """Describes a Helty sensor.""" + + value_fn: Callable[[HeltyData], float | None] + + +SENSORS: tuple[HeltySensorEntityDescription, ...] = ( + HeltySensorEntityDescription( + key="indoor_temperature", + translation_key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.indoor_temperature, + ), + HeltySensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.outdoor_temperature, + ), + HeltySensorEntityDescription( + key="indoor_humidity", + translation_key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.indoor_humidity, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HeltyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Helty sensors.""" + coordinator = entry.runtime_data + async_add_entities(HeltySensor(coordinator, description) for description in SENSORS) + + +class HeltySensor(HeltyEntity, SensorEntity): + """An environmental sensor reported by the ventilation unit.""" + + entity_description: HeltySensorEntityDescription + + def __init__( + self, + coordinator: HeltyDataUpdateCoordinator, + description: HeltySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{self._device_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the current sensor reading.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/helty/strings.json b/homeassistant/components/helty/strings.json new file mode 100644 index 00000000000..0c1d13f5f42 --- /dev/null +++ b/homeassistant/components/helty/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address or hostname of the Helty Flow unit on your network." + }, + "title": "Connect to your Helty Flow" + } + } + }, + "entity": { + "button": { + "reset_filter": { + "name": "Reset filter" + } + }, + "sensor": { + "indoor_humidity": { + "name": "Indoor humidity" + }, + "indoor_temperature": { + "name": "Indoor temperature" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + } + } + }, + "exceptions": { + "reset_filter_failed": { + "message": "Failed to reset the filter." + }, + "set_fan_mode_failed": { + "message": "Failed to set the ventilation mode." + } + } +} diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 54510540f2a..edda925bf8f 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -1,7 +1,5 @@ """Denon HEOS Media Player.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.const import Platform @@ -46,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Create set of identifiers excluding this integration identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN} migrated_identifiers = {(DOMAIN, str(player_id))} - # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + # Add migrated if not already present in another + # device, which occurs if the user downgraded and + # then upgraded if not device_registry.async_get_device(migrated_identifiers): identifiers.update(migrated_identifiers) if len(identifiers) > 0: diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index b6cda10dcb7..26b8dbd3499 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -42,7 +42,10 @@ AUTH_SCHEMA = vol.Schema( async def _validate_host(host: str, errors: dict[str, str]) -> bool: - """Validate host is reachable, return True, otherwise populate errors and return False.""" + """Validate host is reachable. + + Return True, otherwise populate errors and return False. + """ heos = Heos(HeosOptions(host, events=False, heart_beat=False)) try: await heos.connect() @@ -57,7 +60,10 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool: async def _validate_auth( user_input: dict[str, str], entry: HeosConfigEntry, errors: dict[str, str] ) -> bool: - """Validate authentication by signing in or out, otherwise populate errors if needed.""" + """Validate authentication by signing in or out. + + Populate errors if needed. + """ can_validate = ( hasattr(entry, "runtime_data") and entry.runtime_data.heos.connection_state is ConnectionState.CONNECTED @@ -110,7 +116,7 @@ async def _validate_auth( def _get_current_hosts(entry: HeosConfigEntry) -> set[str]: """Get a set of current hosts from the entry.""" - hosts = set(entry.data[CONF_HOST]) + hosts = {entry.data[CONF_HOST]} if hasattr(entry, "runtime_data"): hosts.update( player.ip_address @@ -265,7 +271,9 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): self._discovered_host = hostname return await self.async_step_confirm_discovery() - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + # Only update if the configured host isn't part of the + # discovered hosts to ensure new players that come online + # don't trigger a reload if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: _LOGGER.debug( "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 5e72eb1427e..8eae905391f 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -1,8 +1,10 @@ """HEOS integration coordinator. -Control of all HEOS devices is through connection to a single device. Data is pushed through events. -The coordinator is responsible for refreshing data in response to system-wide events and notifying -entities to update. Entities subscribe to entity-specific updates within the entity class itself. +Control of all HEOS devices is through connection to a single +device. Data is pushed through events. The coordinator is +responsible for refreshing data in response to system-wide events +and notifying entities to update. Entities subscribe to +entity-specific updates within the entity class itself. """ from collections.abc import Callable, Sequence @@ -111,7 +113,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): if not self.heos.is_signed_in: _LOGGER.warning( - "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" + "The HEOS System is not logged in: Enter credentials" + " in the integration options to access favorites" + " and streaming services" ) # Retrieve initial data await self._async_update_groups() @@ -123,7 +127,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def async_shutdown(self) -> None: """Disconnect all callbacks and disconnect from the device.""" - self.heos.dispatcher.disconnect_all() # Removes all connected through heos.add_on_* and player.add_on_* + # Removes all connected through heos.add_on_* + # and player.add_on_* + self.heos.dispatcher.disconnect_all() await self.heos.disconnect() await super().async_shutdown() @@ -168,7 +174,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self.async_update_listeners() async def _async_on_reconnected(self) -> None: - """Handle when reconnected so resources are updated and entities marked available.""" + """Handle reconnection to update resources and mark entities available.""" assert self.config_entry is not None if self.host != self.config_entry.data[CONF_HOST]: self.hass.config_entries.async_update_entry( @@ -188,7 +194,8 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None self._async_handle_player_update_result(data) elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): - # Debounce because we may have received multiple qualifying events in rapid succession. + # Debounce because we may have received multiple + # qualifying events in rapid succession. await self._update_sources_debouncer.async_call() self.async_update_listeners() @@ -268,15 +275,17 @@ class HeosCoordinator(DataUpdateCoordinator[None]): def async_get_current_source( self, now_playing_media: HeosNowPlayingMedia ) -> str | None: - """Determine current source from now playing media (either input source or favorite).""" + """Determine current source from now playing media.""" # Try matching input source if now_playing_media.source_id == const.MUSIC_SOURCE_AUX_INPUT: # If playing a remote input, name will match station for input_source in self._inputs: if input_source.name == now_playing_media.station: return input_source.name - # If playing a local input, match media_id. This needs to be a second loop as media_id - # will match both local and remote inputs, so prioritize remote match by name first. + # If playing a local input, match media_id. This needs + # to be a second loop as media_id will match both local + # and remote inputs, so prioritize remote match by name + # first. for input_source in self._inputs: if input_source.media_id == now_playing_media.media_id: return input_source.name diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b0372145f82..2803443b772 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -1,7 +1,5 @@ """Denon HEOS Media Player.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine, Sequence from contextlib import suppress import dataclasses @@ -39,7 +37,6 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -476,6 +473,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self.coordinator.heos.set_group(new_members) return + @catch_action_error("remove from queue") async def async_remove_from_queue(self, queue_ids: list[int]) -> None: """Remove items from the queue.""" await self._player.remove_from_queue(queue_ids) @@ -626,7 +624,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): async def _async_browse_media_source( self, media_content_id: str | None = None - ) -> BrowseMediaSource: + ) -> media_source.BrowseMediaSource | media_source.RootBrowseMediaSource: """Browse a media source item.""" return await media_source.async_browse_media( self.hass, diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index e42e2bf27a2..aa6ab238978 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -133,7 +133,8 @@ def register_media_player_services() -> None: def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" _LOGGER.warning( - "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" + "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated" + " and will be removed in the 2025.8.0 release" ) ir.async_create_issue( hass, @@ -149,7 +150,7 @@ def _get_controller(hass: HomeAssistant) -> Heos: hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) ) - if not entry or not entry.state == ConfigEntryState.LOADED: + if not entry or entry.state is not ConfigEntryState.LOADED: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" ) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 9de8230e357..876095ae6e5 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -1,7 +1,5 @@ """The HERE Travel Time integration.""" -from __future__ import annotations - import logging from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 8c068de0af0..546a57d1e1c 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -1,7 +1,5 @@ """Config flow for HERE Travel Time integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -90,6 +88,8 @@ def get_user_step_schema(data: Mapping[str, Any]) -> vol.Schema: travel_mode = TRAVEL_MODE_PUBLIC return vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional( CONF_NAME, default=data.get(CONF_NAME, DEFAULT_NAME) ): cv.string, diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 0e447770ca9..ba7891e19cd 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -1,7 +1,5 @@ """The HERE Travel Time integration.""" -from __future__ import annotations - from datetime import datetime, time, timedelta import logging from typing import Any diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index deb886f6805..cc771a6d973 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -1,7 +1,5 @@ """Model Classes for here_travel_time.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime from typing import TypedDict diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 1500006fc39..1c144a30d06 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -1,7 +1,5 @@ """Support for HERE travel time sensors.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from typing import Any diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py index b6cb1e7617d..2725c0dfba1 100644 --- a/homeassistant/components/hikvision/__init__.py +++ b/homeassistant/components/hikvision/__init__.py @@ -1,7 +1,5 @@ """The Hikvision integration.""" -from __future__ import annotations - from dataclasses import dataclass, field import logging from xml.etree.ElementTree import ParseError diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 8196ed48bd9..0998f1c6604 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Hikvision event stream events represented as binary sensors.""" -from __future__ import annotations - import logging from typing import Any @@ -66,10 +64,6 @@ BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = { key="tamper_detection", device_class=BinarySensorDeviceClass.TAMPER, ), - "Shelter Alarm": BinarySensorEntityDescription( - key="shelter_alarm", - translation_key="shelter_alarm", - ), "Disk Full": BinarySensorEntityDescription( key="disk_full", translation_key="disk_full", diff --git a/homeassistant/components/hikvision/camera.py b/homeassistant/components/hikvision/camera.py index 45db2a6d79a..a4a69ce2c54 100644 --- a/homeassistant/components/hikvision/camera.py +++ b/homeassistant/components/hikvision/camera.py @@ -1,7 +1,5 @@ """Support for Hikvision cameras.""" -from __future__ import annotations - from pyhik.hikvision import VideoChannel from homeassistant.components.camera import Camera, CameraEntityFeature diff --git a/homeassistant/components/hikvision/config_flow.py b/homeassistant/components/hikvision/config_flow.py index ebfa0931f19..dcec18db728 100644 --- a/homeassistant/components/hikvision/config_flow.py +++ b/homeassistant/components/hikvision/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hikvision integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/hikvision/entity.py b/homeassistant/components/hikvision/entity.py index 0042e03e6b6..4cd1ed6315f 100644 --- a/homeassistant/components/hikvision/entity.py +++ b/homeassistant/components/hikvision/entity.py @@ -1,7 +1,5 @@ """Base entity for Hikvision integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/hikvision/strings.json b/homeassistant/components/hikvision/strings.json index a6b279bcdec..673c9d5c761 100644 --- a/homeassistant/components/hikvision/strings.json +++ b/homeassistant/components/hikvision/strings.json @@ -84,9 +84,6 @@ "scene_change_detection": { "name": "Scene change detection" }, - "shelter_alarm": { - "name": "Shelter alarm" - }, "unattended_baggage": { "name": "Unattended baggage" }, diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 85ad3ba2f7a..af9575722bf 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -1,7 +1,5 @@ """Support turning on/off motion detection on Hikvision cameras.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index 3694853fb5a..565ad85bca3 100644 --- a/homeassistant/components/hisense_aehw4a1/__init__.py +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -1,4 +1,5 @@ """The Hisense AEH-W4A1 integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import ipaddress import logging diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index cd9f3666e08..ebef8f74f30 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -1,6 +1,5 @@ """Pyaehw4a1 platform to control of Hisense AEH-W4A1 Climate Devices.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index b948060fe24..095fee5b89e 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -1,7 +1,5 @@ """Provide pre-made queries on top of the recorder component.""" -from __future__ import annotations - from datetime import datetime as dt, timedelta from http import HTTPStatus from typing import cast @@ -9,8 +7,10 @@ from typing import cast from aiohttp import web import voluptuous as vol +from homeassistant.auth.permissions import filter_entity_ids_by_permission +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components import frontend -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE @@ -83,6 +83,12 @@ class HistoryPeriodView(HomeAssistantView): "Invalid filter_entity_id", HTTPStatus.BAD_REQUEST ) + entity_ids = filter_entity_ids_by_permission( + request[KEY_HASS_USER], entity_ids, POLICY_READ + ) + if not entity_ids: + return self.json([]) + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 2010b7373ff..82b453a8357 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -1,7 +1,5 @@ """Helpers for the history integration.""" -from __future__ import annotations - from collections.abc import Iterable from datetime import datetime as dt diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 3761c935992..d03958ae707 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -1,7 +1,5 @@ """Websocket API for the history integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Iterable from dataclasses import dataclass @@ -11,6 +9,8 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.auth.permissions import filter_entity_ids_by_permission +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history from homeassistant.components.websocket_api import ActiveConnection, messages @@ -138,6 +138,13 @@ async def ws_get_history_during_period( connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") return + entity_ids = filter_entity_ids_by_permission( + connection.user, entity_ids, POLICY_READ + ) + if not entity_ids: + connection.send_result(msg["id"], {}) + return + include_start_time_state = msg["include_start_time_state"] no_attributes = msg["no_attributes"] @@ -444,6 +451,13 @@ async def ws_stream( connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") return + entity_ids = filter_entity_ids_by_permission( + connection.user, entity_ids, POLICY_READ + ) + if not entity_ids: + _async_send_empty_response(connection, msg_id, start_time, end_time) + return + include_start_time_state = msg["include_start_time_state"] significant_changes_only = msg["significant_changes_only"] no_attributes = msg["no_attributes"] diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index 762d36c0210..5af162fa29d 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -1,7 +1,5 @@ """The history_stats component.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index fc48e3c8e74..2d7e3c695b0 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -1,7 +1,5 @@ """The history_stats component config flow.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from typing import Any, cast diff --git a/homeassistant/components/history_stats/coordinator.py b/homeassistant/components/history_stats/coordinator.py index 091e1da6ad8..95872ba9e09 100644 --- a/homeassistant/components/history_stats/coordinator.py +++ b/homeassistant/components/history_stats/coordinator.py @@ -1,7 +1,5 @@ """History stats data coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 9a88812342e..1f132870af1 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -1,7 +1,5 @@ """Manage the history_stats data.""" -from __future__ import annotations - from dataclasses import dataclass import datetime import logging @@ -94,10 +92,12 @@ class HistoryStats: utc_now = dt_util.utcnow() now_timestamp = floored_timestamp(utc_now) - # If we end up querying data from the recorder when we get triggered by a new state - # change event, it is possible this function could be reentered a second time before - # the first recorder query returns. In that case a second recorder query will be done - # and we need to hold the new event so that we can append it after the second query. + # If we end up querying data from the recorder when we + # get triggered by a new state change event, it is possible + # this function could be reentered a second time before the + # first recorder query returns. In that case a second + # recorder query will be done and we need to hold the new + # event so that we can append it after the second query. # Otherwise the event will be dropped. if event: self._pending_events.append(event) @@ -219,7 +219,7 @@ class HistoryStats: def _async_compute_seconds_and_changes( self, now_timestamp: float, start_timestamp: float, end_timestamp: float ) -> tuple[float, int]: - """Compute the seconds matched and changes from the history list and first state.""" + """Compute seconds matched and changes from history list.""" # state_changes_during_period is called with include_start_time_state=True # which is the default and always provides the state at the start # of the period @@ -285,7 +285,8 @@ class HistoryStats: def _prune_history_cache(self, start_timestamp: float) -> None: """Remove unnecessary old data from the history state cache from previous runs. - Update the timestamp of the last record from before the start to the current start time. + Update the timestamp of the last record from before the + start to the current start time. """ trim_count = 0 for i, history_state in enumerate(self._history_current_period): diff --git a/homeassistant/components/history_stats/diagnostics.py b/homeassistant/components/history_stats/diagnostics.py index 045e37d49b9..aa65920e242 100644 --- a/homeassistant/components/history_stats/diagnostics.py +++ b/homeassistant/components/history_stats/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for history_stats.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index b0ed132c1ef..8806c034140 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -1,7 +1,5 @@ """Helpers to make instant statistics about your history.""" -from __future__ import annotations - import datetime import logging import math diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 367f9892ca2..cadea34e367 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -1,7 +1,5 @@ """Component to make instant statistics about your history.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable, Mapping import datetime diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index 25de2d8956e..0a7fd6110af 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -1,7 +1,5 @@ """Support for the Hitron CODA-4582U, provided by Rogers.""" -from __future__ import annotations - from collections import namedtuple from http import HTTPStatus import logging diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 5c2527aee81..461fd763fb7 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -1,7 +1,5 @@ """Support for the Hive devices and services.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps import logging @@ -46,14 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool except HiveReauthRequired as err: raise ConfigEntryAuthFailed from err + hub_data = devices["parent"][0] + connections: set[tuple[str, str]] = set() + if mac := hub_data.get("macAddress"): + connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, devices["parent"][0]["device_id"])}, - name=devices["parent"][0]["hiveName"], - model=devices["parent"][0]["deviceData"]["model"], - sw_version=devices["parent"][0]["deviceData"]["version"], - manufacturer=devices["parent"][0]["deviceData"]["manufacturer"], + identifiers={(DOMAIN, hub_data["device_id"])}, + connections=connections, + name=hub_data["hiveName"], + model=hub_data["deviceData"]["model"], + sw_version=hub_data["deviceData"]["version"], + manufacturer=hub_data["deviceData"]["manufacturer"], ) await hass.config_entries.async_forward_entry_setups( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 3e2d02f153c..ef2016174f9 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Hive.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -119,9 +117,23 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: _LOGGER.debug("2FA successful") if self.source == SOURCE_REAUTH: - return await self.async_setup_hive_entry() - self.device_registration = True - return await self.async_step_configuration() + try: + device_registered = await self.hive_auth.is_device_registered() + except HiveApiError as err: + _LOGGER.debug( + "Failed to check whether the Hive device" + " is registered during reauthentication: %s", + err, + ) + errors["base"] = "no_internet_available" + else: + if device_registered: + return await self.async_setup_hive_entry() + self.device_registration = True + return await self.async_step_configuration() + else: + self.device_registration = True + return await self.async_step_configuration() schema = vol.Schema({vol.Required(CONF_CODE): str}) return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors) @@ -145,6 +157,8 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" schema = vol.Schema( + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field {vol.Optional(CONF_DEVICE_NAME, default=self.device_name): str} ) return self.async_show_form( @@ -173,6 +187,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Re Authenticate a user.""" + self.data = dict(entry_data) data = { CONF_USERNAME: entry_data[CONF_USERNAME], CONF_PASSWORD: entry_data[CONF_PASSWORD], @@ -219,6 +234,8 @@ class HiveOptionsFlowHandler(OptionsFlow): schema = vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional(CONF_SCAN_INTERVAL, default=self.interval): vol.All( vol.Coerce(int), vol.Range(min=30) ) diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index 1b679086563..a14d2224718 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -2,7 +2,6 @@ from homeassistant.const import Platform -ATTR_MODE = "mode" ATTR_TIME_PERIOD = "time_period" ATTR_ONOFF = "on_off" CONF_CODE = "2fa" diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index f5648690201..3a79fd8e48a 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -1,7 +1,5 @@ """Support for the Hive devices and services.""" -from __future__ import annotations - from typing import Any from apyhiveapi import Hive diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index f89d23b8513..aef7b6be6cb 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -1,7 +1,5 @@ """Support for Hive light devices.""" -from __future__ import annotations - from datetime import timedelta from typing import Any @@ -14,12 +12,12 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import HiveConfigEntry, refresh_system -from .const import ATTR_MODE from .entity import HiveEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index a03bf9279cb..5ebe678e73b 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.8"] + "requirements": ["pyhive-integration==1.0.9"] } diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 0640436d105..c78a1c54c87 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -1,19 +1,16 @@ """Support for the Hive switches.""" -from __future__ import annotations - from datetime import timedelta from typing import Any from apyhiveapi import Hive from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_MODE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HiveConfigEntry, refresh_system -from .const import ATTR_MODE from .entity import HiveEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py index b99fc07bc2f..f594c6f9412 100644 --- a/homeassistant/components/hko/__init__.py +++ b/homeassistant/components/hko/__init__.py @@ -1,7 +1,5 @@ """The Hong Kong Observatory integration.""" -from __future__ import annotations - from hko import LOCATIONS from homeassistant.const import CONF_LOCATION, Platform diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py index 1e2a6230455..c6f0e929f50 100644 --- a/homeassistant/components/hko/config_flow.py +++ b/homeassistant/components/hko/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hong Kong Observatory integration.""" -from __future__ import annotations - from asyncio import timeout import logging from typing import Any diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index 795f3dc68ea..7bd2617e789 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -1,7 +1,5 @@ """Support for HLK-SW16 switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index f0c340785cf..52228284a98 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -1,7 +1,5 @@ """The Holiday integration.""" -from __future__ import annotations - from functools import partial from holidays import country_holidays diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index c5b67b7d555..286436915de 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -1,7 +1,5 @@ """Holiday Calendar.""" -from __future__ import annotations - from datetime import datetime, timedelta from holidays import PUBLIC, HolidayBase, country_holidays diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index e9f16a9e4c5..fb2681cbb93 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Holiday integration.""" -from __future__ import annotations - from typing import Any from babel import Locale, UnknownLocaleError diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3771b354cc..024983c07f5 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.93", "babel==2.15.0"] + "requirements": ["holidays==0.98", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 91c66a4db56..73998c91590 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -1,7 +1,5 @@ """Support for BSH Home Connect appliances.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/home_connect/climate.py b/homeassistant/components/home_connect/climate.py index eda016342e5..d737ada224c 100644 --- a/homeassistant/components/home_connect/climate.py +++ b/homeassistant/components/home_connect/climate.py @@ -30,23 +30,38 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 HVAC_MODES_PROGRAMS_MAP = { - HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, - HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL, - HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY, - HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN, - HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT, + HVACMode.AUTO: ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO + ), + HVACMode.COOL: ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL + ), + HVACMode.DRY: (ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY), + HVACMode.FAN_ONLY: ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN + ), + HVACMode.HEAT: ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT + ), } PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()} PRESET_MODES_PROGRAMS_MAP = { - "active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN, + "active_clean": ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + ), } PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()} FAN_MODES_OPTIONS = { - FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic", - "manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual", + FAN_AUTO: ( + "HeatingVentilationAirConditioning" + ".AirConditioner.EnumType.FanSpeedMode.Automatic" + ), + "manual": ( + "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual" + ), } FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()} @@ -131,17 +146,12 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): @property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" + active_clean = ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + ) return ( - [ - PROGRAMS_PRESET_MODES_MAP[ - ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN - ] - ] - if any( - program.key - is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN - for program in self.appliance.programs - ) + [PROGRAMS_PRESET_MODES_MAP[active_clean]] + if any(program.key is active_clean for program in self.appliance.programs) else None ) @@ -179,13 +189,13 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update_fan_mode, - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE, ) ) self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update, - EventKey(SettingKey.BSH_COMMON_POWER_STATE), + EventKey.BSH_COMMON_SETTING_POWER_STATE, ) ) @@ -194,19 +204,19 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) program_key = cast(ProgramKey, event.value) if event else None power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE) + active_clean = ( + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + ) self._attr_hvac_mode = ( HVACMode.OFF if power_state is not None and power_state.value != BSH_POWER_ON else PROGRAMS_HVAC_MODES_MAP.get(program_key) - if program_key - and program_key - != ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + if program_key and program_key != active_clean else None ) self._attr_preset_mode = ( PROGRAMS_PRESET_MODES_MAP.get(program_key) - if program_key - == ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + if program_key == active_clean else None ) @@ -215,9 +225,7 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): """Return the fan setting.""" option_value = None if event := self.appliance.events.get( - EventKey( - OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE - ) + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE ): option_value = event.value return ( @@ -264,7 +272,6 @@ class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): translation_placeholders={ **get_dict_from_home_connect_error(err), "appliance_name": self.appliance.info.name, - "value": BSH_POWER_ON, }, ) from err diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index 61e9e56016e..e0f26a54a0e 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -108,27 +108,32 @@ def _handle_paired_or_connected_appliance( ) if entity.unique_id not in known_entity_unique_ids ) - for event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + if not ( + callbacks_for_appliance := changed_options_listener_remove_callbacks[ + appliance_ha_id + ] ): - changed_options_listener_remove_callback = ( - appliance_coordinator.async_add_listener( - partial( - _create_option_entities, - entity_registry, - appliance_coordinator, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), - event_key, + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + appliance_coordinator.async_add_listener( + partial( + _create_option_entities, + entity_registry, + appliance_coordinator, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + event_key, + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + callbacks_for_appliance.append( + changed_options_listener_remove_callback ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance_ha_id].append( - changed_options_listener_remove_callback - ) known_entity_unique_ids.update( {cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add} ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 0719d41c65e..7e5802eac55 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -42,6 +42,7 @@ BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confi BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" +BSH_OPERATION_STATE_DELAYED_START = "BSH.Common.EnumType.OperationState.DelayedStart" BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" @@ -398,17 +399,29 @@ OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, - "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Supermode Refrigerator": ( + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR + ), "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, - "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, - "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Door Alarm Freezer": ( + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER + ), + "Door Alarm Refrigerator": ( + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR + ), + "Temperature Alarm Freezer": ( + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER + ), + "Bean Container Empty": ( + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY + ), + "Water Tank Empty": ( + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY + ), "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3ae43ded4b1..0dab0a1a610 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Home Connect.""" -from __future__ import annotations - from asyncio import sleep as asyncio_sleep from collections.abc import Callable from dataclasses import dataclass @@ -540,7 +538,8 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance ) and program_options ): - # The API doesn't allow to fetch the options from the favorite program. + # The API doesn't allow to fetch the + # options from the favorite program. # We can attempt to get the base program and get the options for option in program_options: if option.key == OptionKey.BSH_COMMON_BASE_PROGRAM: @@ -637,16 +636,19 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance options.update(await self.get_options_definitions(resolved_program_key)) for option in options.values(): - option_value = option.constraints.default if option.constraints else None - if option_value is not None: - option_event_key = EventKey(option.key) + option_event_key = EventKey(option.key) + if ( + option_event_key not in events + and option.constraints + and (option_default_value := option.constraints.default) is not None + ): events[option_event_key] = Event( option_event_key, option.key.value, 0, "", "", - option_value, + option_default_value, option.name, unit=option.unit, ) @@ -676,7 +678,8 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance _LOGGER.warning( 'Too many connected/paired events for appliance "%s" ' "(%s times in less than %s minutes), updates have been disabled " - "and they will be enabled again whenever the connection stabilizes. " + "and they will be enabled again whenever " + "the connection stabilizes. " "Consider trying to unplug the appliance " "for a while to perform a soft reset", self.data.info.name, diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index 08558fcd232..364a109cf19 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Home Connect Diagnostics.""" -from __future__ import annotations - from typing import Any from aiohomeconnect.model import GetSetting, Status diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index c4a45e5603b..f595d399469 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -131,7 +131,7 @@ class HomeConnectOptionEntity(HomeConnectEntity): def constraint_fetcher[_EntityT: HomeConnectEntity, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: - """Decorate the function to catch Home Connect too many requests error and retry later. + """Catch Home Connect too many requests error and retry later. If it needs to be called later, it will call async_write_ha_state function """ diff --git a/homeassistant/components/home_connect/fan.py b/homeassistant/components/home_connect/fan.py index 5188fc34daf..e2ff27ba07b 100644 --- a/homeassistant/components/home_connect/fan.py +++ b/homeassistant/components/home_connect/fan.py @@ -21,9 +21,21 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +_FAN_SPEED_PERCENTAGE_KEY = ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE +) +_FAN_SPEED_MODE_KEY = ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE +) + FAN_SPEED_MODE_OPTIONS = { - "auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic", - "manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual", + "auto": ( + "HeatingVentilationAirConditioning" + ".AirConditioner.EnumType.FanSpeedMode.Automatic" + ), + "manual": ( + "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual" + ), } FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()} @@ -84,7 +96,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity): coordinator, AIR_CONDITIONER_ENTITY_DESCRIPTION, context_override=( - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_PERCENTAGE ), ) self.update_preset_mode() @@ -104,15 +116,14 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity): self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update_preset_mode, - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE, ) ) def update_native_value(self) -> None: """Set the speed percentage and speed mode values.""" option_value = None - option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE - if event := self.appliance.events.get(EventKey(option_key)): + if event := self.appliance.events.get(EventKey(_FAN_SPEED_PERCENTAGE_KEY)): option_value = event.value self._attr_percentage = ( cast(int, option_value) if option_value is not None else None @@ -137,8 +148,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity): def update_preset_mode(self) -> None: """Set the preset mode value.""" option_value = None - option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE - if event := self.appliance.events.get(EventKey(option_key)): + if event := self.appliance.events.get(EventKey(_FAN_SPEED_MODE_KEY)): option_value = event.value self._attr_preset_mode = ( FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value)) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 9dbb60de095..8e8301e959d 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -23,6 +23,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.34.0"], + "requirements": ["aiohomeconnect==0.36.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 164d27ca644..57b72212b2f 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -88,7 +88,7 @@ class HomeConnectProgramSelectEntityDescription( @dataclass(frozen=True, kw_only=True) class HomeConnectSelectEntityDescription(SelectEntityDescription): - """Entity Description class for settings and options that have enumeration values.""" + """Entity Description class for settings and options with enum values.""" translation_key_values: dict[str, str] values_translation_key: dict[str, str] @@ -133,7 +133,9 @@ SELECT_ENTITY_DESCRIPTIONS = ( translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM, values_translation_key={ value: translation_key - for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items() + for translation_key, value in ( + FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items() + ) }, ), HomeConnectSelectEntityDescription( diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index a88ad6df746..c57c7d278d2 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -21,6 +21,7 @@ from homeassistant.util import dt as dt_util, slugify from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_DELAYED_START, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, @@ -599,7 +600,7 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): class HomeConnectProgramSensor(HomeConnectSensor): - """Sensor class for Home Connect sensors that reports information related to the running program.""" + """Sensor class for Home Connect running program information.""" async def async_added_to_hass(self) -> None: """Register listener.""" @@ -624,6 +625,7 @@ class HomeConnectProgramSensor(HomeConnectSensor): """Return whether a program is running, paused or finished.""" status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) return status is not None and status.value in [ + BSH_OPERATION_STATE_DELAYED_START, BSH_OPERATION_STATE_RUN, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_FINISHED, @@ -632,8 +634,9 @@ class HomeConnectProgramSensor(HomeConnectSensor): @property def available(self) -> bool: """Return true if the sensor is available.""" - # These sensors are only available if the program is running, paused or finished. - # Otherwise, some sensors report erroneous values. + # These sensors are only available if the program is + # running, paused or finished. Otherwise, some sensors + # report erroneous values. return super().available and self.program_running def update_native_value(self) -> None: diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index bb9783be62b..7425343705d 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -1,7 +1,5 @@ """Custom actions (previously known as services) for the Home Connect integration.""" -from __future__ import annotations - from collections.abc import Awaitable from typing import Any, cast @@ -63,13 +61,21 @@ PROGRAM_OPTIONS = { OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All( - int, vol.Range(min=1, max=100) - ), + ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + ): vol.All(int, vol.Range(min=1, max=100)), OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)), OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool, + OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool, + OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool, + OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool, + OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool, + OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool, + OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool, }.items() } diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 2bec0dc6cf5..4ec6edc023b 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -22,141 +22,147 @@ set_program_and_options: custom_value: false translation_key: programs options: + - consumer_products_cleaning_robot_program_basic_go_home - consumer_products_cleaning_robot_program_cleaning_clean_all - consumer_products_cleaning_robot_program_cleaning_clean_map - - consumer_products_cleaning_robot_program_basic_go_home - - consumer_products_coffee_maker_program_beverage_ristretto + - consumer_products_coffee_maker_program_beverage_caffe_grande + - consumer_products_coffee_maker_program_beverage_caffe_latte + - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_coffee - consumer_products_coffee_maker_program_beverage_espresso - consumer_products_coffee_maker_program_beverage_espresso_doppio - - consumer_products_coffee_maker_program_beverage_coffee - - consumer_products_coffee_maker_program_beverage_x_l_coffee - - consumer_products_coffee_maker_program_beverage_caffe_grande - consumer_products_coffee_maker_program_beverage_espresso_macchiato - - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_hot_water - consumer_products_coffee_maker_program_beverage_latte_macchiato - - consumer_products_coffee_maker_program_beverage_caffe_latte - consumer_products_coffee_maker_program_beverage_milk_froth + - consumer_products_coffee_maker_program_beverage_ristretto - consumer_products_coffee_maker_program_beverage_warm_milk - - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_beverage_x_l_coffee + - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_black_eye + - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait + - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_coffee_world_cafe_cortado + - consumer_products_coffee_maker_program_coffee_world_cortado + - consumer_products_coffee_maker_program_coffee_world_dead_eye + - consumer_products_coffee_maker_program_coffee_world_doppio + - consumer_products_coffee_maker_program_coffee_world_flat_white + - consumer_products_coffee_maker_program_coffee_world_galao + - consumer_products_coffee_maker_program_coffee_world_garoto - consumer_products_coffee_maker_program_coffee_world_grosser_brauner + - consumer_products_coffee_maker_program_coffee_world_kaapi + - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd + - consumer_products_coffee_maker_program_coffee_world_red_eye - consumer_products_coffee_maker_program_coffee_world_verlaengerter - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun - consumer_products_coffee_maker_program_coffee_world_wiener_melange - - consumer_products_coffee_maker_program_coffee_world_flat_white - - consumer_products_coffee_maker_program_coffee_world_cortado - - consumer_products_coffee_maker_program_coffee_world_cafe_cortado - - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche - - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait - - consumer_products_coffee_maker_program_coffee_world_doppio - - consumer_products_coffee_maker_program_coffee_world_kaapi - - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd - - consumer_products_coffee_maker_program_coffee_world_galao - - consumer_products_coffee_maker_program_coffee_world_garoto - - consumer_products_coffee_maker_program_coffee_world_americano - - consumer_products_coffee_maker_program_coffee_world_red_eye - - consumer_products_coffee_maker_program_coffee_world_black_eye - - consumer_products_coffee_maker_program_coffee_world_dead_eye - - consumer_products_coffee_maker_program_beverage_hot_water - - dishcare_dishwasher_program_pre_rinse + - cooking_common_program_hood_automatic + - cooking_common_program_hood_delayed_shut_off + - cooking_common_program_hood_venting + - cooking_oven_program_heating_mode_3_d_hot_air + - cooking_oven_program_heating_mode_air_fry + - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_bread_baking + - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_desiccation + - cooking_oven_program_heating_mode_dough_proving + - cooking_oven_program_heating_mode_frozen_heatup_special + - cooking_oven_program_heating_mode_grill_large_area + - cooking_oven_program_heating_mode_grill_small_area + - cooking_oven_program_heating_mode_hot_air + - cooking_oven_program_heating_mode_hot_air_100_steam + - cooking_oven_program_heating_mode_hot_air_30_steam + - cooking_oven_program_heating_mode_hot_air_60_steam + - cooking_oven_program_heating_mode_hot_air_80_steam + - cooking_oven_program_heating_mode_hot_air_eco + - cooking_oven_program_heating_mode_hot_air_gentle + - cooking_oven_program_heating_mode_hot_air_grilling + - cooking_oven_program_heating_mode_intensive_heat + - cooking_oven_program_heating_mode_keep_warm + - cooking_oven_program_heating_mode_pizza_setting + - cooking_oven_program_heating_mode_pre_heating + - cooking_oven_program_heating_mode_preheat_ovenware + - cooking_oven_program_heating_mode_proof + - cooking_oven_program_heating_mode_sabbath_programme + - cooking_oven_program_heating_mode_slow_cook + - cooking_oven_program_heating_mode_top_bottom_heating + - cooking_oven_program_heating_mode_top_bottom_heating_eco + - cooking_oven_program_heating_mode_warming_drawer + - cooking_oven_program_microwave_1000_watt + - cooking_oven_program_microwave_180_watt + - cooking_oven_program_microwave_360_watt + - cooking_oven_program_microwave_450_watt + - cooking_oven_program_microwave_600_watt + - cooking_oven_program_microwave_900_watt + - cooking_oven_program_microwave_90_watt + - cooking_oven_program_microwave_max + - cooking_oven_program_steam_modes_steam - dishcare_dishwasher_program_auto_1 - dishcare_dishwasher_program_auto_2 - dishcare_dishwasher_program_auto_3 + - dishcare_dishwasher_program_auto_half_load - dishcare_dishwasher_program_eco_50 - - dishcare_dishwasher_program_quick_45 - - dishcare_dishwasher_program_intensiv_70 - - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_express_sparkle_65 - dishcare_dishwasher_program_glas_40 - dishcare_dishwasher_program_glass_care - - dishcare_dishwasher_program_night_wash - - dishcare_dishwasher_program_quick_65 - - dishcare_dishwasher_program_normal_45 - dishcare_dishwasher_program_intensiv_45 - - dishcare_dishwasher_program_auto_half_load + - dishcare_dishwasher_program_intensiv_70 - dishcare_dishwasher_program_intensiv_power - dishcare_dishwasher_program_intensive_fixed_zone - - dishcare_dishwasher_program_magic_daily - - dishcare_dishwasher_program_super_60 - dishcare_dishwasher_program_kurz_60 - - dishcare_dishwasher_program_express_sparkle_65 + - dishcare_dishwasher_program_learning_dishwasher - dishcare_dishwasher_program_machine_care - - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_magic_daily - dishcare_dishwasher_program_maximum_cleaning - dishcare_dishwasher_program_mixed_load - - dishcare_dishwasher_program_learning_dishwasher + - dishcare_dishwasher_program_night_wash + - dishcare_dishwasher_program_normal_45 + - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_pre_rinse + - dishcare_dishwasher_program_quick_45 + - dishcare_dishwasher_program_quick_65 + - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_super_60 - heating_ventilation_air_conditioning_air_conditioner_program_active_clean - heating_ventilation_air_conditioning_air_conditioner_program_auto - heating_ventilation_air_conditioning_air_conditioner_program_cool - heating_ventilation_air_conditioning_air_conditioner_program_dry - heating_ventilation_air_conditioning_air_conditioner_program_fan - heating_ventilation_air_conditioning_air_conditioner_program_heat - - laundry_care_dryer_program_cotton - - laundry_care_dryer_program_synthetic - - laundry_care_dryer_program_mix + - laundry_care_dryer_program_anti_shrink - laundry_care_dryer_program_blankets - laundry_care_dryer_program_business_shirts + - laundry_care_dryer_program_cotton + - laundry_care_dryer_program_delicates + - laundry_care_dryer_program_dessous - laundry_care_dryer_program_down_feathers - laundry_care_dryer_program_hygiene - - laundry_care_dryer_program_jeans - - laundry_care_dryer_program_outdoor - - laundry_care_dryer_program_synthetic_refresh - - laundry_care_dryer_program_towels - - laundry_care_dryer_program_delicates - - laundry_care_dryer_program_super_40 - - laundry_care_dryer_program_shirts_15 - - laundry_care_dryer_program_pillow - - laundry_care_dryer_program_anti_shrink - - laundry_care_dryer_program_my_time_my_drying_time - - laundry_care_dryer_program_time_cold - - laundry_care_dryer_program_time_warm - laundry_care_dryer_program_in_basket + - laundry_care_dryer_program_jeans + - laundry_care_dryer_program_mix + - laundry_care_dryer_program_my_time_my_drying_time + - laundry_care_dryer_program_outdoor + - laundry_care_dryer_program_pillow + - laundry_care_dryer_program_shirts_15 + - laundry_care_dryer_program_super_40 + - laundry_care_dryer_program_synthetic + - laundry_care_dryer_program_synthetic_refresh + - laundry_care_dryer_program_time_cold - laundry_care_dryer_program_time_cold_fix_time_cold_20 - laundry_care_dryer_program_time_cold_fix_time_cold_30 - laundry_care_dryer_program_time_cold_fix_time_cold_60 + - laundry_care_dryer_program_time_warm - laundry_care_dryer_program_time_warm_fix_time_warm_30 - laundry_care_dryer_program_time_warm_fix_time_warm_40 - laundry_care_dryer_program_time_warm_fix_time_warm_60 - - laundry_care_dryer_program_dessous - - cooking_common_program_hood_automatic - - cooking_common_program_hood_venting - - cooking_common_program_hood_delayed_shut_off - - cooking_oven_program_heating_mode_3_d_heating - - cooking_oven_program_heating_mode_air_fry - - cooking_oven_program_heating_mode_grill_large_area - - cooking_oven_program_heating_mode_grill_small_area - - cooking_oven_program_heating_mode_pre_heating - - cooking_oven_program_heating_mode_hot_air - - cooking_oven_program_heating_mode_hot_air_eco - - cooking_oven_program_heating_mode_hot_air_gentle - - cooking_oven_program_heating_mode_hot_air_grilling - - cooking_oven_program_heating_mode_top_bottom_heating - - cooking_oven_program_heating_mode_top_bottom_heating_eco - - cooking_oven_program_heating_mode_bottom_heating - - cooking_oven_program_heating_mode_bread_baking - - cooking_oven_program_heating_mode_pizza_setting - - cooking_oven_program_heating_mode_slow_cook - - cooking_oven_program_heating_mode_intensive_heat - - cooking_oven_program_heating_mode_keep_warm - - cooking_oven_program_heating_mode_preheat_ovenware - - cooking_oven_program_heating_mode_frozen_heatup_special - - cooking_oven_program_heating_mode_desiccation - - cooking_oven_program_heating_mode_defrost - - cooking_oven_program_heating_mode_dough_proving - - cooking_oven_program_heating_mode_proof - - cooking_oven_program_heating_mode_hot_air_30_steam - - cooking_oven_program_heating_mode_hot_air_60_steam - - cooking_oven_program_heating_mode_hot_air_80_steam - - cooking_oven_program_heating_mode_hot_air_100_steam - - cooking_oven_program_heating_mode_sabbath_programme - - cooking_oven_program_microwave_90_watt - - cooking_oven_program_microwave_180_watt - - cooking_oven_program_microwave_360_watt - - cooking_oven_program_microwave_450_watt - - cooking_oven_program_microwave_600_watt - - cooking_oven_program_microwave_900_watt - - cooking_oven_program_microwave_1000_watt - - cooking_oven_program_microwave_max - - cooking_oven_program_steam_modes_steam - - cooking_oven_program_heating_mode_warming_drawer + - laundry_care_dryer_program_towels + - laundry_care_washer_dryer_program_cotton + - laundry_care_washer_dryer_program_cotton_eco_4060 + - laundry_care_washer_dryer_program_easy_care + - laundry_care_washer_dryer_program_mix + - laundry_care_washer_dryer_program_wash_and_dry_60 + - laundry_care_washer_dryer_program_wash_and_dry_90 - laundry_care_washer_program_auto_30 - laundry_care_washer_program_auto_40 - laundry_care_washer_program_auto_60 @@ -190,12 +196,6 @@ set_program_and_options: - laundry_care_washer_program_towels - laundry_care_washer_program_water_proof - laundry_care_washer_program_wool - - laundry_care_washer_dryer_program_cotton - - laundry_care_washer_dryer_program_cotton_eco_4060 - - laundry_care_washer_dryer_program_mix - - laundry_care_washer_dryer_program_easy_care - - laundry_care_washer_dryer_program_wash_and_dry_60 - - laundry_care_washer_dryer_program_wash_and_dry_90 air_conditioner_options: collapsed: true fields: @@ -210,6 +210,7 @@ set_program_and_options: mode: box unit_of_measurement: "%" heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode: + example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic required: false selector: select: @@ -222,7 +223,7 @@ set_program_and_options: collapsed: true fields: consumer_products_cleaning_robot_option_reference_map_id: - example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + example: consumer_products_cleaning_robot_enum_type_available_maps_map_1 required: false selector: select: @@ -230,9 +231,9 @@ set_program_and_options: translation_key: available_maps options: - consumer_products_cleaning_robot_enum_type_available_maps_temp_map - - consumer_products_cleaning_robot_enum_type_available_maps_map1 - - consumer_products_cleaning_robot_enum_type_available_maps_map2 - - consumer_products_cleaning_robot_enum_type_available_maps_map3 + - consumer_products_cleaning_robot_enum_type_available_maps_map_1 + - consumer_products_cleaning_robot_enum_type_available_maps_map_2 + - consumer_products_cleaning_robot_enum_type_available_maps_map_3 consumer_products_cleaning_robot_option_cleaning_mode: example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard required: false @@ -310,7 +311,7 @@ set_program_and_options: - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c - consumer_products_coffee_maker_option_bean_container: + consumer_products_coffee_maker_option_bean_container_selection: example: consumer_products_coffee_maker_enum_type_bean_container_selection_right required: false selector: @@ -468,8 +469,8 @@ set_program_and_options: hood_options: collapsed: true fields: - cooking_hood_option_venting_level: - example: cooking_hood_enum_type_stage_fan_stage01 + cooking_common_option_hood_venting_level: + example: cooking_hood_enum_type_stage_fan_stage_01 required: false selector: select: @@ -482,8 +483,8 @@ set_program_and_options: - cooking_hood_enum_type_stage_fan_stage_03 - cooking_hood_enum_type_stage_fan_stage_04 - cooking_hood_enum_type_stage_fan_stage_05 - cooking_hood_option_intensive_level: - example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + cooking_common_option_hood_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage_1 required: false selector: select: @@ -491,8 +492,8 @@ set_program_and_options: translation_key: intensive_level options: - cooking_hood_enum_type_intensive_stage_intensive_stage_off - - cooking_hood_enum_type_intensive_stage_intensive_stage1 - - cooking_hood_enum_type_intensive_stage_intensive_stage2 + - cooking_hood_enum_type_intensive_stage_intensive_stage_1 + - cooking_hood_enum_type_intensive_stage_intensive_stage_2 oven_options: collapsed: true fields: @@ -567,7 +568,7 @@ set_program_and_options: - laundry_care_washer_enum_type_temperature_ul_hot - laundry_care_washer_enum_type_temperature_ul_extra_hot laundry_care_washer_option_spin_speed: - example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + example: laundry_care_washer_enum_type_spin_speed_r_p_m_800 required: false selector: select: @@ -611,12 +612,12 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_i_dos1_active: + laundry_care_washer_option_i_dos_1_active: example: false required: false selector: boolean: - laundry_care_washer_option_i_dos2_active: + laundry_care_washer_option_i_dos_2_active: example: false required: false selector: @@ -656,7 +657,7 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_vario_perfect: + laundry_care_common_option_vario_perfect: example: laundry_care_common_enum_type_vario_perfect_eco_perfect required: false selector: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index b49476407df..8a50dfe860c 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -260,7 +260,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -431,7 +431,7 @@ } }, "bean_container": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]", "state": { "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]", "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]" @@ -484,9 +484,9 @@ "current_map": { "name": "Current map", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -557,19 +557,19 @@ } }, "intensive_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]", "state": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]" } }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -620,7 +620,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -786,7 +786,7 @@ } }, "vario_perfect": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]", "state": { "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", @@ -794,7 +794,7 @@ } }, "venting_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", @@ -1272,10 +1272,10 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" }, "i_dos1_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]" }, "i_dos2_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]" }, "intensiv_zone": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" @@ -1458,9 +1458,9 @@ }, "available_maps": { "options": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map" } }, @@ -1584,8 +1584,8 @@ }, "intensive_level": { "options": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off" } }, @@ -1629,7 +1629,7 @@ "cooking_common_program_hood_automatic": "Automatic", "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", "cooking_common_program_hood_venting": "Venting", - "cooking_oven_program_heating_mode_3_d_heating": "3D heating", + "cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air", "cooking_oven_program_heating_mode_air_fry": "Air fry", "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", "cooking_oven_program_heating_mode_bread_baking": "Bread baking", @@ -1892,7 +1892,7 @@ "description": "Describes the amount of coffee beans used in a coffee machine program.", "name": "Bean amount" }, - "consumer_products_coffee_maker_option_bean_container": { + "consumer_products_coffee_maker_option_bean_container_selection": { "description": "Defines the preferred bean container.", "name": "Bean container" }, @@ -1920,11 +1920,11 @@ "description": "Defines if double dispensing is enabled.", "name": "Multiple beverages" }, - "cooking_hood_option_intensive_level": { + "cooking_common_option_hood_intensive_level": { "description": "Defines the intensive setting.", "name": "Intensive level" }, - "cooking_hood_option_venting_level": { + "cooking_common_option_hood_venting_level": { "description": "Defines the required fan setting.", "name": "Venting level" }, @@ -1992,15 +1992,19 @@ "description": "Defines if the silent mode is activated.", "name": "Silent mode" }, + "laundry_care_common_option_vario_perfect": { + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", + "name": "Vario perfect" + }, "laundry_care_dryer_option_drying_target": { "description": "Describes the drying target for a dryer program.", "name": "Drying target" }, - "laundry_care_washer_option_i_dos1_active": { + "laundry_care_washer_option_i_dos_1_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)", "name": "i-Dos 1 Active" }, - "laundry_care_washer_option_i_dos2_active": { + "laundry_care_washer_option_i_dos_2_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)", "name": "i-Dos 2 Active" }, @@ -2044,10 +2048,6 @@ "description": "Defines the temperature of the washing program.", "name": "Temperature" }, - "laundry_care_washer_option_vario_perfect": { - "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", - "name": "Vario perfect" - }, "laundry_care_washer_option_water_plus": { "description": "Defines if the water plus option is activated.", "name": "Water +" diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py index ee5febb3cf7..ab3f7a713c7 100644 --- a/homeassistant/components/home_connect/utils.py +++ b/homeassistant/components/home_connect/utils.py @@ -18,7 +18,8 @@ def bsh_key_to_translation_key(bsh_key: str) -> str: """Convert a BSH key to a translation key format. This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + and converts it to a translation key format, such as + `dishcare_dishwasher_bsh_key_eco50`. """ return "_".join( RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 9583857660f..54c6454167b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -289,9 +289,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: """Service handler for reloading core config.""" try: conf = await conf_util.async_hass_config_yaml(hass) - except HomeAssistantError as err: - _LOGGER.error(err) - return + except (HomeAssistantError, FileNotFoundError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="core_config_reload_failed", + translation_placeholders={"error": str(err)}, + ) from err # auth only processed during startup await core_config.async_process_ha_core_config(hass, conf.get(DOMAIN) or {}) @@ -409,7 +412,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: exposed_entities = ExposedEntities(hass) await exposed_entities.async_initialize() hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities - async_set_stop_handler(hass, _async_stop) + async_set_stop_handler(hass) async def _async_check_deprecation(event: Event) -> None: """Check and create deprecation issues after startup.""" @@ -452,6 +455,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: "arch": arch, }, ) + if not info["docker"] and not info["virtualenv"]: + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_local_deps", + breaks_in_ha_version="2026.11.0", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="unsupported_local_deps", + ) # Delay deprecation check to make sure installation method is determined correctly hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_check_deprecation) @@ -469,7 +483,11 @@ async def _async_stop(hass: HomeAssistant, restart: bool) -> None: @callback def async_set_stop_handler( hass: HomeAssistant, - stop_handler: Callable[[HomeAssistant, bool], Coroutine[Any, Any, None]], + stop_handler: Callable[[HomeAssistant, bool], Coroutine[Any, Any, None]] + | None = None, ) -> None: - """Set function which is called by the stop and restart services.""" - hass.data[DATA_STOP_HANDLER] = stop_handler + """Set function which is called by the stop and restart services. + + If stop handler is omitted it will restore the default stop handler. + """ + hass.data[DATA_STOP_HANDLER] = _async_stop if stop_handler is None else stop_handler diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index 3ca8a14cce7..95892bc9e4a 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -1,7 +1,5 @@ """Constants for the Homeassistant integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Final from homeassistant import core as ha diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 135e6cdd376..b999c033634 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -1,7 +1,5 @@ """Control which entities are exposed to voice assistants.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import dataclasses from itertools import chain @@ -12,7 +10,6 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -248,9 +245,6 @@ class ExposedEntities: """Return True if an entity should be exposed to an assistant.""" should_expose: bool - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): return self._async_should_expose_legacy_entity(assistant, entity_id) @@ -408,19 +402,6 @@ def ws_expose_entity( """Expose an entity to an assistant.""" entity_ids: list[str] = msg["entity_ids"] - if blocked := next( - ( - entity_id - for entity_id in entity_ids - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES - ), - None, - ): - connection.send_error( - msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" - ) - return - for entity_id in entity_ids: for assistant in msg["assistants"]: async_expose_entity(hass, assistant, entity_id, msg["should_expose"]) diff --git a/homeassistant/components/homeassistant/logbook.py b/homeassistant/components/homeassistant/logbook.py index 2e7c17485e1..c5c7ca08ec8 100644 --- a/homeassistant/components/homeassistant/logbook.py +++ b/homeassistant/components/homeassistant/logbook.py @@ -1,7 +1,5 @@ """Describe homeassistant logbook events.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/homeassistant/components/homeassistant/repairs.py b/homeassistant/components/homeassistant/repairs.py index d631c13b569..5f770d7bdf4 100644 --- a/homeassistant/components/homeassistant/repairs.py +++ b/homeassistant/components/homeassistant/repairs.py @@ -1,10 +1,11 @@ """Repairs for Home Assistant.""" -from __future__ import annotations - -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir from .const import DOMAIN @@ -20,7 +21,7 @@ class IntegrationNotFoundFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( step_id="init", @@ -30,7 +31,7 @@ class IntegrationNotFoundFlow(RepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" entries = self.hass.config_entries.async_entries(self.domain) for entry in entries: @@ -39,7 +40,7 @@ class IntegrationNotFoundFlow(RepairsFlow): async def async_step_ignore( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the ignore step of a fix flow.""" ir.async_get(self.hass).async_ignore( DOMAIN, f"integration_not_found.{self.domain}", True @@ -60,7 +61,7 @@ class OrphanedConfigEntryFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( step_id="init", @@ -70,14 +71,14 @@ class OrphanedConfigEntryFlow(RepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" await self.hass.config_entries.async_remove(self.entry_id) return self.async_create_entry(data={}) async def async_step_ignore( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the ignore step of a fix flow.""" ir.async_get(self.hass).async_ignore( DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 33ae659f0f6..be8926bb098 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -1,7 +1,5 @@ """Allow users to set and activate scenes.""" -from __future__ import annotations - from collections.abc import Mapping, ValuesView import logging from typing import Any, NamedTuple, cast @@ -185,9 +183,12 @@ async def async_setup_platform( """Reload the scene config.""" try: config = await conf_util.async_hass_config_yaml(hass) - except HomeAssistantError as err: - _LOGGER.error(err) - return + except (HomeAssistantError, FileNotFoundError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="scene_config_reload_failed", + translation_placeholders={"error": str(err)}, + ) from err integration = await async_get_integration(hass, SCENE_DOMAIN) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index b928ff0b851..edcc87b2f35 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -52,9 +52,7 @@ reload_config_entry: target: fields: entry_id: - advanced: true required: false - example: 8955375327824e14ba89e4b29cc3ec9a selector: config_entry: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 36478b7cc58..a1002f96aa5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -21,6 +21,9 @@ "config_validator_unknown_err": { "message": "Unknown error calling {domain} config validator - {error}." }, + "core_config_reload_failed": { + "message": "Failed to reload the Home Assistant Core configuration - {error}" + }, "max_length_exceeded": { "message": "Value {value} for property {property_name} has a maximum length of {max_length} characters." }, @@ -48,6 +51,9 @@ "platform_schema_validator_err": { "message": "Unknown error when validating config for {domain} from integration {p_name} - {error}." }, + "scene_config_reload_failed": { + "message": "Failed to reload the Home Assistant scene platform configuration - {error}" + }, "service_config_entry_not_found": { "message": "Integration {domain} config entry with ID {entry_id} was not found." }, @@ -94,7 +100,7 @@ "title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]" }, "country_not_configured": { - "description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below.", + "description": "No country has been configured. Click the \"Learn more\" button below to set your country.", "title": "The country has not been configured" }, "deprecated_architecture": { @@ -106,12 +112,12 @@ "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]" }, "deprecated_method": { - "description": "This system is using the {installation_type} installation type, which has been deprecated and will become unsupported following the release of Home Assistant 2025.12. While you can continue using your current setup after that point, we strongly recommend migrating to a supported installation method.", - "title": "Deprecation notice: Installation method" + "description": "This system is using the {installation_type} installation type, which has been unsupported since Home Assistant 2025.12. To continue receiving updates and support, migrate to a supported installation method.", + "title": "Unsupported installation method" }, "deprecated_method_architecture": { - "description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been deprecated and will no longer be supported after the release of Home Assistant 2025.12.", - "title": "Deprecation notice" + "description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been unsupported since Home Assistant 2025.12. To continue receiving updates and support, migrate to supported hardware and use a supported installation method.", + "title": "Unsupported installation method and architecture" }, "deprecated_os_aarch64": { "description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. To continue using Home Assistant on this hardware, you will need to install a 64-bit operating system. Please refer to our [installation guide]({installation_guide}).", @@ -148,7 +154,7 @@ }, "step": { "init": { - "description": "The integration `{domain}` could not be found. This happens when a (community) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.", + "description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.", "menu_options": { "confirm": "Remove previous configurations", "ignore": "Ignore" @@ -167,10 +173,6 @@ "description": "Please do the following steps:\n- Adopt your configuration to support template rendering to native python types.\n- Remove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file.\n- Restart Home Assistant to fix this issue.", "title": "The support for legacy templates is being removed" }, - "no_platform_setup": { - "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}", - "title": "Unused YAML configuration for the {platform} integration" - }, "orphaned_ignored_config_entry": { "fix_flow": { "abort": { @@ -189,10 +191,18 @@ }, "title": "Orphaned ignored config entry for {domain}" }, + "platform_config_not_supported": { + "description": "Configuring the {integration_domain} integration by adding `{platform_key}` under the `{platform_domain}:` key is not supported. The {integration_domain} integration must be configured under its own `{integration_domain}:` key instead.\n\nTo resolve this:\n\n1. Remove the following from your YAML configuration file:\n\n{yaml_example}\n\n2. Move the configuration under the `{integration_domain}:` key instead.\n\n3. Restart Home Assistant.\n\nTo see the detailed documentation, select Learn more.", + "title": "Unsupported YAML configuration for the {integration_domain} integration" + }, "platform_only": { "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant.", "title": "The {domain} integration does not support YAML configuration under its own key" }, + "platform_setup_not_supported": { + "description": "It's not possible to configure {integration_domain} {platform_domain} by adding `{platform_key}` to the {platform_domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n\n1. Remove `{platform_key}` occurrences from the `{platform_domain}:` configuration in your YAML configuration file.\n\n2. Restart Home Assistant.", + "title": "Unused YAML configuration for the {integration_domain} integration" + }, "storage_corruption": { "fix_flow": { "step": { @@ -203,6 +213,10 @@ } }, "title": "Storage corruption detected for {storage_key}" + }, + "unsupported_local_deps": { + "description": "This system is running Home Assistant outside a virtual environment or a Docker container. This is not supported and will not work after the release of Home Assistant 2026.11.", + "title": "Deprecation notice: Installation method" } }, "services": { @@ -215,10 +229,10 @@ "name": "Reload all Home Assistant configuration" }, "reload_config_entry": { - "description": "Reloads the specified config entry.", + "description": "Reloads any explicitly provided config entry ID and any config entries referenced by entities or devices in the target. If both are provided, the union of those config entries is reloaded.", "fields": { "entry_id": { - "description": "The configuration entry ID of the entry to be reloaded.", + "description": "Optional configuration entry ID to reload.", "name": "Config entry ID" } }, @@ -236,7 +250,7 @@ "description": "Restarts Home Assistant.", "fields": { "safe_mode": { - "description": "Disable community integrations and community cards.", + "description": "Disable custom integrations and custom cards.", "name": "Safe mode" } }, diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index 3f98c5ae6e0..addea87ac92 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from homeassistant.components import system_health diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 8065c23c5c1..7f45f19862b 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,7 +1,5 @@ """Offer event listening automation rules.""" -from __future__ import annotations - from collections.abc import ItemsView, Mapping from typing import Any diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index dac250792ea..906674dc8ca 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -1,7 +1,5 @@ """Offer numeric state listening automation rules.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 53372cb479e..0ca943d3c3f 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -1,7 +1,5 @@ """Offer state listening automation rules.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging @@ -9,7 +7,13 @@ import logging import voluptuous as vol from homeassistant import exceptions -from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + MATCH_ALL, +) from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -34,7 +38,6 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -CONF_ENTITY_ID = "entity_id" CONF_FROM = "from" CONF_TO = "to" CONF_NOT_FROM = "not_from" diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b..ded640b55a7 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_OFFSET, CONF_PLATFORM, + CONF_WEEKDAY, STATE_UNAVAILABLE, STATE_UNKNOWN, WEEKDAYS, @@ -38,8 +39,6 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -CONF_WEEKDAY = "weekday" - _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) @@ -68,7 +67,8 @@ _TIME_TRIGGER_SCHEMA = vol.Any( valid_at_template, msg=( "Expected HH:MM, HH:MM:SS, an Entity ID with domain 'input_datetime' or " - "'sensor', a combination of a timestamp sensor entity and an offset, or Limited Template" + "'sensor', a combination of a timestamp sensor entity" + " and an offset, or Limited Template" ), ) @@ -225,7 +225,7 @@ async def async_attach_trigger( # noqa: C901 elif ( new_state.domain == "sensor" and new_state.attributes.get(ATTR_DEVICE_CLASS) - == sensor.SensorDeviceClass.TIMESTAMP + in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): trigger_dt = dt_util.parse_datetime(new_state.state) @@ -257,17 +257,19 @@ async def async_attach_trigger( # noqa: C901 at_time = _TIME_AT_SCHEMA(render) except vol.Invalid as exc: raise HomeAssistantError( - f"Limited Template for 'at' rendered a unexpected value '{render}', expected HH:MM, " - f"HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'" + f"Limited Template for 'at' rendered a" + f" unexpected value '{render}', expected" + " HH:MM, HH:MM:SS or Entity ID with domain" + " 'input_datetime' or 'sensor'" ) from exc if isinstance(at_time, str): # entity update_entity_trigger(at_time, new_state=hass.states.get(at_time)) to_track.append(TrackEntity(at_time, update_entity_trigger_event)) - elif isinstance(at_time, dict) and CONF_OFFSET in at_time: - # entity with offset - entity_id: str = at_time.get(CONF_ENTITY_ID, "") + elif isinstance(at_time, dict): + # entity with optional offset + entity_id: str = at_time[CONF_ENTITY_ID] offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0)) update_entity_trigger( entity_id, new_state=hass.states.get(entity_id), offset=offset diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 14096d87277..9bd9ce1fb3c 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -1,7 +1,5 @@ """Offer time listening automation rules.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 4a268901ca2..270afa41fad 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant alerts integration.""" -from __future__ import annotations - import logging from homeassistant.const import EVENT_COMPONENT_LOADED diff --git a/homeassistant/components/homeassistant_alerts/coordinator.py b/homeassistant/components/homeassistant_alerts/coordinator.py index 542ebf857df..3e63cf8b744 100644 --- a/homeassistant/components/homeassistant_alerts/coordinator.py +++ b/homeassistant/components/homeassistant_alerts/coordinator.py @@ -5,7 +5,7 @@ import logging from awesomeversion import AwesomeVersion, AwesomeVersionStrategy -from homeassistant.components.hassio import get_supervisor_info +from homeassistant.components.hassio import HassioNotReadyError, get_supervisor_info from homeassistant.const import __version__ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -78,7 +78,9 @@ class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]) continue if self.supervisor and "supervisor" in alert: - if (supervisor_info := get_supervisor_info(self.hass)) is None: + try: + supervisor_info = get_supervisor_info(self.hass) + except HassioNotReadyError: continue if "affected_from_version" in alert["supervisor"]: diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py index cbd88114e66..0d7edab7a52 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/__init__.py +++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant Connect ZBT-2 integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging import os.path @@ -10,14 +8,14 @@ from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) from homeassistant.components.usb import USBDevice, async_register_port_event_callback -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL +from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL, SERIAL_NUMBER _LOGGER = logging.getLogger(__name__) @@ -97,3 +95,75 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"]) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HomeAssistantConnectZBT2ConfigEntry +) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version == 1: + serial_number = config_entry.data[SERIAL_NUMBER] + + # Installations ended up with multiple config entries per physical adapter + # in 2026.5.0 and 2026.5.1. We need to delete the older entry. + duplicates = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data.get(SERIAL_NUMBER) == serial_number + ] + canonical = max( + duplicates, + key=lambda e: ( + e.source != SOURCE_IGNORE, + e.disabled_by is None, + e.minor_version, + e.modified_at, + e.entry_id, + ), + ) + + if canonical.entry_id != config_entry.entry_id: + # The canonical entry's migration will remove this duplicate. + return False + + for duplicate in duplicates: + if duplicate.entry_id == config_entry.entry_id: + continue + _LOGGER.debug( + "Removing duplicate config entry %s for serial %s in favor of %s", + duplicate.entry_id, + serial_number, + config_entry.entry_id, + ) + await hass.config_entries.async_remove(duplicate.entry_id) + + # Replace the synthetic unique ID with the USB serial number + hass.config_entries.async_update_entry( + config_entry, + unique_id=serial_number, + version=1, + minor_version=2, + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + # This means the user has downgraded from a future version + return False diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py index cca11596259..78bd8cab5d3 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py +++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Home Assistant Connect ZBT-2 integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, Protocol @@ -16,10 +14,7 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.usb import ( - usb_service_info_from_device, - usb_unique_id_from_service_info, -) +from homeassistant.components.usb import usb_service_info_from_device from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -114,7 +109,7 @@ class HomeAssistantConnectZBT2ConfigFlow( """Handle a config flow for Home Assistant Connect ZBT-2.""" VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" @@ -132,14 +127,12 @@ class HomeAssistantConnectZBT2ConfigFlow( async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" - unique_id = usb_unique_id_from_service_info(discovery_info) - discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device ) try: - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(discovery_info.serial_number) finally: self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device}) @@ -157,9 +150,10 @@ class HomeAssistantConnectZBT2ConfigFlow( """Handle import from ZHA/OTBR firmware notification.""" assert fw_discovery_info["usb_device"] is not None usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"]) - unique_id = usb_unique_id_from_service_info(usb_info) - if await self.async_set_unique_id(unique_id, raise_on_progress=False): + if await self.async_set_unique_id( + usb_info.serial_number, raise_on_progress=False + ): self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device}) self._usb_info = usb_info diff --git a/homeassistant/components/homeassistant_connect_zbt2/diagnostics.py b/homeassistant/components/homeassistant_connect_zbt2/diagnostics.py new file mode 100644 index 00000000000..dcbd1967502 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/diagnostics.py @@ -0,0 +1,13 @@ +"""Provides diagnostics for the Home Assistant Connect ZBT-2 integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return {"config_entry": config_entry.as_dict()} diff --git a/homeassistant/components/homeassistant_connect_zbt2/hardware.py b/homeassistant/components/homeassistant_connect_zbt2/hardware.py index 0d45e055407..29720c53c49 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/hardware.py +++ b/homeassistant/components/homeassistant_connect_zbt2/hardware.py @@ -1,7 +1,5 @@ """The Home Assistant Connect ZBT-2 hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback @@ -21,7 +19,9 @@ EXPECTED_ENTRY_VERSION = ( @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" - entries = hass.config_entries.async_entries(DOMAIN) + entries = hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ) return [ HardwareInfo( board=None, diff --git a/homeassistant/components/homeassistant_connect_zbt2/switch.py b/homeassistant/components/homeassistant_connect_zbt2/switch.py index 07d6677e999..316e1821192 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/switch.py +++ b/homeassistant/components/homeassistant_connect_zbt2/switch.py @@ -1,7 +1,5 @@ """Home Assistant Connect ZBT-2 switch entities.""" -from __future__ import annotations - import logging from homeassistant.components.homeassistant_hardware.coordinator import ( diff --git a/homeassistant/components/homeassistant_connect_zbt2/update.py b/homeassistant/components/homeassistant_connect_zbt2/update.py index e2d166bdf77..39cb770b5d3 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/update.py +++ b/homeassistant/components/homeassistant_connect_zbt2/update.py @@ -1,7 +1,5 @@ """Home Assistant Connect ZBT-2 firmware update entity.""" -from __future__ import annotations - import logging from universal_silabs_flasher.flasher import Zbt2Flasher @@ -176,7 +174,10 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): device_registry = dr.async_get(self.hass) device_registry.async_update_device( device_id=self.device_entry.id, - sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + sw_version=( + f"{self.entity_description.firmware_name}" + f" {self._attr_installed_version}" + ), ) @callback diff --git a/homeassistant/components/homeassistant_connect_zbt2/util.py b/homeassistant/components/homeassistant_connect_zbt2/util.py index ebd6f33a8a8..13c7a85a953 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/util.py +++ b/homeassistant/components/homeassistant_connect_zbt2/util.py @@ -1,7 +1,5 @@ """Utility functions for Home Assistant Connect ZBT-2 integration.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/homeassistant_green/__init__.py b/homeassistant/components/homeassistant_green/__init__.py index 79688f9d16a..5d3ebcb868b 100644 --- a/homeassistant/components/homeassistant_green/__init__.py +++ b/homeassistant/components/homeassistant_green/__init__.py @@ -1,8 +1,6 @@ """The Home Assistant Green integration.""" -from __future__ import annotations - -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import HassioNotReadyError, get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -16,9 +14,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - if (os_info := get_os_info(hass)) is None: - # The hassio integration has not yet fetched data from the supervisor - raise ConfigEntryNotReady + try: + os_info = get_os_info(hass) + except HassioNotReadyError as err: + raise ConfigEntryNotReady from err board: str | None if (board := os_info.get("board")) is None or board != "green": diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py index ca03c213db7..301060fb516 100644 --- a/homeassistant/components/homeassistant_green/config_flow.py +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Home Assistant Green integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py index 825eede5653..5fcd3091390 100644 --- a/homeassistant/components/homeassistant_green/hardware.py +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -1,7 +1,5 @@ """The Home Assistant Green hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import BoardInfo, HardwareInfo from homeassistant.components.hassio import get_os_info from homeassistant.core import HomeAssistant, callback @@ -18,8 +16,7 @@ MODEL = "green" @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" - if (os_info := get_os_info(hass)) is None: - raise HomeAssistantError + os_info = get_os_info(hass) board: str | None if (board := os_info.get("board")) is None: raise HomeAssistantError diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index fc2b393805e..9f815da13ba 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant Hardware integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index eeeab870514..5f929683b4a 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -1,8 +1,7 @@ """Constants for the Homeassistant Hardware integration.""" -from __future__ import annotations - import logging +import re from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey @@ -37,3 +36,7 @@ SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol" SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher" Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html" + +# Community add-ons use an 8-char repository hash prefix in their slug +Z2M_ADDON_NAME = "Zigbee2MQTT" +Z2M_ADDON_SLUG_REGEX = re.compile(r"^[0-9a-f]{8}_zigbee2mqtt(?:_edge)?$") diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index 6c4b2cb38e4..85913533e5b 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -1,7 +1,5 @@ """Home Assistant hardware firmware update coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 84fb9f2cb3d..eb551ce4f20 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Home Assistant SkyConnect integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from enum import StrEnum @@ -281,7 +279,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): if probed_fw_version >= fw_version: _LOGGER.debug( - "Not downgrading firmware, installed %s is newer than available %s", + "Not downgrading firmware, installed %s" + " is newer than available %s", probed_fw_version, fw_version, ) @@ -419,10 +418,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): otbr_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(otbr_manager) - if addon_info.state == AddonState.NOT_INSTALLED: + if addon_info.state is AddonState.NOT_INSTALLED: return await self.async_step_install_otbr_addon() - if addon_info.state == AddonState.RUNNING: + if addon_info.state is AddonState.RUNNING: await otbr_manager.async_stop_addon() return await self.async_step_start_otbr_addon() diff --git a/homeassistant/components/homeassistant_hardware/helpers.py b/homeassistant/components/homeassistant_hardware/helpers.py index 58337362f0e..1e86a0ca1ef 100644 --- a/homeassistant/components/homeassistant_hardware/helpers.py +++ b/homeassistant/components/homeassistant_hardware/helpers.py @@ -1,7 +1,5 @@ """Home Assistant Hardware integration helpers.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager @@ -218,7 +216,8 @@ class HardwareInfoDispatcher: if self._active_firmware_updates[device] != source_domain: current_domain = self._active_firmware_updates[device] raise ValueError( - f"Firmware update for {device} is owned by {current_domain}, not {source_domain}" + f"Firmware update for {device} is owned by" + f" {current_domain}, not {source_domain}" ) del self._active_firmware_updates[device] diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 02963f796cf..7efc85d2d30 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -3,12 +3,11 @@ "name": "Home Assistant Hardware", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], - "dependencies": ["usb"], + "dependencies": ["repairs", "usb"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "serialx==1.1.1", - "universal-silabs-flasher==1.0.3", + "universal-silabs-flasher==1.1.0", "ha-silabs-firmware-client==0.3.0" ] } diff --git a/homeassistant/components/homeassistant_hardware/repair_helpers.py b/homeassistant/components/homeassistant_hardware/repair_helpers.py new file mode 100644 index 00000000000..144e4dee42e --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/repair_helpers.py @@ -0,0 +1,72 @@ +"""Repairs for the Home Assistant Hardware integration.""" + +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration" + + +@callback +def _multi_pan_issue_id(config_entry: ConfigEntry) -> str: + """Return the issue id for the multi-PAN migration issue of an entry.""" + return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}" + + +@callback +def async_create_multi_pan_migration_issue( + hass: HomeAssistant, + domain: str, + config_entry: ConfigEntry, +) -> None: + """Create a repair issue to guide migration away from Multi-PAN.""" + ir.async_create_issue( + hass, + domain=domain, + issue_id=_multi_pan_issue_id(config_entry), + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MULTI_PAN_MIGRATION, + translation_placeholders={"hardware_name": config_entry.title}, + data={"entry_id": config_entry.entry_id}, + ) + + +@callback +def async_delete_multi_pan_migration_issue( + hass: HomeAssistant, + domain: str, + config_entry: ConfigEntry, +) -> None: + """Delete the multi-PAN migration repair issue for this entry.""" + ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry)) + + +class MultiPanMigrationRepairFlow(RepairsFlow): + """Reuse the multi-PAN options flow uninstall steps as a repair flow. + + Subclass this together with the hardware-specific + ``MultiPanOptionsFlowHandler`` in each hardware integration's repairs + module. + + The repair flow runs in the repairs flow manager where ``self.handler`` + is the integration domain rather than the hardware config entry id, so + the ``config_entry`` accessor of ``OptionsFlow`` must be overridden. + """ + + _repair_config_entry: ConfigEntry + + @property + def config_entry(self) -> ConfigEntry: + """Return the hardware config entry to migrate.""" + return self._repair_config_entry + + async def _async_step_start_migration(self) -> RepairsFlowResult: + """Jump straight into the uninstall step of the migration flow. + + The repair flow's init data is the issue context, not user form input, + so pass None to render the uninstall confirmation form. + """ + return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return] diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b32998f55b0..bee67611e6a 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -1,13 +1,13 @@ """Manage the Silicon Labs Multiprotocol add-on.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio import dataclasses import logging from typing import Any, Protocol +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing import voluptuous as vol import yarl @@ -27,6 +27,7 @@ from homeassistant.config_entries import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -39,15 +40,18 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store -from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG +from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG +from .util import ( + ApplicationType, + WaitingAddonManager, + async_firmware_flashing_context, + async_flash_silabs_firmware, +) _LOGGER = logging.getLogger(__name__) DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager" -DATA_FLASHER_ADDON_MANAGER = "silabs_flasher" -ADDON_STATE_POLL_INTERVAL = 3 -ADDON_INFO_POLL_TIMEOUT = 15 * 60 CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" @@ -73,53 +77,6 @@ async def get_multiprotocol_addon_manager( return manager -class WaitingAddonManager(AddonManager): - """Addon manager which supports waiting operations for managing an addon.""" - - async def async_wait_until_addon_state(self, *states: AddonState) -> None: - """Poll an addon's info until it is in a specific state.""" - async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT): - while True: - try: - info = await self.async_get_addon_info() - except AddonError: - info = None - - _LOGGER.debug("Waiting for addon to be in state %s: %s", states, info) - - if info is not None and info.state in states: - break - - await asyncio.sleep(ADDON_STATE_POLL_INTERVAL) - - async def async_start_addon_waiting(self) -> None: - """Start an add-on.""" - await self.async_schedule_start_addon() - await self.async_wait_until_addon_state(AddonState.RUNNING) - - async def async_install_addon_waiting(self) -> None: - """Install an add-on.""" - await self.async_schedule_install_addon() - await self.async_wait_until_addon_state( - AddonState.RUNNING, - AddonState.NOT_RUNNING, - ) - - async def async_uninstall_addon_waiting(self) -> None: - """Uninstall an add-on.""" - try: - info = await self.async_get_addon_info() - except AddonError: - info = None - - # Do not try to uninstall an addon if it is already uninstalled - if info is not None and info.state == AddonState.NOT_INSTALLED: - return - - await self.async_uninstall_addon() - await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED) - - class MultiprotocolAddonManager(WaitingAddonManager): """Silicon Labs Multiprotocol add-on manager.""" @@ -267,18 +224,6 @@ class MultipanProtocol(Protocol): """ -@singleton(DATA_FLASHER_ADDON_MANAGER) -@callback -def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: - """Get the flasher add-on manager.""" - return WaitingAddonManager( - hass, - LOGGER, - "Silicon Labs Flasher", - SILABS_FLASHER_ADDON_SLUG, - ) - - @dataclasses.dataclass class SerialPortSettings: """Serial port settings.""" @@ -309,7 +254,7 @@ class OptionsFlowHandler(OptionsFlow, ABC): def __init__(self, config_entry: ConfigEntry) -> None: """Set up the options flow.""" - # pylint: disable=hass-component-root-import + # pylint: disable=home-assistant-component-root-import from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) @@ -341,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC): def _zha_name(self) -> str: """Return the ZHA name.""" + @abstractmethod + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + + @abstractmethod + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp').""" + + @property + @abstractmethod + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + @property def flow_manager(self) -> OptionsFlowManager: """Return the correct flow manager.""" @@ -385,7 +343,7 @@ class OptionsFlowHandler(OptionsFlow, ABC): multipan_manager = await get_multiprotocol_addon_manager(self.hass) addon_info = await self._async_get_addon_info(multipan_manager) - if addon_info.state == AddonState.NOT_INSTALLED: + if addon_info.state is AddonState.NOT_INSTALLED: return await self.async_step_addon_not_installed() return await self.async_step_addon_installed() @@ -451,7 +409,7 @@ class OptionsFlowHandler(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" - # pylint: disable=hass-component-root-import + # pylint: disable=home-assistant-component-root-import from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, @@ -688,62 +646,8 @@ class OptionsFlowHandler(OptionsFlow, ABC): async def async_step_firmware_revert( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Install the flasher addon, if necessary.""" - - flasher_manager = get_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(flasher_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_flasher_addon() - - if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_configure_flasher_addon() - - # If the addon is already installed and running, fail - return self.async_abort( - reason="addon_already_running", - description_placeholders={"addon_name": flasher_manager.addon_name}, - ) - - async def async_step_install_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show progress dialog for installing flasher addon.""" - flasher_manager = get_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(flasher_manager) - - _LOGGER.debug("Flasher addon state: %s", addon_info) - - if not self.install_task: - self.install_task = self.hass.async_create_task( - flasher_manager.async_install_addon_waiting(), - "SiLabs Flasher addon install", - eager_start=False, - ) - - if not self.install_task.done(): - return self.async_show_progress( - step_id="install_flasher_addon", - progress_action="install_addon", - description_placeholders={"addon_name": flasher_manager.addon_name}, - progress_task=self.install_task, - ) - - try: - await self.install_task - except AddonError as err: - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="install_failed") - finally: - self.install_task = None - - return self.async_show_progress_done(next_step_id="configure_flasher_addon") - - async def async_step_configure_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Perform initial backup and reconfigure ZHA.""" - # pylint: disable=hass-component-root-import + """Initiate ZHA backup and start multiprotocol addon uninstall.""" + # pylint: disable=home-assistant-component-root-import from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, @@ -784,17 +688,6 @@ class OptionsFlowHandler(OptionsFlow, ABC): _LOGGER.exception("Unexpected exception during ZHA migration") raise AbortFlow("zha_migration_failed") from err - flasher_manager = get_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(flasher_manager) - new_addon_config = { - **addon_info.options, - "device": new_settings.device, - "flow_control": new_settings.flow_control, - } - - _LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config, flasher_manager) - return await self.async_step_uninstall_multiprotocol_addon() async def async_step_uninstall_multiprotocol_addon( @@ -823,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC): finally: self.stop_task = None - return self.async_show_progress_done(next_step_id="start_flasher_addon") + return self.async_show_progress_done(next_step_id="install_zigbee_firmware") - async def async_step_start_flasher_addon( + async def async_step_install_zigbee_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Start Silicon Labs Flasher add-on.""" - flasher_manager = get_flasher_addon_manager(self.hass) + """Flash Zigbee firmware directly onto the radio.""" + if not self.install_task: - if not self.start_task: + async def _flash_firmware() -> None: + serial_port_settings = await self._async_serial_port_settings() + device = serial_port_settings.device - async def start_and_wait_until_done() -> None: - await flasher_manager.async_start_addon_waiting() - # Now that the addon is running, wait for it to finish - await flasher_manager.async_wait_until_addon_state( - AddonState.NOT_RUNNING - ) + # For the duration of firmware flashing, hint to other integrations + # (i.e. ZHA) that the hardware is in use and should not be accessed. + async with async_firmware_flashing_context(self.hass, device, DOMAIN): + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(self._firmware_update_url(), session) - self.start_task = self.hass.async_create_task( - start_and_wait_until_done(), eager_start=False + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw + for fw in manifest.firmwares + if fw.filename.startswith(self._zigbee_firmware_type()) + ) + fw_data = await client.async_fetch_firmware(fw_manifest) + except ( + StopIteration, + TimeoutError, + ClientError, + ManifestMissing, + ValueError, + ) as err: + raise HomeAssistantError( + "Failed to fetch Zigbee firmware" + ) from err + + await async_flash_silabs_firmware( + hass=self.hass, + device=device, + fw_data=fw_data, + flasher_cls=self._flasher_cls, + expected_installed_firmware_type=ApplicationType.EZSP, + progress_callback=lambda offset, total: ( + self.async_update_progress(offset / total) + ), + ) + + self.install_task = self.hass.async_create_task( + _flash_firmware(), + "Flash Zigbee firmware", + eager_start=False, ) - if not self.start_task.done(): + if not self.install_task.done(): return self.async_show_progress( - step_id="start_flasher_addon", - progress_action="start_flasher_addon", - description_placeholders={"addon_name": flasher_manager.addon_name}, - progress_task=self.start_task, + step_id="install_zigbee_firmware", + progress_action="install_zigbee_firmware", + description_placeholders={ + "hardware_name": self._hardware_name(), + }, + progress_task=self.install_task, ) try: - await self.start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="flasher_failed") + await self.install_task + except HomeAssistantError as err: + _LOGGER.error("Failed to flash Zigbee firmware: %s", err) + return self.async_show_progress_done(next_step_id="firmware_flash_failed") finally: - self.start_task = None + self.install_task = None return self.async_show_progress_done(next_step_id="flashing_complete") - async def async_step_flasher_failed( + async def async_step_firmware_flash_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Flasher add-on start failed.""" - flasher_manager = get_flasher_addon_manager(self.hass) + """Firmware flashing failed.""" return self.async_abort( - reason="addon_start_failed", - description_placeholders={"addon_name": flasher_manager.addon_name}, + reason="fw_install_failed", + description_placeholders={"firmware_name": "Zigbee"}, ) async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Finish flashing and update the config entry.""" - flasher_manager = get_flasher_addon_manager(self.hass) - await flasher_manager.async_uninstall_addon_waiting() - # Finish ZHA migration if needed if self._zha_migration_mgr: try: @@ -909,7 +833,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: # Request the addon to start if it's not started # `async_start_addon` returns as soon as the start request has been sent # and does not wait for the addon to be started, so we raise below - if addon_info.state == AddonState.NOT_RUNNING: + if addon_info.state is AddonState.NOT_RUNNING: await multipan_manager.async_start_addon() if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): @@ -929,7 +853,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> multipan_manager = await get_multiprotocol_addon_manager(hass) addon_info: AddonInfo = await multipan_manager.async_get_addon_info() - if addon_info.state != AddonState.RUNNING: + if addon_info.state is not AddonState.RUNNING: return False if addon_info.options["device"] != device_path: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 3545c080e08..8c02b5f6cad 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -102,7 +102,9 @@ }, "progress": { "install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.", - "start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds." + "install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.", + "start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.", + "uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled." }, "step": { "addon_installed_other_device": { diff --git a/homeassistant/components/homeassistant_hardware/switch.py b/homeassistant/components/homeassistant_hardware/switch.py index 6da4964da39..4c447ab8f6e 100644 --- a/homeassistant/components/homeassistant_hardware/switch.py +++ b/homeassistant/components/homeassistant_hardware/switch.py @@ -1,7 +1,5 @@ """Home Assistant Hardware base beta firmware switch entity.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 3501ec67d4f..c6a9db8c8e2 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -1,7 +1,5 @@ """Home Assistant Hardware base firmware update entity.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -73,7 +71,8 @@ class FirmwareUpdateExtraStoredData(ExtraStoredData): return cls( FirmwareManifest.from_json( data["firmware_manifest"], - # This data is not technically part of the manifest and is loaded externally + # This data is not technically part of the manifest + # and is loaded externally url=URL(data["firmware_manifest"]["url"]), html_url=URL(data["firmware_manifest"]["html_url"]), ) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index e8e57b2ae48..d6018655e7b 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -1,7 +1,5 @@ """Utility functions for Home Assistant SkyConnect integration.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import AsyncGenerator, Callable, Sequence @@ -14,7 +12,13 @@ from universal_silabs_flasher.const import ApplicationType as FlasherApplication from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.flasher import BaseFlasher, DeviceSpecificFlasher, Flasher -from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.hassio import ( + AddonError, + AddonManager, + AddonState, + HassioNotReadyError, + get_apps_list, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -26,18 +30,66 @@ from .const import ( OTBR_ADDON_MANAGER_DATA, OTBR_ADDON_NAME, OTBR_ADDON_SLUG, + Z2M_ADDON_NAME, + Z2M_ADDON_SLUG_REGEX, ZIGBEE_FLASHER_ADDON_MANAGER_DATA, ZIGBEE_FLASHER_ADDON_NAME, ZIGBEE_FLASHER_ADDON_SLUG, ) from .helpers import async_firmware_update_context -from .silabs_multiprotocol_addon import ( - WaitingAddonManager, - get_multiprotocol_addon_manager, -) _LOGGER = logging.getLogger(__name__) +ADDON_STATE_POLL_INTERVAL = 3 +ADDON_INFO_POLL_TIMEOUT = 15 * 60 + + +class WaitingAddonManager(AddonManager): + """Addon manager which supports waiting operations for managing an addon.""" + + async def async_wait_until_addon_state(self, *states: AddonState) -> None: + """Poll an addon's info until it is in a specific state.""" + async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT): + while True: + try: + info = await self.async_get_addon_info() + except AddonError: + info = None + + _LOGGER.debug("Waiting for addon to be in state %s: %s", states, info) + + if info is not None and info.state in states: + break + + await asyncio.sleep(ADDON_STATE_POLL_INTERVAL) + + async def async_start_addon_waiting(self) -> None: + """Start an add-on.""" + await self.async_schedule_start_addon() + await self.async_wait_until_addon_state(AddonState.RUNNING) + + async def async_install_addon_waiting(self) -> None: + """Install an add-on.""" + await self.async_schedule_install_addon() + await self.async_wait_until_addon_state( + AddonState.RUNNING, + AddonState.NOT_RUNNING, + ) + + async def async_uninstall_addon_waiting(self) -> None: + """Uninstall an add-on.""" + try: + info = await self.async_get_addon_info() + except AddonError: + info = None + + # Do not try to uninstall an addon if it is already uninstalled + if info is not None and info.state == AddonState.NOT_INSTALLED: + return + + await self.async_uninstall_addon() + await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED) + class ApplicationType(StrEnum): """Application type running on a device.""" @@ -84,6 +136,17 @@ def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager ) +@callback +def get_z2m_addon_manager(hass: HomeAssistant, slug: str) -> WaitingAddonManager: + """Get the Z2M add-on manager.""" + return WaitingAddonManager( + hass, + _LOGGER, + Z2M_ADDON_NAME, + slug, + ) + + @dataclass(kw_only=True) class OwningAddon: """Owning add-on.""" @@ -107,7 +170,7 @@ class OwningAddon: except AddonError: return False else: - return addon_info.state == AddonState.RUNNING + return addon_info.state is AddonState.RUNNING @asynccontextmanager async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[None]: @@ -120,7 +183,7 @@ class OwningAddon: yield return - if addon_info.state != AddonState.RUNNING: + if addon_info.state is not AddonState.RUNNING: yield return @@ -156,7 +219,7 @@ class OwningIntegration: yield return - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: yield return @@ -196,7 +259,7 @@ async def get_otbr_addon_firmware_info( except AddonError: return None - if otbr_addon_info.state == AddonState.NOT_INSTALLED: + if otbr_addon_info.state is AddonState.NOT_INSTALLED: return None if (otbr_path := otbr_addon_info.options.get("device")) is None: @@ -212,6 +275,32 @@ async def get_otbr_addon_firmware_info( ) +async def get_z2m_addon_firmware_info( + hass: HomeAssistant, z2m_addon_manager: AddonManager +) -> FirmwareInfo | None: + """Get firmware info from a Z2M add-on.""" + try: + z2m_addon_info = await z2m_addon_manager.async_get_addon_info() + except AddonError: + return None + + if z2m_addon_info.state is AddonState.NOT_INSTALLED: + return None + + serial = z2m_addon_info.options.get("serial") + + if not isinstance(serial, dict) or (z2m_port := serial.get("port")) is None: + return None + + return FirmwareInfo( + device=z2m_port, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({z2m_addon_manager.addon_slug})", + owners=[OwningAddon(slug=z2m_addon_manager.addon_slug)], + ) + + async def guess_hardware_owners( hass: HomeAssistant, device_path: str ) -> list[FirmwareInfo]: @@ -221,46 +310,63 @@ async def guess_hardware_owners( async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info(): device_guesses[firmware_info.device].append(firmware_info) + if not is_hassio(hass): + return device_guesses.get(device_path, []) + # It may be possible for the OTBR addon to be present without the integration - if is_hassio(hass): - otbr_addon_manager = get_otbr_addon_manager(hass) - otbr_addon_fw_info = await get_otbr_addon_firmware_info( - hass, otbr_addon_manager - ) - otbr_path = ( - otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None - ) + otbr_addon_manager = get_otbr_addon_manager(hass) + otbr_addon_fw_info = await get_otbr_addon_firmware_info(hass, otbr_addon_manager) + otbr_path = otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None - # Only create a new entry if there are no existing OTBR ones - if otbr_path is not None and not any( - info.source == "otbr" for info in device_guesses[otbr_path] - ): - assert otbr_addon_fw_info is not None - device_guesses[otbr_path].append(otbr_addon_fw_info) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + assert otbr_addon_fw_info is not None + device_guesses[otbr_path].append(otbr_addon_fw_info) - if is_hassio(hass): - multipan_addon_manager = await get_multiprotocol_addon_manager(hass) + # Lazy import to avoid circular dependency + from .silabs_multiprotocol_addon import ( # noqa: PLC0415 + get_multiprotocol_addon_manager, + ) - try: - multipan_addon_info = await multipan_addon_manager.async_get_addon_info() - except AddonError: - pass - else: - if multipan_addon_info.state != AddonState.NOT_INSTALLED: - multipan_path = multipan_addon_info.options.get("device") + multipan_addon_manager = await get_multiprotocol_addon_manager(hass) - if multipan_path is not None: - device_guesses[multipan_path].append( - FirmwareInfo( - device=multipan_path, - firmware_type=ApplicationType.CPC, - firmware_version=None, - source="multiprotocol", - owners=[ - OwningAddon(slug=multipan_addon_manager.addon_slug) - ], - ) + try: + multipan_addon_info = await multipan_addon_manager.async_get_addon_info() + except AddonError: + pass + else: + if multipan_addon_info.state is not AddonState.NOT_INSTALLED: + multipan_path = multipan_addon_info.options.get("device") + + if multipan_path is not None: + device_guesses[multipan_path].append( + FirmwareInfo( + device=multipan_path, + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[OwningAddon(slug=multipan_addon_manager.addon_slug)], ) + ) + + # Z2M can be provided by one of many add-ons, we match them by name + try: + apps_list = get_apps_list(hass) + except HassioNotReadyError: + apps_list = [] + for app_info in apps_list: + slug = app_info.get("slug") + + if not isinstance(slug, str) or Z2M_ADDON_SLUG_REGEX.fullmatch(slug) is None: + continue + + z2m_addon_manager = get_z2m_addon_manager(hass, slug) + z2m_fw_info = await get_z2m_addon_firmware_info(hass, z2m_addon_manager) + + if z2m_fw_info is not None: + device_guesses[z2m_fw_info.device].append(z2m_fw_info) return device_guesses.get(device_path, []) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index a386a49894a..f39f42f1574 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant SkyConnect integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging import os.path @@ -9,13 +7,20 @@ import os.path from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + async_create_multi_pan_migration_issue, + async_delete_multi_pan_migration_issue, +) +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + multi_pan_addon_using_device, +) from homeassistant.components.homeassistant_hardware.util import guess_firmware_info from homeassistant.components.usb import ( USBDevice, async_register_port_event_callback, - scan_serial_ports, + async_scan_serial_ports, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -94,6 +99,16 @@ async def async_setup_entry( translation_key="device_disconnected", ) + try: + uses_multi_pan = await multi_pan_addon_using_device(hass, device_path) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + if uses_multi_pan: + async_create_multi_pan_migration_issue(hass, DOMAIN, entry) + else: + async_delete_multi_pan_migration_issue(hass, DOMAIN, entry) + # Create and store the firmware update coordinator in runtime_data session = async_get_clientsession(hass) coordinator = FirmwareUpdateCoordinator( @@ -125,11 +140,17 @@ async def async_migrate_entry( "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: if config_entry.minor_version == 1: - # Add-on startup with type service get started before Core, always (e.g. the - # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, - # so we can't safely probe here. Instead, we must make an educated guess! + # Add-on startup with type service get started before + # Core, always (e.g. the Multi-Protocol add-on). + # Probing the firmware would interfere with the + # add-on, so we can't safely probe here. Instead, + # we must make an educated guess! firmware_guess = await guess_firmware_info(hass, config_entry.data[DEVICE]) new_data = {**config_entry.data} @@ -163,7 +184,7 @@ async def async_migrate_entry( key not in config_entry.data for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) ): - serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports = await async_scan_serial_ports(hass) serial_ports_info = {port.device: port for port in serial_ports} device = config_entry.data[DEVICE] @@ -196,6 +217,50 @@ async def async_migrate_entry( minor_version=4, ) + if config_entry.minor_version == 4: + serial_number = config_entry.data[SERIAL_NUMBER] + + # Installations ended up with multiple config entries per physical adapter + # in 2026.5.0 and 2026.5.1. We need to delete the older entry. + duplicates = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data.get(SERIAL_NUMBER) == serial_number + ] + canonical = max( + duplicates, + key=lambda e: ( + e.source != SOURCE_IGNORE, + e.disabled_by is None, + e.minor_version, + e.modified_at, + e.entry_id, + ), + ) + + if canonical.entry_id != config_entry.entry_id: + # The canonical entry's migration will remove this duplicate. + return False + + for duplicate in duplicates: + if duplicate.entry_id == config_entry.entry_id: + continue + _LOGGER.warning( + "Removing duplicate config entry %s for serial %s in favor of %s", + duplicate.entry_id, + serial_number, + config_entry.entry_id, + ) + await hass.config_entries.async_remove(duplicate.entry_id) + + # Replace the synthetic unique ID with the USB serial number + hass.config_entries.async_update_entry( + config_entry, + unique_id=serial_number, + version=1, + minor_version=5, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 654714aa243..d8ff88463f4 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Home Assistant SkyConnect integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, Protocol @@ -19,10 +17,7 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.usb import ( - usb_service_info_from_device, - usb_unique_id_from_service_info, -) +from homeassistant.components.usb import usb_service_info_from_device from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -130,7 +125,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 4 + MINOR_VERSION = 5 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" @@ -154,9 +149,7 @@ class HomeAssistantSkyConnectConfigFlow( async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" - unique_id = usb_unique_id_from_service_info(discovery_info) - - if await self.async_set_unique_id(unique_id): + if await self.async_set_unique_id(discovery_info.serial_number): self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device}) discovery_info.device = await self.hass.async_add_executor_job( @@ -182,9 +175,10 @@ class HomeAssistantSkyConnectConfigFlow( """Handle import from ZHA/OTBR firmware notification.""" assert fw_discovery_info["usb_device"] is not None usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"]) - unique_id = usb_unique_id_from_service_info(usb_info) - if await self.async_set_unique_id(unique_id, raise_on_progress=False): + if await self.async_set_unique_id( + usb_info.serial_number, raise_on_progress=False + ): self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device}) self._usb_info = usb_info @@ -254,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( """Return the name of the hardware.""" return self._hw_variant.full_name + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + return NABU_CASA_FIRMWARE_RELEASES_URL + + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier.""" + return "skyconnect_zigbee_ncp" + + @property + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + return Zbt1Flasher # type: ignore[no-any-return] + async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 90ac80bf49a..641701fb67d 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -1,7 +1,5 @@ """The Home Assistant SkyConnect hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/homeassistant_sky_connect/repairs.py b/homeassistant/components/homeassistant_sky_connect/repairs.py new file mode 100644 index 00000000000..2ee1ebb9927 --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/repairs.py @@ -0,0 +1,48 @@ +"""Repairs for the Home Assistant SkyConnect integration.""" + +from typing import Any, cast + +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, + MultiPanMigrationRepairFlow, +) +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler + + +class SkyConnectMultiPanMigrationRepairFlow( + MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler +): + """Multi-PAN migration repair flow for Home Assistant SkyConnect.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize the repair flow.""" + HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry) + self._repair_config_entry = config_entry + + async def async_step_init( # type: ignore[override] + self, user_input: dict[str, Any] | None = None + ) -> RepairsFlowResult: + """Jump straight into the uninstall step.""" + return await self._async_step_start_migration() + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a SkyConnect repair issue.""" + if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None: + entry_id = cast(str, data["entry_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + return SkyConnectMultiPanMigrationRepairFlow(entry) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 43a3d4dcf7b..7a033aa2992 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -106,6 +106,37 @@ "message": "The device is not plugged in" } }, + "issues": { + "multi_pan_migration": { + "fix_flow": { + "abort": { + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" + }, + "step": { + "uninstall_addon": { + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + }, + "description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + } + }, + "title": "Multiprotocol support is deprecated" + } + }, "options": { "abort": { "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", @@ -130,8 +161,10 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" }, "step": { "addon_installed_other_device": { diff --git a/homeassistant/components/homeassistant_sky_connect/switch.py b/homeassistant/components/homeassistant_sky_connect/switch.py index 249e744fe87..57fce65a1e2 100644 --- a/homeassistant/components/homeassistant_sky_connect/switch.py +++ b/homeassistant/components/homeassistant_sky_connect/switch.py @@ -1,7 +1,5 @@ """Home Assistant SkyConnect switch entities.""" -from __future__ import annotations - import logging from homeassistant.components.homeassistant_hardware.coordinator import ( diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index b44560c1f9b..598526c2488 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -1,7 +1,5 @@ """Home Assistant SkyConnect firmware update entity.""" -from __future__ import annotations - import logging from universal_silabs_flasher.flasher import Zbt1Flasher @@ -197,7 +195,10 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): device_registry = dr.async_get(self.hass) device_registry.async_update_device( device_id=self.device_entry.id, - sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + sw_version=( + f"{self.entity_description.firmware_name}" + f" {self._attr_installed_version}" + ), ) @callback diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index c463c1b9275..5fee492c1a1 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -1,7 +1,5 @@ """Utility functions for Home Assistant SkyConnect integration.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index e772c0fe7b3..a9d0c8b9d28 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,31 +1,41 @@ """The Home Assistant Yellow integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import HassioNotReadyError, get_os_info from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + async_create_multi_pan_migration_issue, + async_delete_multi_pan_migration_issue, +) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, + multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, guess_firmware_info, ) +from homeassistant.components.usb import ( + SerialDevice, + USBDevice, + async_register_serial_port_scanner, +) from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from .const import ( + DOMAIN, FIRMWARE, FIRMWARE_VERSION, + MANUFACTURER, NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA, @@ -54,9 +64,10 @@ async def async_setup_entry( hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - if (os_info := get_os_info(hass)) is None: - # The hassio integration has not yet fetched data from the supervisor - raise ConfigEntryNotReady + try: + os_info = get_os_info(hass) + except HassioNotReadyError as err: + raise ConfigEntryNotReady from err if os_info.get("board") != "yellow": # Not running on a Home Assistant Yellow, Home Assistant may have been migrated @@ -72,6 +83,16 @@ async def async_setup_entry( except HomeAssistantError as err: raise ConfigEntryNotReady from err + try: + multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + if multipan_using_device: + async_create_multi_pan_migration_issue(hass, DOMAIN, entry) + else: + async_delete_multi_pan_migration_issue(hass, DOMAIN, entry) + if firmware is ApplicationType.EZSP: discovery_flow.async_create_flow( hass, @@ -80,6 +101,20 @@ async def async_setup_entry( data=ZHA_HW_DISCOVERY_DATA, ) + @callback + def _scan_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: + """Contribute the Yellow's built-in Zigbee radio port.""" + return [ + SerialDevice( + device=RADIO_DEVICE, + serial_number=None, + manufacturer=MANUFACTURER, + description="Yellow Zigbee Radio", + ) + ] + + entry.async_on_unload(async_register_serial_port_scanner(hass, _scan_serial_ports)) + # Create and store the firmware update coordinator in runtime_data session = async_get_clientsession(hass) coordinator = FirmwareUpdateCoordinator( @@ -113,9 +148,11 @@ async def async_migrate_entry( if config_entry.version == 1: if config_entry.minor_version == 1: - # Add-on startup with type service get started before Core, always (e.g. the - # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, - # so we can't safely probe here. Instead, we must make an educated guess! + # Add-on startup with type service get started before + # Core, always (e.g. the Multi-Protocol add-on). + # Probing the firmware would interfere with the + # add-on, so we can't safely probe here. Instead, + # we must make an educated guess! firmware_guess = await guess_firmware_info(hass, RADIO_DEVICE) new_data = {**config_entry.data} diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index acbdb1f1a58..464d79e5199 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Home Assistant Yellow integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio import logging @@ -321,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler( """Return the name of the hardware.""" return BOARD_NAME + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + return NABU_CASA_FIRMWARE_RELEASES_URL + + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier.""" + return "yellow_zigbee_ncp" + + @property + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + return YellowFlasher # type: ignore[no-any-return] + async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py index 0772b27f936..427f8224204 100644 --- a/homeassistant/components/homeassistant_yellow/hardware.py +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -1,7 +1,5 @@ """The Home Assistant Yellow hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import BoardInfo, HardwareInfo from homeassistant.components.hassio import get_os_info from homeassistant.core import HomeAssistant, callback @@ -18,8 +16,7 @@ MODEL = "yellow" @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" - if (os_info := get_os_info(hass)) is None: - raise HomeAssistantError + os_info = get_os_info(hass) board: str | None if (board := os_info.get("board")) is None: raise HomeAssistantError diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json index 31f5b163f92..9c69c2ce863 100644 --- a/homeassistant/components/homeassistant_yellow/manifest.json +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "homeassistant_hardware"], + "dependencies": ["hardware", "homeassistant_hardware", "usb"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", "integration_type": "hardware", "loggers": [ diff --git a/homeassistant/components/homeassistant_yellow/repairs.py b/homeassistant/components/homeassistant_yellow/repairs.py new file mode 100644 index 00000000000..6e14d462f9d --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/repairs.py @@ -0,0 +1,48 @@ +"""Repairs for the Home Assistant Yellow integration.""" + +from typing import cast + +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, + MultiPanMigrationRepairFlow, +) +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler + + +class YellowMultiPanMigrationRepairFlow( + MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler +): + """Multi-PAN migration repair flow for Home Assistant Yellow.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the repair flow.""" + HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry) + self._repair_config_entry = config_entry + + async def async_step_main_menu( # type: ignore[override] + self, _: None = None + ) -> RepairsFlowResult: + """Jump straight into the uninstall step.""" + return await self._async_step_start_migration() + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a Yellow repair issue.""" + if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None: + entry_id = cast(str, data["entry_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + return YellowMultiPanMigrationRepairFlow(hass, entry) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index aacf51da97d..de451b7b125 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,37 @@ } } }, + "issues": { + "multi_pan_migration": { + "fix_flow": { + "abort": { + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" + }, + "step": { + "uninstall_addon": { + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + }, + "description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + } + }, + "title": "Multiprotocol support is deprecated" + } + }, "options": { "abort": { "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", @@ -37,8 +68,10 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" }, "step": { "addon_installed_other_device": { diff --git a/homeassistant/components/homeassistant_yellow/switch.py b/homeassistant/components/homeassistant_yellow/switch.py index 3e4cf01c370..21ac69e67a2 100644 --- a/homeassistant/components/homeassistant_yellow/switch.py +++ b/homeassistant/components/homeassistant_yellow/switch.py @@ -1,7 +1,5 @@ """Home Assistant Yellow switch entities.""" -from __future__ import annotations - import logging from homeassistant.components.homeassistant_hardware.coordinator import ( diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 9b0ad5807ab..7e4b359f26b 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -1,7 +1,5 @@ """Home Assistant Yellow firmware update entity.""" -from __future__ import annotations - import logging from universal_silabs_flasher.flasher import YellowFlasher @@ -188,7 +186,10 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): device_registry = dr.async_get(self.hass) device_registry.async_update_device( device_id=self.device_entry.id, - sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + sw_version=( + f"{self.entity_description.firmware_name}" + f" {self._attr_installed_version}" + ), ) @callback diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 6f8f1968c91..c6ba60fd4a4 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -54,10 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo try: await homee.get_access_token() except HomeeConnectionFailedException as exc: - raise ConfigEntryNotReady(f"Connection to Homee failed: {exc.reason}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_failed", + ) from exc except HomeeAuthFailedException as exc: raise ConfigEntryAuthFailed( - f"Authentication to Homee failed: {exc.reason}" + translation_domain=DOMAIN, + translation_key="auth_failed", ) from exc hass.loop.create_task(homee.run()) diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py index 74aa6e36884..d6f6f85b865 100644 --- a/homeassistant/components/homee/alarm_control_panel.py +++ b/homeassistant/components/homee/alarm_control_panel.py @@ -33,7 +33,6 @@ class HomeeAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription ALARM_DESCRIPTIONS = { AttributeType.HOMEE_MODE: HomeeAlarmControlPanelEntityDescription( key="homee_mode", - code_arm_required=False, state_list=[ AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMED_NIGHT, diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py index 0aa3467f760..e4ff2ce8f06 100644 --- a/homeassistant/components/homee/climate.py +++ b/homeassistant/components/homee/climate.py @@ -200,7 +200,7 @@ class HomeeClimate(HomeeNodeEntity, ClimateEntity): def get_climate_features( node: HomeeNode, ) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: - """Determine supported climate features of a node based on the available attributes.""" + """Determine supported climate features of a node.""" features = ClimateEntityFeature.TARGET_TEMPERATURE hvac_modes = [HVACMode.HEAT] preset_modes: list[str] = [] diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 87b23e1bd65..9d0991f42dd 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -122,7 +122,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): existing_entry = await self.async_set_unique_id(self._name) if ( existing_entry - and existing_entry.state == ConfigEntryState.LOADED + and existing_entry.state is ConfigEntryState.LOADED and existing_entry.runtime_data.connected and existing_entry.data[CONF_HOST] != self._host ): diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index b48d965512e..1a7008f432d 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -1,7 +1,8 @@ """The homee cover platform.""" +from enum import Enum import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from pyHomee.const import AttributeType, NodeProfile from pyHomee.model import HomeeAttribute, HomeeNode @@ -24,12 +25,35 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -OPEN_CLOSE_ATTRIBUTES = [ +COVER_DEVICE_PROFILES = { + NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, + NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE, + NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, +} +IS_CLOSED_ATTRIBUTES = [ AttributeType.OPEN_CLOSE, - AttributeType.SLAT_ROTATION_IMPULSE, AttributeType.UP_DOWN, + AttributeType.POSITION, + AttributeType.SHUTTER_SLAT_POSITION, ] -POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] + + +class HomeeCoverState(float, Enum): + """Open/closed states for covers in homee.""" + + OPEN = 0.0 + CLOSED = 1.0 + STOPPED = 2.0 + OPENING = 3.0 + CLOSING = 4.0 + + +class HomeeSlatState(float, Enum): + """Slat states for covers in homee.""" + + STOPPED = 0.0 + CLOSED = 1.0 + OPEN = 2.0 def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: @@ -44,7 +68,7 @@ def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: def get_cover_features( node: HomeeNode, open_close_attribute: HomeeAttribute | None ) -> CoverEntityFeature: - """Determine the supported cover features of a homee node based on the available attributes.""" + """Determine the supported cover features of a homee node.""" features = CoverEntityFeature(0) if (open_close_attribute is not None) and open_close_attribute.editable: @@ -59,7 +83,11 @@ def get_cover_features( features |= CoverEntityFeature.SET_POSITION if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None: - features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT + features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + ) if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None: features |= CoverEntityFeature.SET_TILT_POSITION @@ -69,12 +97,6 @@ def get_cover_features( def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: """Determine the device class a homee node based on the node profile.""" - COVER_DEVICE_PROFILES = { - NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, - NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE, - NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, - } - return COVER_DEVICE_PROFILES.get(node.profile) @@ -84,9 +106,23 @@ async def add_cover_entities( nodes: list[HomeeNode], ) -> None: """Add homee cover entities.""" - async_add_entities( - HomeeCover(node, config_entry) for node in nodes if is_cover_node(node) - ) + entities: list[HomeeNode] = [] + for node in nodes: + if is_cover_node(node): + if any( + node.get_attribute_by_type(attr) is not None + for attr in IS_CLOSED_ATTRIBUTES + ): + entities.append(node) + else: + _LOGGER.warning( + "Cover %s could not be added, because it is missing an Attribute " + "for closed indication. Please open an issue at " + "https://github.com/home-assistant/core/issues", + node.name, + ) + + async_add_entities(HomeeCover(cover, config_entry) for cover in entities) async def async_setup_entry( @@ -100,7 +136,7 @@ async def async_setup_entry( def is_cover_node(node: HomeeNode) -> bool: - """Determine if a node is controllable as a homee cover based on its profile and attributes.""" + """Determine if a node is controllable as a homee cover.""" return node.profile in [ NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, @@ -168,9 +204,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): """Return the opening status of the cover.""" if self._open_close_attribute is not None: return ( - self._open_close_attribute.get_value() == 3 + self._open_close_attribute.get_value() == HomeeCoverState.OPENING if not self._open_close_attribute.is_reversed - else self._open_close_attribute.get_value() == 4 + else self._open_close_attribute.get_value() == HomeeCoverState.CLOSING ) return None @@ -180,15 +216,15 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): """Return the closing status of the cover.""" if self._open_close_attribute is not None: return ( - self._open_close_attribute.get_value() == 4 + self._open_close_attribute.get_value() == HomeeCoverState.CLOSING if not self._open_close_attribute.is_reversed - else self._open_close_attribute.get_value() == 3 + else self._open_close_attribute.get_value() == HomeeCoverState.OPENING ) return None @property - def is_closed(self) -> bool | None: + def is_closed(self) -> bool: """Return if the cover is closed.""" if ( attribute := self._node.get_attribute_by_type(AttributeType.POSITION) @@ -197,35 +233,44 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): if self._open_close_attribute is not None: if not self._open_close_attribute.is_reversed: - return self._open_close_attribute.get_value() == 1 + return self._open_close_attribute.get_value() == HomeeCoverState.CLOSED - return self._open_close_attribute.get_value() == 0 + return self._open_close_attribute.get_value() == HomeeCoverState.OPEN - # If none of the above is present, it might be a slat only cover. - if ( - attribute := self._node.get_attribute_by_type( - AttributeType.SHUTTER_SLAT_POSITION - ) - ) is not None: - return attribute.get_value() == attribute.minimum + # If none of the above is present, it will be a slat only cover. + attribute = self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + if TYPE_CHECKING: + # This case should not happen, because we check for + # the presence of an IS_CLOSED_ATTRIBUTE when adding entities. + assert attribute is not None - return None + return attribute.get_value() == attribute.minimum async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_homee_value(self._open_close_attribute, 0) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.OPEN + ) else: - await self.async_set_homee_value(self._open_close_attribute, 1) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.CLOSED + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_homee_value(self._open_close_attribute, 1) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.CLOSED + ) else: - await self.async_set_homee_value(self._open_close_attribute, 0) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.OPEN + ) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -245,7 +290,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" if self._open_close_attribute is not None: - await self.async_set_homee_value(self._open_close_attribute, 2) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.STOPPED + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -255,9 +302,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_homee_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN) else: - await self.async_set_homee_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -267,9 +314,18 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_homee_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED) else: - await self.async_set_homee_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + if ( + slat_attribute := self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + ) is not None: + await self.async_set_homee_value(slat_attribute, HomeeSlatState.STOPPED) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 83b5d5c56fc..4ac2ff6c737 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -39,6 +39,9 @@ } }, "switch": { + "homeegram": { + "default": "mdi:robot" + }, "manual_operation": { "default": "mdi:hand-back-left" }, diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index 3fbfcbeba22..b7ea2952394 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -37,7 +37,7 @@ PARALLEL_UPDATES = 0 def is_light_node(node: HomeeNode) -> bool: - """Determine if a node is controllable as a homee light based on its profile and attributes.""" + """Determine if a node is controllable as a homee light.""" assert node.attribute_map is not None return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py index f061e2eefae..724851abe13 100644 --- a/homeassistant/components/homee/lock.py +++ b/homeassistant/components/homee/lock.py @@ -1,11 +1,11 @@ """The Homee lock platform.""" -from typing import Any +from typing import TYPE_CHECKING, Any from pyHomee.const import AttributeChangedBy, AttributeType -from pyHomee.model import HomeeNode +from pyHomee.model import HomeeAttribute, HomeeNode -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -15,6 +15,24 @@ from .helpers import get_name_for_enum, setup_homee_platform PARALLEL_UPDATES = 0 +LOCK_STATE_UNLOCKED = 0.0 +LOCK_STATE_LOCKED = 1.0 + + +def _determine_lock_state_open(attribute: HomeeAttribute) -> float | None: + """Return the attribute value that momentarily unlatches the lock. + + Different homee-compatible locks encode the "open" (unlatch) command + differently. The Hörmann SmartKey uses a signed range {-1, 0, 1} + where -1 is unlatch; other devices extend above with {0, 1, 2}. + Returns None when the device only supports two states. + """ + if attribute.maximum == 2.0: + return 2.0 + if attribute.minimum == -1.0: + return -1.0 + return None + async def add_lock_entities( config_entry: HomeeConfigEntry, @@ -45,20 +63,53 @@ class HomeeLock(HomeeEntity, LockEntity): _attr_name = None + def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + """Initialize the homee lock.""" + super().__init__(attribute, entry) + self._lock_state_open = _determine_lock_state_open(attribute) + if self._lock_state_open is not None: + self._attr_supported_features = LockEntityFeature.OPEN + @property def is_locked(self) -> bool: """Return if lock is locked.""" - return self._attribute.current_value == 1.0 + return self._attribute.current_value == LOCK_STATE_LOCKED + + @property + def is_open(self) -> bool: + """Return if lock is open (unlatched).""" + # Require target_value too, so mid-transition away from "open" resolves + # to is_locking/is_unlocking rather than OPEN (HA state precedence). + return ( + self._lock_state_open is not None + and self._attribute.current_value == self._lock_state_open + and self._attribute.target_value == self._lock_state_open + ) @property def is_locking(self) -> bool: """Return if lock is locking.""" - return self._attribute.target_value > self._attribute.current_value + return ( + self._attribute.target_value == LOCK_STATE_LOCKED + and self._attribute.current_value != LOCK_STATE_LOCKED + ) @property def is_unlocking(self) -> bool: """Return if lock is unlocking.""" - return self._attribute.target_value < self._attribute.current_value + return ( + self._attribute.target_value == LOCK_STATE_UNLOCKED + and self._attribute.current_value != LOCK_STATE_UNLOCKED + ) + + @property + def is_opening(self) -> bool: + """Return if lock is opening (unlatching).""" + return ( + self._lock_state_open is not None + and self._attribute.target_value == self._lock_state_open + and self._attribute.current_value != self._lock_state_open + ) @property def changed_by(self) -> str: @@ -80,8 +131,14 @@ class HomeeLock(HomeeEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock specified lock. A code to lock the lock with may be specified.""" - await self.async_set_homee_value(1) + await self.async_set_homee_value(LOCK_STATE_LOCKED) async def async_unlock(self, **kwargs: Any) -> None: """Unlock specified lock. A code to unlock the lock with may be specified.""" - await self.async_set_homee_value(0) + await self.async_set_homee_value(LOCK_STATE_UNLOCKED) + + async def async_open(self, **kwargs: Any) -> None: + """Open (unlatch) the lock.""" + if TYPE_CHECKING: + assert self._lock_state_open is not None + await self.async_set_homee_value(self._lock_state_open) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index d18344d711b..b873607280a 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "silver", - "requirements": ["pyHomee==1.3.8"], + "requirements": ["pyHomee==1.4.0"], "zeroconf": [ { "name": "homee-*", diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index bf86abb7938..07a2092ebb8 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -44,13 +44,13 @@ rules: # Gold devices: done - diagnostics: todo - discovery-update-info: todo - discovery: todo + diagnostics: done + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: todo docs-troubleshooting: done docs-use-cases: todo @@ -62,9 +62,11 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: | + The integration currently does not have any known issues. stale-devices: done - # Platinum async-dependency: todo inject-websession: todo diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 4bb1339ddff..b74e62b9f48 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -487,12 +487,21 @@ } }, "exceptions": { + "auth_failed": { + "message": "Authentication to homee failed." + }, "connection_closed": { "message": "Could not connect to homee while setting attribute." }, + "connection_failed": { + "message": "Connection to homee failed." + }, "disarm_not_supported": { "message": "Disarm is not supported by homee." }, + "homeegram_turn_off_not_supported": { + "message": "Turning off homeegrams is not supported." + }, "invalid_preset_mode": { "message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'." } diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index b620cb55c26..c7ebd48528e 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -6,6 +6,7 @@ from typing import Any from pyHomee.const import AttributeType, NodeProfile from pyHomee.model import HomeeAttribute, HomeeNode +from pyHomee.model_homeegram import HomeeGram from homeassistant.components.switch import ( SwitchDeviceClass, @@ -14,9 +15,11 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import HomeeConfigEntry +from . import DOMAIN, HomeeConfigEntry from .const import CLIMATE_PROFILES, LIGHT_PROFILES from .entity import HomeeEntity from .helpers import setup_homee_platform @@ -95,6 +98,10 @@ async def async_setup_entry( """Set up the switch platform for the Homee component.""" await setup_homee_platform(add_switch_entities, async_add_entities, config_entry) + async_add_entities( + HomeegramSwitch(homeegram, config_entry) + for homeegram in config_entry.runtime_data.homeegrams + ) class HomeeSwitch(HomeeEntity, SwitchEntity): @@ -137,3 +144,75 @@ class HomeeSwitch(HomeeEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.async_set_homee_value(0) + + +class HomeegramSwitch(SwitchEntity): + """Representation of a Homeegram as switch.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, homeegram: HomeeGram, entry: HomeeConfigEntry) -> None: + """Initialize a homee Homeegram switch entity.""" + self._homeegram = homeegram + self._entry = entry + self._attr_unique_id = f"{entry.unique_id}-hg-{homeegram.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}-homeegrams")}, + name="Homeegrams", + model="Homeegram Switches", + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) + self._attr_translation_key = "homeegram" + self._host_connected = entry.runtime_data.connected + self._attr_name = homeegram.name + + self._attr_entity_registry_enabled_default = self._is_enabled_by_default( + homeegram + ) + + async def async_added_to_hass(self) -> None: + """Add the Homeegram entity to home assistant.""" + self.async_on_remove( + self._homeegram.add_on_changed_listener(self._on_homeegram_updated) + ) + self.async_on_remove( + self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def is_on(self) -> bool: + """Return True if homeegram is executing.""" + return bool(self._homeegram.play) + + @property + def available(self) -> bool: + """Return the availability of the homeegram based on host availability.""" + return bool(self._homeegram.active) and self._host_connected + + async def async_turn_on(self, **kwargs: Any) -> None: + """Trigger Homeegram on switching on.""" + await self._entry.runtime_data.play_homeegram(self._homeegram.id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turning off homeegrams is not supported.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="homeegram_turn_off_not_supported", + ) + + def _on_homeegram_updated(self, homeegram: HomeeGram) -> None: + self.async_write_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.async_write_ha_state() + + def _is_enabled_by_default(self, homeegram: HomeeGram) -> bool: + """Return if the homeegram should be enabled by default.""" + # Only enable homeegram switches by default if there is more than 1 homeegram action. + return ( + sum(len(action_list) for action_list in homeegram.actions.data.values()) > 1 + ) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1af5b86b5a1..0626071eeaf 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,7 +1,5 @@ """Support for Apple HomeKit.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Iterable @@ -27,7 +25,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.device_automation.trigger import ( # pylint: disable=hass-component-root-import +from homeassistant.components.device_automation.trigger import ( # pylint: disable=home-assistant-component-root-import async_validate_trigger_config, ) from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass @@ -934,7 +932,7 @@ class HomeKit: @callback def _async_register_bridge(self) -> None: - """Register the bridge as a device so homekit_controller and exclude it from discovery.""" + """Register bridge as device for homekit_controller exclusion.""" assert self.driver is not None dev_reg = dr.async_get(self.hass) formatted_mac = dr.format_mac(self.driver.state.mac) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 06fc0a1c493..1d260598c93 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,7 +1,5 @@ """Extend the basic Accessory and Bridge functions.""" -from __future__ import annotations - import logging from typing import Any, cast from uuid import UUID @@ -204,7 +202,10 @@ def get_accessory( # noqa: C901 if device_class == MediaPlayerDeviceClass.RECEIVER: a_type = "ReceiverMediaPlayer" - elif device_class == MediaPlayerDeviceClass.TV: + elif device_class in ( + MediaPlayerDeviceClass.TV, + MediaPlayerDeviceClass.PROJECTOR, + ): a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -529,7 +530,8 @@ class HomeAccessory(Accessory): # type: ignore[misc] for attr in self._reload_on_change_attrs: if old_attributes.get(attr) != new_attributes.get(attr): _LOGGER.debug( - "%s: Reloading HomeKit accessory since %s has changed from %s -> %s", + "%s: Reloading HomeKit accessory since" + " %s has changed from %s -> %s", self.entity_id, attr, old_attributes.get(attr), @@ -652,7 +654,10 @@ class HomeAccessory(Accessory): # type: ignore[misc] @ha_callback def async_reload(self) -> None: - """Reload and recreate an accessory and update the c# value in the mDNS record.""" + """Reload and recreate an accessory. + + Update the c# value in the mDNS record. + """ async_dispatcher_send( self.hass, SIGNAL_RELOAD_ENTITIES.format(self.driver.entry_id), diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index f755f6f901f..c76232f65f9 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -9,8 +9,6 @@ can't change the hash without causing breakages for HA users. This module generates and stores them in a HA storage. """ -from __future__ import annotations - from collections.abc import Generator import random diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 0ef2e8563bc..308f55f7b23 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,7 +1,5 @@ """Config flow for HomeKit integration.""" -from __future__ import annotations - from collections.abc import Iterable from copy import deepcopy from operator import itemgetter @@ -386,18 +384,17 @@ class OptionsFlowHandler(OptionsFlow): return self.async_show_form(step_id="yaml") - async def async_step_advanced( + async def async_step_bridged_device_triggers( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Choose advanced options.""" + """Choose bridged device triggers options.""" hk_options = self.hk_options - show_advanced_options = self.show_advanced_options bridge_mode = hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE - if not show_advanced_options or user_input is not None or not bridge_mode: + if user_input is not None or not bridge_mode: if user_input: hk_options.update(user_input) - if show_advanced_options and bridge_mode: + if bridge_mode: hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] hk_options.pop(CONF_DOMAINS, None) @@ -413,7 +410,7 @@ class OptionsFlowHandler(OptionsFlow): if device_id in all_supported_devices ] return self.async_show_form( - step_id="advanced", + step_id="bridged_device_triggers", data_schema=vol.Schema( { vol.Optional(CONF_DEVICES, default=devices): cv.multi_select( @@ -448,7 +445,7 @@ class OptionsFlowHandler(OptionsFlow): if not entity_config: all_entity_config.pop(entity_id) - return await self.async_step_advanced() + return await self.async_step_bridged_device_triggers() cameras_with_audio = [] cameras_with_copy = [] @@ -497,7 +494,7 @@ class OptionsFlowHandler(OptionsFlow): hk_options[CONF_FILTER] = entity_filter if self.included_cameras: return await self.async_step_cameras() - return await self.async_step_advanced() + return await self.async_step_bridged_device_triggers() entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) @@ -541,7 +538,7 @@ class OptionsFlowHandler(OptionsFlow): hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) if self.included_cameras: return await self.async_step_cameras() - return await self.async_step_advanced() + return await self.async_step_bridged_device_triggers() entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) @@ -596,7 +593,7 @@ class OptionsFlowHandler(OptionsFlow): ) if self.included_cameras: return await self.async_step_cameras() - return await self.async_step_advanced() + return await self.async_step_bridged_device_triggers() entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 2d4e2b03079..5d5a8efc0e2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,7 +1,5 @@ """Constants used be the HomeKit component.""" -from __future__ import annotations - from homeassistant.const import CONF_DEVICES from homeassistant.util.signal_type import SignalTypeFormat diff --git a/homeassistant/components/homekit/diagnostics.py b/homeassistant/components/homekit/diagnostics.py index eb062735ad0..9304fcf72c4 100644 --- a/homeassistant/components/homekit/diagnostics.py +++ b/homeassistant/components/homekit/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for HomeKit.""" -from __future__ import annotations - from typing import Any from pyhap.accessory_driver import AccessoryDriver diff --git a/homeassistant/components/homekit/doorbell.py b/homeassistant/components/homekit/doorbell.py index 9857cf83b36..ca7630ed1a9 100644 --- a/homeassistant/components/homekit/doorbell.py +++ b/homeassistant/components/homekit/doorbell.py @@ -1,7 +1,5 @@ """Extend the doorbell functions.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index a477dde9c9d..3e3508ae039 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -6,8 +6,6 @@ be stable between reboots and upgrades. This module generates and stores them in a HA storage. """ -from __future__ import annotations - from uuid import UUID from pyhap.util import uuid_to_hap_type diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 7748f86b9ac..f4c5fc9d191 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==5.0.0", - "fnv-hash-fast==2.0.0", + "fnv-hash-fast==2.0.3", "homekit-audio-proxy==1.2.1", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/models.py b/homeassistant/components/homekit/models.py index 9b647928fdd..65db8b4d0a6 100644 --- a/homeassistant/components/homekit/models.py +++ b/homeassistant/components/homekit/models.py @@ -1,7 +1,5 @@ """Models for the HomeKit component.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 17835c1003b..3bb8625cba5 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -25,12 +25,12 @@ }, "title": "Select the entity for the accessory" }, - "advanced": { + "bridged_device_triggers": { "data": { "devices": "Devices (Triggers)" }, "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", - "title": "Advanced configuration" + "title": "Bridged device triggers" }, "cameras": { "data": { @@ -60,7 +60,7 @@ "include_exclude_mode": "Inclusion mode", "mode": "HomeKit mode" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "yaml": { diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py index 62e07d3a25b..2dfdfe66720 100644 --- a/homeassistant/components/homekit/type_air_purifiers.py +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -378,7 +378,7 @@ class AirPurifier(Fan): @callback def _async_update_filter_change_indicator(self, new_state: State | None) -> None: - """Handle linked filter change indicator binary sensor state change to update HomeKit value.""" + """Handle filter change indicator state change.""" if new_state is None or new_state.state in IGNORED_STATES: return @@ -408,7 +408,7 @@ class AirPurifier(Fan): @callback def _async_update_filter_life_level(self, new_state: State | None) -> None: - """Handle linked filter life level sensor state change to update HomeKit value.""" + """Handle filter life level sensor state change.""" if new_state is None or new_state.state in IGNORED_STATES: return diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 87802bf1661..480357bd227 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -141,7 +141,7 @@ CONFIG_DEFAULTS = { @TYPES.register("Camera") # False-positive on pylint, not a CameraEntity -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] """Generate a Camera accessory.""" diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 5c91dd0c3bb..0938f64a554 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -96,9 +96,10 @@ class Fan(HomeAccessory): ) if CHAR_ROTATION_SPEED in self.chars: - # Initial value is set to 100 because 0 is a special value (off). 100 is - # an arbitrary non-zero value. It is updated immediately by async_update_state - # to set to the correct initial value. + # Initial value is set to 100 because 0 is a special + # value (off). 100 is an arbitrary non-zero value. It + # is updated immediately by async_update_state to set + # to the correct initial value. self.char_speed = serv_fan.configure_char( CHAR_ROTATION_SPEED, value=100, diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 2cdd031cbfa..3b4e4f72d92 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -267,8 +267,9 @@ class HumidifierDehumidifier(HomeAccessory): if (humidity < min_humidity) or (humidity > max_humidity): humidity = min(max_humidity, max(min_humidity, humidity)) - # Update the HomeKit value to the clamped humidity, so the user will get a visual feedback that they - # cannot not set to a value below/above the min/max. + # Update the HomeKit value to the clamped humidity, + # so the user will get visual feedback that they + # cannot set to a value below/above the min/max. self.char_target_humidity.set_value(humidity) self.async_call_service( @@ -285,10 +286,10 @@ class HumidifierDehumidifier(HomeAccessory): """Return min and max humidity range.""" attributes = state.attributes min_humidity = max( - int(round(attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY))), 0 + round(attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY)), 0 ) max_humidity = min( - int(round(attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY))), 100 + round(attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY)), 100 ) return min_humidity, max_humidity diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 212b3228154..a1cc276f929 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -1,7 +1,5 @@ """Class to hold all light accessories.""" -from __future__ import annotations - from datetime import datetime import logging from typing import Any @@ -115,9 +113,10 @@ class Light(HomeAccessory): self.char_on = serv_light.configure_char(CHAR_ON, value=0) if self.brightness_supported: - # Initial value is set to 100 because 0 is a special value (off). 100 is - # an arbitrary non-zero value. It is updated immediately by async_update_state - # to set to the correct initial value. + # Initial value is set to 100 because 0 is a special + # value (off). 100 is an arbitrary non-zero value. It + # is updated immediately by async_update_state to set + # to the correct initial value. self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 9fef970d560..8be2d428321 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -1,7 +1,5 @@ """Class to hold all sensor accessories.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, NamedTuple diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 8a1d9e33051..131b0e2863b 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,7 +1,5 @@ """Class to hold all switch accessories.""" -from __future__ import annotations - import logging from typing import Any, Final, NamedTuple @@ -351,9 +349,13 @@ class ValveBase(HomeAccessory): CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration, properties={ - # Default remaining time maxValue to 48 hours if not set via linked default duration. - # pyhap truncates the remaining time to maxValue of the characteristic (pyhap default is 1 hour). - # This can potentially show a remaining duration that is lower than the actual remaining duration. + # Default remaining time maxValue to 48 hours + # if not set via linked default duration. + # pyhap truncates the remaining time to + # maxValue of the characteristic (pyhap + # default is 1 hour). This can potentially + # show a remaining duration that is lower + # than the actual remaining duration. PROP_MAX_VALUE: self._get_linked_duration_property( INPUT_NUMBER_CONF_MAX, VALVE_REMAINING_TIME_MAX_DEFAULT ), diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 783a66ea261..c68dc1aea48 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -483,8 +483,9 @@ class Thermostat(HomeAccessory): f" {char_values[CHAR_TARGET_HEATING_COOLING]}" ) # Many integrations do not actually implement `hvac_mode` for the - # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we made a call to - # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` + # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we + # made a call to `SERVICE_SET_HVAC_MODE_THERMOSTAT` + # before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` # to ensure the device is in the right mode before setting the temp. self.async_call_service( CLIMATE_DOMAIN, diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index 86b2019e97e..ed92e737bfa 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -1,7 +1,5 @@ """Class to hold all sensor accessories.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 242422b6f95..97269181d58 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -1,7 +1,5 @@ """Collection of useful functions for the HomeKit component.""" -from __future__ import annotations - import io import ipaddress import logging @@ -535,7 +533,8 @@ def density_to_air_quality_nitrogen_dioxide(density: float) -> int: def density_to_air_quality_voc(density: float) -> int: """Map VOCs μg/m3 to HomeKit AirQuality level. - The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization). + The VOC mappings use the IAQ guidelines for Europe released + by the WHO (World Health Organization). Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf https://github.com/paulvha/svm30/blob/master/extras/Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf """ @@ -625,10 +624,13 @@ def _get_test_socket() -> socket.socket: @callback def async_port_is_available(port: int) -> bool: """Check to see if a port is available.""" + test_socket = _get_test_socket() try: - _get_test_socket().bind(("", port)) + test_socket.bind(("", port)) except OSError: return False + finally: + test_socket.close() return True @@ -686,14 +688,18 @@ def accessory_friendly_name(hass_name: str, accessory: Accessory) -> str: def state_needs_accessory_mode(state: State) -> bool: - """Return if the entity represented by the state must be paired in accessory mode.""" + """Return if the entity state must be paired in accessory mode.""" if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN): return True return ( state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) - in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) + in ( + MediaPlayerDeviceClass.TV, + MediaPlayerDeviceClass.RECEIVER, + MediaPlayerDeviceClass.PROJECTOR, + ) ) or ( state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 639cec6dcb5..1fca676571b 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,7 +1,5 @@ """Support for Homekit device discovery.""" -from __future__ import annotations - import asyncio import contextlib import logging diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a0342203e4a..5d5eed0c37b 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Homekit Alarm Control Panel.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 1c80da3cc9c..ff7374544eb 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Homekit motion sensors.""" -from __future__ import annotations - from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index 730b3c8425d..a099b2e4bee 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -4,8 +4,6 @@ These are mostly used where a HomeKit accessory exposes additional non-standard characteristics that don't map to a Home Assistant feature. """ -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index 36bf30e5bab..b20d24d8c56 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -1,7 +1,5 @@ """Support for Homekit cameras.""" -from __future__ import annotations - from aiohomekit.model import Accessory from aiohomekit.model.services import ServicesTypes diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 45208b03394..1aa5e052e67 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -1,7 +1,5 @@ """Support for Homekit climate devices.""" -from __future__ import annotations - import logging from typing import Any, Final @@ -287,9 +285,9 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): await self.async_put_characteristics( { CharacteristicsTypes.ACTIVE: ActivationStateValues.ACTIVE, - CharacteristicsTypes.TARGET_HEATER_COOLER_STATE: TARGET_HEATER_COOLER_STATE_HASS_TO_HOMEKIT[ - hvac_mode - ], + CharacteristicsTypes.TARGET_HEATER_COOLER_STATE: ( + TARGET_HEATER_COOLER_STATE_HASS_TO_HOMEKIT[hvac_mode] + ), } ) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 3b15e69b149..1a7b07ee1c4 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure homekit_controller.""" -from __future__ import annotations - import logging import re from typing import TYPE_CHECKING, Any, Self, cast @@ -164,7 +162,8 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required("device"): vol.In( { key: ( - f"{key} ({formatted_category(discovery.description.category)})" + f"{key} (" + f"{formatted_category(discovery.description.category)})" ) for key, discovery in self.devices.items() } diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 6a6252b434c..5cf378ba7e7 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -1,7 +1,5 @@ """Helpers for managing a pairing with a HomeKit accessory or bridge.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Iterable from datetime import datetime, timedelta @@ -247,12 +245,13 @@ class HKDevice: @callback def async_set_available_state(self, available: bool) -> None: - """Mark state of all entities on this connection when it becomes available or unavailable.""" + """Mark state of all entities on this connection.""" _LOGGER.debug( "Called async_set_available_state with %s for %s", available, self.unique_id ) - # Don't mark entities as unavailable during shutdown to preserve their last known state - # Also skip if the availability state hasn't changed + # Don't mark entities as unavailable during shutdown to + # preserve their last known state. Also skip if the + # availability state hasn't changed. if (self.hass.is_stopping and not available) or self.available == available: return self.available = available @@ -299,7 +298,7 @@ class HKDevice: # yet. attempts = None if self.hass.state is CoreState.running else 1 if ( - transport == Transport.BLE + transport is Transport.BLE and pairing.accessories and pairing.accessories.has_aid(1) ): @@ -329,26 +328,28 @@ class HKDevice: ) entry.async_on_unload(self._async_cancel_subscription_timer) - if transport != Transport.BLE: + if transport is not Transport.BLE: # Although async_populate_accessories_state fetched the accessory database, # the /accessories endpoint may return cached values from the accessory's # perspective. For example, Ecobee thermostats may report stale temperature # values (like 100°C) in their /accessories response after restarting. # We need to explicitly poll characteristics to get fresh sensor readings # before processing the entity map and creating devices. - # Use poll_all=True since entities haven't registered their characteristics yet. + # Use poll_all=True since entities haven't registered + # their characteristics yet. try: await self.async_update(poll_all=True) except ValueError as exc: _LOGGER.debug( - "Accessory %s responded with unparsable response, first update was skipped: %s", + "Accessory %s responded with unparsable" + " response, first update was skipped: %s", self.unique_id, exc, ) await self.async_process_entity_map() - if transport != Transport.BLE: + if transport is not Transport.BLE: # Start regular polling after entity map is processed self._async_start_polling() @@ -358,7 +359,7 @@ class HKDevice: self.async_set_available_state(self.pairing.is_available) - if transport == Transport.BLE: + if transport is Transport.BLE: # If we are using BLE, we need to periodically check of the # BLE device is available since we won't get callbacks # when it goes away since we HomeKit supports disconnected @@ -419,7 +420,7 @@ class HKDevice: identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) connections: set[tuple[str, str]] = set() - if self.pairing.transport == Transport.BLE and ( + if self.pairing.transport is Transport.BLE and ( discovery := self.pairing.controller.discoveries.get( normalize_hkid(self.unique_id) ) @@ -593,7 +594,7 @@ class HKDevice: @callback def async_reap_stale_entity_registry_entries(self) -> None: - """Delete entity registry entities for removed characteristics, services and accessories.""" + """Delete entity registry entities for removed characteristics.""" _LOGGER.debug( "Removing stale entity registry entries for pairing %s", self.unique_id, @@ -621,7 +622,7 @@ class HKDevice: current_unique_id.add((accessory.aid, service.iid, None)) for char in service.characteristics: - if self.pairing.transport != Transport.BLE: + if self.pairing.transport is not Transport.BLE: if char.type == CharacteristicsTypes.THREAD_CONTROL_POINT: continue @@ -645,7 +646,7 @@ class HKDevice: @callback def async_migrate_ble_unique_id(self) -> None: - """Config entries from step_bluetooth used incorrect identifier for unique_id.""" + """Config entries from step_bluetooth used wrong unique_id.""" unique_id = normalize_hkid(self.unique_id) if unique_id != self.config_entry.unique_id: _LOGGER.debug( @@ -729,12 +730,15 @@ class HKDevice: async def async_process_entity_map(self) -> None: """Process the entity map and load any platforms or entities that need adding. - This is idempotent and will be called at startup and when we detect metadata changes - via the c# counter on the zeroconf record. + This is idempotent and will be called at startup and when + we detect metadata changes via the c# counter on the + zeroconf record. """ - # Ensure the Pairing object has access to the latest version of the entity map. This - # is especially important for BLE, as the Pairing instance relies on the entity map - # to map aid/iid to GATT characteristics. So push it to there as well. + # Ensure the Pairing object has access to the latest + # version of the entity map. This is especially important + # for BLE, as the Pairing instance relies on the entity + # map to map aid/iid to GATT characteristics. So push it + # to there as well. self.async_detect_workarounds() # Migrate to new device ids @@ -1053,7 +1057,7 @@ class HKDevice: @property def is_unprovisioned_thread_device(self) -> bool: """Is this a thread capable device not connected by CoAP.""" - if self.pairing.controller.transport_type != TransportType.BLE: + if self.pairing.controller.transport_type is not TransportType.BLE: return False if not self.entity_map.aid(1).services.first( @@ -1065,7 +1069,7 @@ class HKDevice: async def async_thread_provision(self) -> None: """Migrate a HomeKit pairing to CoAP (Thread).""" - if self.pairing.controller.transport_type == TransportType.COAP: + if self.pairing.controller.transport_type is TransportType.COAP: raise HomeAssistantError("Already connected to a thread network") if not (dataset := await async_get_preferred_dataset(self.hass)): @@ -1095,7 +1099,8 @@ class HKDevice: except AccessoryNotFoundError as exc: _LOGGER.debug( - "%s: Failed to appear on local network as a Thread device, reverting to BLE", + "%s: Failed to appear on local network as a" + " Thread device, reverting to BLE", self.unique_id, ) raise HomeAssistantError("Could not migrate device to Thread") from exc diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 77deb07b3dd..fdd34455486 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -103,6 +103,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor", CharacteristicsTypes.THREAD_CONTROL_POINT: "button", CharacteristicsTypes.MUTE: "switch", + CharacteristicsTypes.AIRPLAY_ENABLE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", CharacteristicsTypes.TEMPERATURE_UNITS: "select", diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 5ea990f55e6..be30343f742 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,7 +1,5 @@ """Support for Homekit covers.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes @@ -224,7 +222,8 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): else: return None - # Recalculate tilt_position. Convert arc to percent scale based on min/max values. + # Recalculate tilt_position. Convert arc to percent scale + # based on min/max values. tilt_position = char.value min_value = char.minValue max_value = char.maxValue @@ -272,7 +271,8 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): elif self.is_horizontal_tilt: char = self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET] - # Calculate tilt_position. Convert from 1-100 scale to arc degree scale respecting possible min/max Values. + # Calculate tilt_position. Convert from 1-100 scale to arc + # degree scale respecting possible min/max Values. min_value = char.minValue max_value = char.maxValue if min_value is None or max_value is None: diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 6195e61af3f..eb50ccca8d1 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for homekit devices.""" -from __future__ import annotations - from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any @@ -209,7 +207,10 @@ TRIGGER_FINDERS = { async def async_setup_triggers_for_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: - """Triggers aren't entities as they have no state, but we still need to set them up for a config entry.""" + """Set up triggers for a config entry. + + Triggers aren't entities as they have no state. + """ hkid = config_entry.data["AccessoryPairingID"] conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] diff --git a/homeassistant/components/homekit_controller/diagnostics.py b/homeassistant/components/homekit_controller/diagnostics.py index bfd034807c9..f72186439dc 100644 --- a/homeassistant/components/homekit_controller/diagnostics.py +++ b/homeassistant/components/homekit_controller/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for HomeKit Controller.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics.characteristic_types import CharacteristicsTypes diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index c5478ccb97d..ff6048d3564 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -1,7 +1,5 @@ """Homekit Controller entities.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import ( @@ -161,12 +159,13 @@ class HomeKitEntity(Entity): char_types = self.get_characteristic_types() - # Setup events and/or polling for characteristics directly attached to this entity + # Setup events and/or polling for characteristics + # directly attached to this entity for char in self.service.characteristics.filter(char_types=char_types): self._setup_characteristic(char) - # Setup events and/or polling for characteristics attached to sub-services of this - # entity (like an INPUT_SOURCE). + # Setup events and/or polling for characteristics attached + # to sub-services of this entity (like an INPUT_SOURCE). for service in self.accessory.services.filter(parent_service=self.service): for char in service.characteristics.filter(char_types=char_types): self._setup_characteristic(char) @@ -250,7 +249,7 @@ class HomeKitEntity(Entity): class AccessoryEntity(HomeKitEntity): - """A HomeKit entity that is related to an entire accessory rather than a specific service or characteristic.""" + """A HomeKit entity related to an entire accessory.""" def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit accessory.""" @@ -265,10 +264,11 @@ class AccessoryEntity(HomeKitEntity): class BaseCharacteristicEntity(HomeKitEntity): - """A HomeKit entity that is related to an single characteristic rather than a whole service. + """A HomeKit entity related to a single characteristic. - This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with - the service entity. + This is typically used to expose additional sensor, + binary_sensor or number entities that don't belong + with the service entity. """ def __init__( @@ -302,10 +302,11 @@ class BaseCharacteristicEntity(HomeKitEntity): class CharacteristicEntity(BaseCharacteristicEntity): - """A HomeKit entity that is related to an single characteristic rather than a whole service. + """A HomeKit entity related to a single characteristic. - This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with - the service entity. + This is typically used to expose additional sensor, + binary_sensor or number entities that don't belong + with the service entity. """ def __init__( @@ -321,4 +322,8 @@ class CharacteristicEntity(BaseCharacteristicEntity): def old_unique_id(self) -> str: """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) - return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" + return ( + f"homekit-{serial}-aid:{self._aid}" + f"-sid:{self._char.service.iid}" + f"-cid:{self._char.iid}" + ) diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index b90d561d60d..04d98db2a83 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -1,7 +1,5 @@ """Support for Homekit motion sensors.""" -from __future__ import annotations - from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import InputEventValues from aiohomekit.model.services import Service, ServicesTypes @@ -50,8 +48,9 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): self.entity_description = entity_description - # An INPUT_EVENT may support single_press, long_press and double_press. All are optional. So we have to - # clamp InputEventValues for this exact device + # An INPUT_EVENT may support single_press, long_press and + # double_press. All are optional. So we have to clamp + # InputEventValues for this exact device self._attr_event_types = [ INPUT_EVENT_VALUES[v] for v in clamp_enum_to_char(InputEventValues, self._char) diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 4138277d81c..fdb049b3363 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -1,7 +1,5 @@ """Support for Homekit fans.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index 7906d5ec52b..b1abc9d56eb 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -1,7 +1,5 @@ """Support for HomeKit Controller humidifier.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes @@ -115,7 +113,9 @@ class HomeKitBaseHumidifier(HomeKitEntity, HumidifierEntity): else: await self.async_put_characteristics( { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: self._on_mode_value, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: ( + self._on_mode_value + ), CharacteristicsTypes.ACTIVE: True, } ) diff --git a/homeassistant/components/homekit_controller/icons.json b/homeassistant/components/homekit_controller/icons.json index 49ea157a560..f1086ec166f 100644 --- a/homeassistant/components/homekit_controller/icons.json +++ b/homeassistant/components/homekit_controller/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "airplay_enable": { + "default": "mdi:cast-variant" + }, "lock_physical_controls": { "default": "mdi:lock-open" }, diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index ab19adb8e9d..5000748d24e 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,7 +1,5 @@ """Support for Homekit lights.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 06b8382c8af..582ff9ed1a9 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -1,7 +1,5 @@ """Support for HomeKit Controller locks.""" -from __future__ import annotations - from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index e3b4a760680..14613ff3c85 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -1,7 +1,5 @@ """Support for HomeKit Controller Televisions.""" -from __future__ import annotations - import logging from aiohomekit.model.characteristics import ( diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 96d6707d8eb..d454469f4c0 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -4,8 +4,6 @@ These are mostly used where a HomeKit accessory exposes additional non-standard characteristics that don't map to a Home Assistant feature. """ -from __future__ import annotations - from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from homeassistant.components.number import ( @@ -26,11 +24,13 @@ from .connection import HKDevice from .entity import CharacteristicEntity NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { - CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: NumberEntityDescription( - key=CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL, - name="Spray Quantity", - translation_key="spray_quantity", - entity_category=EntityCategory.CONFIG, + CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: ( + NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL, + name="Spray Quantity", + translation_key="spray_quantity", + entity_category=EntityCategory.CONFIG, + ) ), CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION, diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index f174743b12f..e8c5fce2184 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -1,7 +1,5 @@ """Support for Homekit select entities.""" -from __future__ import annotations - from dataclasses import dataclass from enum import IntEnum diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2e381b553a6..ab5aee19162 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,7 +1,5 @@ """Support for Homekit sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum @@ -146,33 +144,41 @@ def thread_status_to_str(char: Characteristic) -> str: SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { - CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT, - name="Power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, + CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ) ), - CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS, - name="Current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ) ), - CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20, - name="Current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ) ), - CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR, - name="Energy kWh", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR, + name="Energy kWh", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) ), CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: HomeKitSensorEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT, @@ -209,12 +215,14 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), - CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2, - name="Power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, + CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ) ), CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: HomeKitSensorEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE, @@ -363,13 +371,15 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription( - key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, - name="Valve position", - translation_key="valve_position", - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: ( + HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, + name="Valve position", + translation_key="valve_position", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ) ), } @@ -509,14 +519,14 @@ class HomeKitBatterySensor(HomeKitSensor): icon = "mdi:battery" is_charging = self.is_charging if is_charging and native_value > 10: - percentage = int(round(native_value / 20 - 0.01)) * 20 + percentage = round(native_value / 20 - 0.01) * 20 icon += f"-charging-{percentage}" elif is_charging: icon += "-outline" elif self.is_low_battery: icon += "-alert" elif native_value < 95: - percentage = max(int(round(native_value / 10 - 0.01)) * 10, 10) + percentage = max(round(native_value / 10 - 0.01) * 10, 10) icon += f"-{percentage}" return icon @@ -690,7 +700,7 @@ async def async_setup_entry( @callback def async_add_accessory(accessory: Accessory) -> bool: - if conn.pairing.transport != Transport.BLE: + if conn.pairing.transport is not Transport.BLE: return False accessory_info = accessory.services.first( diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index 8a73f99b391..c5add3a4c75 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -1,7 +1,5 @@ """Helpers for HomeKit data stored in HA storage.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 68eafb941a9..94d9478c206 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -6,7 +6,7 @@ "already_configured": "Accessory is already configured with this controller.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", - "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "ignored_model": "HomeKit support for this model is blocked as a more feature-complete native integration is available.", "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting configuration entry for it in Home Assistant that must first be removed.", "invalid_properties": "Invalid properties announced by device.", "no_devices": "No unpaired devices could be found" @@ -22,11 +22,11 @@ "flow_title": "{name} ({category})", "step": { "busy_error": { - "description": "Abort pairing on all controllers, or try restarting the device, then continue to resume pairing.", + "description": "Abort pairing on all controllers, or try restarting the device, then try pairing again.", "title": "The device is already pairing with another controller" }, "max_tries_error": { - "description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then continue to resume pairing.", + "description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then try pairing again.", "title": "Maximum authentication attempts exceeded" }, "pair": { @@ -38,7 +38,7 @@ "title": "Pair with a device via HomeKit Accessory Protocol" }, "protocol_error": { - "description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then continue to resume pairing.", + "description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then try pairing again.", "title": "Error communicating with the accessory" }, "user": { diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index c24a4edf545..7ad3c486f1b 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -1,7 +1,5 @@ """Support for Homekit switches.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -46,11 +44,13 @@ SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = { translation_key="pairing_mode", entity_category=EntityCategory.CONFIG, ), - CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE: DeclarativeSwitchEntityDescription( - key=CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE, - name="Pairing Mode", - translation_key="pairing_mode", - entity_category=EntityCategory.CONFIG, + CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE: ( + DeclarativeSwitchEntityDescription( + key=CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE, + name="Pairing Mode", + translation_key="pairing_mode", + entity_category=EntityCategory.CONFIG, + ) ), CharacteristicsTypes.LOCK_PHYSICAL_CONTROLS: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.LOCK_PHYSICAL_CONTROLS, @@ -70,6 +70,12 @@ SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = { translation_key="sleep_mode", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.AIRPLAY_ENABLE: DeclarativeSwitchEntityDescription( + key=CharacteristicsTypes.AIRPLAY_ENABLE, + name="AirPlay Enable", + translation_key="airplay_enable", + entity_category=EntityCategory.CONFIG, + ), } diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index 9d04576ec28..f64ab9c8c43 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -16,11 +16,13 @@ type IidTuple = tuple[int, int | None, int | None] def unique_id_to_iids(unique_id: str) -> IidTuple | None: - """Convert a unique_id to a tuple of accessory id, service iid and characteristic iid. + """Convert a unique_id to a tuple of aid, service iid and char iid. - Depending on the field in the accessory map that is referenced, some of these may be None. + Depending on the field in the accessory map that is + referenced, some of these may be None. - Returns None if this unique_id doesn't follow the homekit_controller scheme and is invalid. + Returns None if this unique_id doesn't follow the + homekit_controller scheme and is invalid. """ try: match unique_id.split("_"): @@ -31,7 +33,8 @@ def unique_id_to_iids(unique_id: str) -> IidTuple | None: case (unique_id, aid): return (int(aid), None, None) except ValueError: - # One of the int conversions failed - this can't be a valid homekit_controller unique id + # One of the int conversions failed - this can't be + # a valid homekit_controller unique id # Fall through and return None pass diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 41d965fab11..4c64fdb5f42 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -26,7 +26,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType +from homeassistant.util.async_ import run_callback_threadsafe from .const import ( ATTR_ADDRESS, @@ -381,12 +383,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: homematic.setInstallMode(interface, t=time, mode=mode, address=address) - hass.services.register( + run_callback_threadsafe( + hass.loop, + async_register_admin_service, + hass, DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, - schema=SCHEMA_SERVICE_SET_INSTALL_MODE, - ) + SCHEMA_SERVICE_SET_INSTALL_MODE, + ).result() def _service_put_paramset(service: ServiceCall) -> None: """Service to call the putParamset method on a HomeMatic connection.""" diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py index e2090b74ce8..c2707fcf4de 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -1,7 +1,5 @@ """Support for HomeMatic binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index 096ad76db11..93c3cc3eea7 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -1,7 +1,5 @@ """Support for Homematic thermostats.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index f93d92eed56..220d4d4420d 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -1,7 +1,5 @@ """Support for HomeMatic covers.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( @@ -120,7 +118,10 @@ class HMCover(HMDevice, CoverEntity): class HMGarage(HMCover): - """Represents a Homematic Garage cover. Homematic garage covers do not support position attributes.""" + """Represents a Homematic Garage cover. + + Homematic garage covers do not support position attributes. + """ _attr_device_class = CoverDeviceClass.GARAGE diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 9a153eb0aa8..73ac0812b46 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -1,7 +1,5 @@ """Homematic base entity.""" -from __future__ import annotations - from abc import abstractmethod from datetime import timedelta import logging @@ -144,7 +142,8 @@ class HMDevice(Entity): channel = channels[0] else: channel = self._channel - # Remember the channel for this attribute to ignore invalid events later + # Remember the channel for this attribute to + # ignore invalid events later self._channel_map[node] = str(channel) _LOGGER.debug("Channel map for %s: %s", self._address, str(self._channel_map)) diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index 62ce1cc9457..5cf70c7b3a0 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -1,7 +1,5 @@ """Support for Homematic lights.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ( diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py index 7640146b422..7625ab5fe4d 100644 --- a/homeassistant/components/homematic/lock.py +++ b/homeassistant/components/homematic/lock.py @@ -1,7 +1,5 @@ """Support for Homematic locks.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py index b4a2692a417..c7ea2ec72c1 100644 --- a/homeassistant/components/homematic/notify.py +++ b/homeassistant/components/homematic/notify.py @@ -1,7 +1,5 @@ """Notification support for Homematic.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 04b6546674c..a52ca924814 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -1,7 +1,5 @@ """Support for HomeMatic sensors.""" -from __future__ import annotations - from copy import copy import logging @@ -288,10 +286,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { ), } -DEFAULT_SENSOR_DESCRIPTION = SensorEntityDescription( - key="", - entity_registry_enabled_default=True, -) +DEFAULT_SENSOR_DESCRIPTION = SensorEntityDescription(key="") def setup_platform( diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py index ac8a2e5fe14..ae758472800 100644 --- a/homeassistant/components/homematic/switch.py +++ b/homeassistant/components/homematic/switch.py @@ -1,7 +1,5 @@ """Support for HomeMatic switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 30038d1f897..270d3e46af9 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,5 +1,7 @@ """Support for HomematicIP Cloud devices.""" +import logging + import voluptuous as vol from homeassistant import config_entries @@ -21,8 +23,11 @@ from .const import ( HMIPC_NAME, ) from .hap import HomematicIPConfigEntry, HomematicipHAP +from .migration import _match_legacy_class_name, _migrate_unique_id from .services import async_setup_services +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=[]): vol.All( @@ -85,8 +90,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) if not await hap.async_setup(): return False - _async_remove_obsolete_entities(hass, entry, hap) - # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection hap.reset_connection_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hap.shutdown @@ -119,22 +122,128 @@ async def async_unload_entry( return await hap.async_reset() -@callback -def _async_remove_obsolete_entities( - hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP -): - """Remove obsolete entities from entity registry.""" +async def async_migrate_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Migrate the config entry from version 1 to version 2.""" + if config_entry.version > 2: + return False - if hap.home.currentAPVersion < "2.2.12": - return + if config_entry.version == 1: + _LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2") - entity_registry = er.async_get(hass) - er_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - for er_entry in er_entries: - if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): - entity_registry.async_remove(er_entry.entity_id) - continue + # Remove obsolete entities before the bulk unique_id rewrite. + # After rewrite, old-format patterns would no longer be matchable. + # HomematicipAccesspointStatus* entities are always obsolete (removed + # in firmware 2.2.12+). HomematicipBatterySensor_{hapid} entities for + # access points are also obsolete. Those legacy access point battery + # entities do not belong to a device registry device, unlike real + # device battery sensors, so we can safely remove them before rewrite. + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for entry in entries: + if entry.unique_id.startswith("HomematicipAccesspointStatus") or ( + entry.unique_id.startswith("HomematicipBatterySensor_") + and entry.device_id is None + ): + _LOGGER.debug( + "Removing obsolete entity: %s (%s)", + entry.entity_id, + entry.unique_id, + ) + entity_registry.async_remove(entry.entity_id) - for hapid in hap.home.accessPointUpdateStates: - if er_entry.unique_id == f"HomematicipBatterySensor_{hapid}": - entity_registry.async_remove(er_entry.entity_id) + # Pre-pass: deduplicate legacy entries that would migrate to the same + # new unique_id, and drop legacy entries whose target is already + # occupied by a stable-format entry from a previously-aborted + # migration. Two collision shapes are handled here: + # + # a) Two or more legacy entries share the same new target id (e.g. + # HomematicipNotificationLight + HomematicipNotificationLightV2 + # for the same HmIP-BSL after firmware 2.0.0, or Switch + + # SwitchMeasuring on a device whose capability class changed). + # + # b) One legacy entry shares its target with a stable-format entry + # that was successfully migrated on a previous run before the + # run aborted on a sibling collision. async_migrate_entries + # commits each update individually with no rollback, so partial + # migration is the steady state for any user who already hit + # this bug at least once. + # + # When deduplicating pure-legacy groups, prefer the entry whose + # legacy class name is longer — that is the more specific variant + # (V2 over V1, Measuring over plain) and the one HA has been + # actively binding to since the class transition. + legacy_by_target: dict[tuple[str, str], list[er.RegistryEntry]] = {} + stable_targets: set[tuple[str, str]] = set() + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + new_id = _migrate_unique_id(entry.unique_id) + if new_id is None: + # Stable-format entry — record so we can detect (b). + stable_targets.add((entry.domain, entry.unique_id)) + continue + legacy_by_target.setdefault((entry.domain, new_id), []).append(entry) + + for key, group in legacy_by_target.items(): + if key in stable_targets: + # (b): stable entry already occupies the target. Drop every + # legacy duplicate; the surviving stable entry stays put. + for dup in group: + _LOGGER.warning( + "Removing legacy registry entry %s (%s) — its" + " migration target %s is already in use by a stable" + " entry from a previously-aborted migration", + dup.entity_id, + dup.unique_id, + key[1], + ) + entity_registry.async_remove(dup.entity_id) + continue + if len(group) <= 1: + continue + # (a): multiple legacy entries collide on the same target. + group.sort( + key=lambda e: len(_match_legacy_class_name(e.unique_id) or ""), + reverse=True, + ) + keeper, *duplicates = group + for dup in duplicates: + _LOGGER.warning( + "Removing duplicate registry entry %s (%s) — collides" + " with %s on migration to %s", + dup.entity_id, + dup.unique_id, + keeper.entity_id, + key[1], + ) + entity_registry.async_remove(dup.entity_id) + + @callback + def _update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + new_unique_id = _migrate_unique_id(entity_entry.unique_id) + if new_unique_id is None: + _LOGGER.debug( + "Skipping unique_id %s (already stable format)", + entity_entry.unique_id, + ) + return None + _LOGGER.debug( + "Migrating %s: %s -> %s", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} + + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) + + hass.config_entries.async_update_entry(config_entry, version=2) + _LOGGER.info("Migration to version 2 successful") + + return True diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index ddfe10fba54..6a54f8902c5 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud alarm control panel.""" -from __future__ import annotations - import logging from homematicip.functionalHomes import SecurityAndAlarmHome @@ -42,6 +40,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) _attr_code_arm_required = False + _feature_id = "alarm" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" @@ -127,4 +126,4 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._home.id}" + return f"{self._home.id}_{self._feature_id}" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d3b164209ce..8eb7d3aefda 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -1,10 +1,15 @@ """Support for HomematicIP Cloud binary sensor.""" -from __future__ import annotations - +from collections.abc import Callable +from dataclasses import dataclass from typing import Any -from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import ( + BinaryBehaviorType, + LockState, + SmokeDetectorAlarmType, + WindowState, +) from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, @@ -34,6 +39,7 @@ from homematicip.group import SecurityGroup, SecurityZoneGroup from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -74,6 +80,161 @@ SAM_DEVICE_ATTRIBUTES = { } +def _always_exists(_device: Device) -> bool: + """Default exists_fn: every matched device gets the entity.""" + return True + + +@dataclass(frozen=True, kw_only=True) +class HmipBinarySensorDescription[_DeviceT: Device](BinarySensorEntityDescription): + """Describe a simple HomematicIP binary sensor.""" + + value_fn: Callable[[_DeviceT], bool] + exists_fn: Callable[[_DeviceT], bool] = _always_exists + # Required: contributes to unique_id via {device.id}_{channel}_{key}. An + # implicit default would silently lean on get_channel_index()'s fallback + # and create a migration footgun. + channel: int + + +MOTION_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[ + MotionDetectorIndoor | MotionDetectorOutdoor | MotionDetectorPushButton + ], + ..., +] = ( + HmipBinarySensorDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + value_fn=lambda device: device.motionDetected, + channel=1, + ), +) + +PRESENCE_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[PresenceDetectorIndoor], + ..., +] = ( + HmipBinarySensorDescription( + key="presence", + device_class=BinarySensorDeviceClass.PRESENCE, + value_fn=lambda device: device.presenceDetected, + channel=1, + ), +) + +SMOKE_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[SmokeDetector], + ..., +] = ( + HmipBinarySensorDescription( + key="smoke", + device_class=BinarySensorDeviceClass.SMOKE, + value_fn=lambda device: ( + device.smokeDetectorAlarmType == SmokeDetectorAlarmType.PRIMARY_ALARM + ), + channel=1, + ), + HmipBinarySensorDescription( + key="chamber_degraded", + translation_key="chamber_degraded", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda device: device.chamberDegraded, + exists_fn=lambda device: smoke_detector_channel_data_exists( + device, "chamberDegraded" + ), + channel=1, + ), +) + +WATER_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[WaterSensor], + ..., +] = ( + HmipBinarySensorDescription( + key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda device: device.moistureDetected or device.waterlevelDetected, + channel=1, + ), +) + +RAIN_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[RainSensor | WeatherSensorPlus | WeatherSensorPro], + ..., +] = ( + HmipBinarySensorDescription( + key="rain", + translation_key="raining", + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda device: device.raining, + channel=1, + ), +) + +MAINS_FAILURE_SENSOR_DESCRIPTIONS: tuple[ + HmipBinarySensorDescription[PluggableMainsFailureSurveillance], + ..., +] = ( + HmipBinarySensorDescription( + key="mains_failure", + device_class=BinarySensorDeviceClass.POWER, + value_fn=lambda device: not device.powerMainsFailure, + channel=1, + ), +) + +BATTERY_SENSOR_DESCRIPTION = HmipBinarySensorDescription[Device]( + key="battery", + device_class=BinarySensorDeviceClass.BATTERY, + value_fn=lambda device: bool(device.lowBat), + channel=0, +) + +SIMPLE_BINARY_SENSOR_DESCRIPTIONS: dict[ + tuple[type, ...], tuple[HmipBinarySensorDescription[Any], ...] +] = { + ( + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + ): MOTION_SENSOR_DESCRIPTIONS, + (PresenceDetectorIndoor,): PRESENCE_SENSOR_DESCRIPTIONS, + (SmokeDetector,): SMOKE_SENSOR_DESCRIPTIONS, + (WaterSensor,): WATER_SENSOR_DESCRIPTIONS, + (RainSensor, WeatherSensorPlus, WeatherSensorPro): RAIN_SENSOR_DESCRIPTIONS, + (PluggableMainsFailureSurveillance,): MAINS_FAILURE_SENSOR_DESCRIPTIONS, +} + + +def _create_simple_binary_sensors( + hap: HomematicipHAP, + device: Device, +) -> list[HomematicipSimpleBinarySensor[Any]]: + """Create all simple described binary sensors for a device.""" + entities: list[HomematicipSimpleBinarySensor[Any]] = [] + + for device_types, descriptions in SIMPLE_BINARY_SENSOR_DESCRIPTIONS.items(): + if not isinstance(device, device_types): + continue + entities.extend( + HomematicipSimpleBinarySensor(hap, device, description) + for description in descriptions + if description.exists_fn(device) + ) + # Each device class matches at most one group key (enforced by + # test_simple_binary_sensor_descriptions_no_overlap), so further + # iteration cannot add entities. + break + + if device.lowBat is not None: + entities.append( + HomematicipSimpleBinarySensor(hap, device, BATTERY_SENSOR_DESCRIPTION) + ) + + return entities + + def _is_full_flush_lock_controller(device: object) -> bool: """Return whether the device is an HmIP-FLC.""" return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( @@ -133,37 +294,15 @@ async def async_setup_entry( entities.append(HomematicipShutterContact(hap, device)) if isinstance(device, RotaryHandleSensor): entities.append(HomematicipShutterContact(hap, device, True)) - if isinstance( - device, - ( - MotionDetectorIndoor, - MotionDetectorOutdoor, - MotionDetectorPushButton, - ), - ): - entities.append(HomematicipMotionDetector(hap, device)) - if isinstance(device, PluggableMainsFailureSurveillance): - entities.append( - HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) - ) + if isinstance(device, Device): + entities.extend(_create_simple_binary_sensors(hap, device)) + if _is_full_flush_lock_controller(device): entities.append(HomematicipFullFlushLockControllerLocked(hap, device)) entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device)) - if isinstance(device, PresenceDetectorIndoor): - entities.append(HomematicipPresenceDetector(hap, device)) - if isinstance(device, SmokeDetector): - entities.append(HomematicipSmokeDetector(hap, device)) - if smoke_detector_channel_data_exists(device, "chamberDegraded"): - entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device)) - if isinstance(device, WaterSensor): - entities.append(HomematicipWaterDetector(hap, device)) - if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)): - entities.append(HomematicipRainSensor(hap, device)) if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipStormSensor(hap, device)) entities.append(HomematicipSunshineSensor(hap, device)) - if isinstance(device, Device) and device.lowBat is not None: - entities.append(HomematicipBatterySensor(hap, device)) for group in hap.home.groups: if isinstance(group, SecurityGroup): @@ -174,30 +313,56 @@ async def async_setup_entry( async_add_entities(entities) +class HomematicipSimpleBinarySensor[_DeviceT: Device]( + HomematicipGenericEntity, BinarySensorEntity +): + """A simple HomematicIP binary sensor backed by an entity description.""" + + entity_description: HmipBinarySensorDescription[_DeviceT] + + def __init__( + self, + hap: HomematicipHAP, + device: _DeviceT, + description: HmipBinarySensorDescription[_DeviceT], + ) -> None: + """Initialize the described binary sensor.""" + super().__init__( + hap, + device, + channel=description.channel, + feature_id=description.key, + use_description_name=True, + ) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return whether the binary sensor is on.""" + return self.entity_description.value_fn(self._device) + + class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP cloud connection sensor.""" + _attr_translation_key = "cloud_connection" + def __init__(self, hap: HomematicipHAP) -> None: """Initialize the cloud connection sensor.""" - super().__init__(hap, hap.home) - - @property - def name(self) -> str: - """Return the name cloud connection entity.""" - - name = "Cloud Connection" - # Add a prefix to the name if the homematic ip home has a name. - return name if not self._home.name else f"{self._home.name} {name}" + super().__init__(hap, hap.home, feature_id="cloud_connection") @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - # Adds a sensor to the existing HAP device + # Merges into the existing HAP device registered in __init__.py. + # Name must match __init__.py logic for has_entity_name to work. + label = self._home.label or "" return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device (DOMAIN, self._home.id) - } + }, + name=label, ) @property @@ -245,10 +410,18 @@ class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): class HomematicipAccelerationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP acceleration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the acceleration sensor.""" + super().__init__(hap, device, feature_id="acceleration") + class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP tilt vibration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt vibration sensor.""" + super().__init__(hap, device, feature_id="tilt_vibration") + class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" @@ -262,6 +435,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt channel=1, is_multi_channel=True, channel_real_index=None, + feature_id: str = "contact", ) -> None: """Initialize the multi contact entity.""" super().__init__( @@ -270,6 +444,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt channel=channel, is_multi_channel=is_multi_channel, channel_real_index=channel_real_index, + feature_id=feature_id, ) @property @@ -286,7 +461,7 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the multi contact entity.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__(hap, device, is_multi_channel=False, feature_id="contact") class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): @@ -298,7 +473,9 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: """Initialize the shutter contact.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__( + hap, device, is_multi_channel=False, feature_id="shutter_contact" + ) self.has_additional_state = has_additional_state @property @@ -314,17 +491,6 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn return state_attr -class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP motion detector.""" - - _attr_device_class = BinarySensorDeviceClass.MOTION - - @property - def is_on(self) -> bool: - """Return true if motion is detected.""" - return self._device.motionDetected - - class HomematicipFullFlushLockControllerLocked( HomematicipGenericEntity, BinarySensorEntity ): @@ -334,11 +500,26 @@ class HomematicipFullFlushLockControllerLocked( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller lock sensor.""" - super().__init__(hap, device, post="Locked") + super().__init__(hap, device, post="Locked", feature_id="lock_locked") @property def is_on(self) -> bool: - """Return true if the controlled lock is locked.""" + """Return true if the controlled lock is unlocked. + + Per HA's BinarySensorDeviceClass.LOCK contract, ON means + unlocked / open and OFF means locked / closed. + + The mapping from the firmware-reported ``lockState`` depends on + the channel's ``binaryBehaviorType``. With the default + ``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState + flips to ``LOCKED``) when the contact closes — i.e. when a + magnetic door contact registers the door as closed. With + ``NORMALLY_CLOSE`` the same physical event puts the input into + the IDLE state (lockState ``UNLOCKED``). To present the same + HA semantics regardless of which way the user wired the + contact, ``lockState`` is interpreted relative to the + configured behavior. + """ channel = _get_channel_by_role( self._device, "MULTI_MODE_LOCK_INPUT_CHANNEL", @@ -347,7 +528,15 @@ class HomematicipFullFlushLockControllerLocked( if channel is None: return False lock_state = getattr(channel, "lockState", None) - return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + is_locked_state = ( + getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + ) + binary_behavior = getattr(channel, "binaryBehaviorType", None) + normally_close = ( + getattr(binary_behavior, "name", binary_behavior) + == BinaryBehaviorType.NORMALLY_CLOSE.name + ) + return is_locked_state if normally_close else not is_locked_state class HomematicipFullFlushLockControllerGlassBreak( @@ -359,7 +548,7 @@ class HomematicipFullFlushLockControllerGlassBreak( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller glass break sensor.""" - super().__init__(hap, device, post="Glass break") + super().__init__(hap, device, post="Glass break", feature_id="glass_break") @property def is_on(self) -> bool: @@ -374,67 +563,12 @@ class HomematicipFullFlushLockControllerGlassBreak( return bool(getattr(channel, "glassBroken", False)) -class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP presence detector.""" - - _attr_device_class = BinarySensorDeviceClass.PRESENCE - - @property - def is_on(self) -> bool: - """Return true if presence is detected.""" - return self._device.presenceDetected - - -class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP smoke detector.""" - - _attr_device_class = BinarySensorDeviceClass.SMOKE - - @property - def is_on(self) -> bool: - """Return true if smoke is detected.""" - if self._device.smokeDetectorAlarmType: - return ( - self._device.smokeDetectorAlarmType - == SmokeDetectorAlarmType.PRIMARY_ALARM - ) - return False - - -class HomematicipSmokeDetectorChamberDegraded( - HomematicipGenericEntity, BinarySensorEntity -): - """Representation of the HomematicIP smoke detector chamber health.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, hap: HomematicipHAP, device) -> None: - """Initialize smoke detector chamber health sensor.""" - super().__init__(hap, device, post="Chamber Degraded") - - @property - def is_on(self) -> bool: - """Return true if smoke chamber is degraded.""" - return self._device.chamberDegraded - - -class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP water detector.""" - - _attr_device_class = BinarySensorDeviceClass.MOISTURE - - @property - def is_on(self) -> bool: - """Return true, if moisture or waterlevel is detected.""" - return self._device.moistureDetected or self._device.waterlevelDetected - - class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP storm sensor.""" def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize storm sensor.""" - super().__init__(hap, device, "Storm") + super().__init__(hap, device, "Storm", feature_id="storm") @property def icon(self) -> str: @@ -447,21 +581,6 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): return self._device.storm -class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP rain sensor.""" - - _attr_device_class = BinarySensorDeviceClass.MOISTURE - - def __init__(self, hap: HomematicipHAP, device) -> None: - """Initialize rain sensor.""" - super().__init__(hap, device, "Raining") - - @property - def is_on(self) -> bool: - """Return true, if it is raining.""" - return self._device.raining - - class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP sunshine sensor.""" @@ -469,7 +588,7 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" - super().__init__(hap, device, post="Sunshine") + super().__init__(hap, device, post="Sunshine", feature_id="sunshine") @property def is_on(self) -> bool: @@ -488,47 +607,22 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): return state_attr -class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): - """Representation of the HomematicIP low battery sensor.""" - - _attr_device_class = BinarySensorDeviceClass.BATTERY - - def __init__(self, hap: HomematicipHAP, device) -> None: - """Initialize battery sensor.""" - super().__init__(hap, device, post="Battery") - - @property - def is_on(self) -> bool: - """Return true if battery is low.""" - return self._device.lowBat - - -class HomematicipPluggableMainsFailureSurveillanceSensor( - HomematicipGenericEntity, BinarySensorEntity -): - """Representation of the HomematicIP pluggable mains failure surveillance sensor.""" - - _attr_device_class = BinarySensorDeviceClass.POWER - - def __init__(self, hap: HomematicipHAP, device) -> None: - """Initialize pluggable mains failure surveillance sensor.""" - super().__init__(hap, device) - - @property - def is_on(self) -> bool: - """Return true if power mains fails.""" - return not self._device.powerMainsFailure - - class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP security zone sensor group.""" + _attr_has_entity_name = False _attr_device_class = BinarySensorDeviceClass.SAFETY - def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + post: str = "SecurityZone", + feature_id: str = "security_zone", + ) -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post=post) + super().__init__(hap, device, post=post, feature_id=feature_id) @property def available(self) -> bool: @@ -578,7 +672,7 @@ class HomematicipSecuritySensorGroup( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize security group.""" - super().__init__(hap, device, post="Sensors") + super().__init__(hap, device, post="Sensors", feature_id="security") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index bcd157d44d6..bdb1d33c3b5 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud button devices.""" -from __future__ import annotations - from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity @@ -45,7 +43,7 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize a wall mounted garage door controller.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="garage_button") self._attr_icon = "mdi:arrow-up-down" async def async_press(self) -> None: @@ -58,7 +56,9 @@ class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller opener button.""" - super().__init__(hap, device, post="Door opener") + super().__init__( + hap, device, post="Door opener", feature_id="lock_opener_button" + ) self._attr_icon = "mdi:door-open" async def async_press(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 689bce9243f..aedbbdb4184 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud climate devices.""" -from __future__ import annotations - from typing import Any from homematicip.base.enums import AbsenceType @@ -72,9 +70,11 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): Heat mode is supported for all heating devices incl. their defined profiles. Boost is available for radiator thermostats only. - Cool mode is only available for floor heating systems, if basically enabled in the hmip app. + Cool mode is only available for floor heating systems, if + basically enabled in the hmip app. """ + _attr_has_entity_name = False _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) @@ -83,7 +83,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="climate") self._simple_heating = None if device.actualTemperature is None: self._simple_heating = self._first_radiator_thermostat @@ -287,7 +287,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ] def _get_qualified_profile_name(self, profile: HeatingCoolingProfile) -> str: - """Get a name for the given profile. If exists, this is the name of the profile.""" + """Get a name for the given profile.""" if profile.name != "": return profile.name if profile.index in NICE_PROFILE_NAMES: diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 3a8614b9959..f874416220d 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the HomematicIP Cloud component.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -16,7 +14,7 @@ from .hap import HomematicipAuth class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for the HomematicIP Cloud component.""" - VERSION = 1 + VERSION = 2 auth: HomematicipAuth diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index a8070c455d1..742877dce23 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud cover devices.""" -from __future__ import annotations - from typing import Any from homematicip.base.enums import DoorCommand, DoorState @@ -69,6 +67,10 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.BLIND + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the blind module entity.""" + super().__init__(hap, device, feature_id="blind") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -153,10 +155,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): device, channel=1, is_multi_channel=True, + feature_id="shutter", ) -> None: """Initialize the multi cover entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id=feature_id, ) @property @@ -218,7 +225,11 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): ) -> None: """Initialize the multi slats entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="slats", ) @property @@ -269,6 +280,10 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the garage door module entity.""" + super().__init__(hap, device, feature_id="garage_door") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -305,12 +320,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter group.""" + _attr_has_entity_name = False _attr_device_class = CoverDeviceClass.SHUTTER def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post, is_multi_channel=False) + super().__init__( + hap, device, post, is_multi_channel=False, feature_id="shutter" + ) @property def available(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/diagnostics.py b/homeassistant/components/homematicip_cloud/diagnostics.py index 64f418cbcc0..752384fcc61 100644 --- a/homeassistant/components/homematicip_cloud/diagnostics.py +++ b/homeassistant/components/homematicip_cloud/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for HomematicIP Cloud.""" -from __future__ import annotations - import json from typing import Any diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 81f2c7e8c7e..b57c8459f2d 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -1,7 +1,5 @@ """Generic entity for the HomematicIP Cloud component.""" -from __future__ import annotations - import contextlib import logging from typing import Any @@ -76,6 +74,7 @@ GROUP_ATTRIBUTES = { class HomematicipGenericEntity(Entity): """Representation of the HomematicIP generic entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( @@ -86,8 +85,17 @@ class HomematicipGenericEntity(Entity): channel: int | None = None, is_multi_channel: bool | None = False, channel_real_index: int | None = None, + *, + feature_id: str, + use_description_name: bool = False, ) -> None: - """Initialize the generic entity.""" + """Initialize the generic entity. + + When ``use_description_name`` is True, leave ``_attr_name`` unset so + HA's standard name resolution (``EntityDescription.name``, + ``device_class``, ``translation_key`` + placeholders) drives the + entity name. Default False keeps the legacy channel/post composition. + """ self._hap = hap self._home: AsyncHome = hap.home self._device = device @@ -95,12 +103,15 @@ class HomematicipGenericEntity(Entity): self._channel = channel # channel_real_index represents the actual index of the devices channel. - # Accessing a functionalChannel by the channel parameter or array index is unreliable, - # because the functionalChannels array is sorted as strings, not numbers. + # Accessing a functionalChannel by the channel parameter + # or array index is unreliable, because the + # functionalChannels array is sorted as strings, not + # numbers. # For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ... # Using channel_real_index ensures you reference the correct channel. self._channel_real_index: int | None = channel_real_index + self._feature_id = feature_id self._is_multi_channel = is_multi_channel self.functional_channel = None with contextlib.suppress(ValueError): @@ -109,6 +120,14 @@ class HomematicipGenericEntity(Entity): # Marker showing that the HmIP device hase been removed. self.hmip_device_removed = False + # Compute entity name based on has_entity_name mode. + if not self._attr_has_entity_name: + # Legacy mode (groups, special entities): compose the full name + # including device/group label and home prefix. + self._attr_name = self._compute_legacy_name() + elif not use_description_name: + self._setup_entity_name() + @property def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" @@ -117,6 +136,14 @@ class HomematicipGenericEntity(Entity): device_id = str(self._device.id) home_id = str(self._device.homeId) + # Include the home name in the device name so that the + # previous "{home} {device}" naming is preserved after + # switching to has_entity_name=True. + device_name = self._device.label + home_name = getattr(self._home, "name", None) + if device_name and home_name: + device_name = f"{home_name} {device_name}" + return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device @@ -124,7 +151,7 @@ class HomematicipGenericEntity(Entity): }, manufacturer=self._device.oem, model=self._device.modelType, - name=self._device.label, + name=device_name, sw_version=self._device.firmwareVersion, # Link to the homematic ip access point. via_device=(DOMAIN, home_id), @@ -197,38 +224,93 @@ class HomematicipGenericEntity(Entity): self.async_remove(force_remove=True), eager_start=False ) - @property - def name(self) -> str: - """Return the name of the generic entity.""" + def _compute_legacy_name(self) -> str: + """Compute the full legacy name for entities without has_entity_name. - name = "" - # Try to get a label from a channel. - functional_channels = getattr(self._device, "functionalChannels", None) - if functional_channels and self.functional_channel: - if self._is_multi_channel: - label = getattr(self.functional_channel, "label", None) - if label: - name = str(label) - elif len(functional_channels) > 1: - label = getattr(functional_channels[1], "label", None) - if label: - name = str(label) - - # Use device label, if name is not defined by channel label. - if not name: - name = self._device.label or "" - if self._post: - name = f"{name} {self._post}" - elif self._is_multi_channel: - name = f"{name} Channel{self.get_channel_index()}" - - # Add a prefix to the name if the homematic ip home has a name. + Used by group entities and other special cases where has_entity_name + is False. Includes device/group label, post suffix, and home prefix. + """ + name = self._device.label or "" + if self._post: + name = f"{name} {self._post}" if name else self._post home_name = getattr(self._home, "name", None) if name and home_name: name = f"{home_name} {name}" - return name + def _setup_entity_name(self) -> None: + """Set up entity naming for has_entity_name mode. + + With has_entity_name=True, HA composes the full friendly name as + "{device_name} {entity_name}". This method sets the appropriate + naming attributes. + + For multi-channel entities, channel labels provide _attr_name (dynamic). + For entities with _post, _attr_name is derived from the post suffix, + with the first letter capitalized for display consistency. + For primary entities, HA uses device_class as the name. + """ + # Multi-channel entities: use channel label as entity name. + if self._is_multi_channel and self.functional_channel: + label = getattr(self.functional_channel, "label", None) + if label: + label_str = str(label) + device_label = self._device.label or "" + # Strip device name prefix from channel label to avoid + # duplication when HA composes "{device_name} {entity_name}". + # E.g., device "Licht Flur" + channel "Licht Flur 5" -> "5". + if device_label and label_str.startswith(device_label): + stripped = label_str[len(device_label) :].strip() + if stripped: + self._attr_name = stripped + # Otherwise channel label equals device label (modulo + # whitespace); leave _attr_name unset so HA composes just + # the device name without duplicating it. + return + self._attr_name = label_str + return + # Fallback: use post suffix or generic channel name. + if self._post: + self._attr_name = self._post[0].upper() + self._post[1:] + else: + self._attr_name = f"Channel{self.get_channel_index()}" + return + + # Entities with a post suffix: use it as the entity name, + # capitalizing the first letter for display consistency. + if self._post: + self._attr_name = self._post[0].upper() + self._post[1:] + return + + # Non-multi-channel entities on devices with multiple channels: + # use the first functional channel's label as name context. + # This preserves names like "Treppe CH" for single-function entities + # on multi-channel devices (e.g., HmIP-BSL switch channel). + functional_channels = getattr(self._device, "functionalChannels", None) + if functional_channels and len(functional_channels) > 1: + ch1 = ( + functional_channels.get(1) + if isinstance(functional_channels, dict) + else functional_channels[1] + ) + label = getattr(ch1, "label", None) if ch1 else None + if label: + label_str = str(label) + device_label = self._device.label or "" + # Strip device name prefix to avoid duplication. + if device_label and label_str.startswith(device_label): + stripped = label_str[len(device_label) :].strip() + if stripped: + self._attr_name = stripped + # Otherwise channel label equals device label (modulo + # whitespace); leave _attr_name unset. + return + self._attr_name = label_str + return + + # Primary entity on device: leave unset so HA derives name from + # device_class or translation_key. + @property def available(self) -> bool: """Return if entity is available.""" @@ -237,11 +319,10 @@ class HomematicipGenericEntity(Entity): @property def unique_id(self) -> str: """Return a unique ID.""" - unique_id = f"{self.__class__.__name__}_{self._device.id}" - if self._is_multi_channel: - unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}" - - return unique_id + if not isinstance(self._device, Device): + return f"{self._device.id}_{self._feature_id}" + channel_index = self.get_channel_index() + return f"{self._device.id}_{channel_index}_{self._feature_id}" @property def icon(self) -> str | None: @@ -277,28 +358,35 @@ class HomematicipGenericEntity(Entity): """Return the FunctionalChannel for the device. Resolution priority: - 1. For multi-channel entities with a real index, find channel by index match. - 2. For multi-channel entities without a real index, use the provided channel position. - 3. For non multi-channel entities with >1 channels, use channel at position 1 - (index 0 is often a meta/service channel in HmIP). + 1. For multi-channel entities with a real index, find + channel by index match. + 2. For multi-channel entities without a real index, use + the provided channel position. + 3. For non multi-channel entities with >1 channels, use + channel at position 1 (index 0 is often a meta/service + channel in HmIP). Raises ValueError if no suitable channel can be resolved. """ functional_channels = getattr(self._device, "functionalChannels", None) if not functional_channels: raise ValueError( - f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels" + f"Device {getattr(self._device, 'id', 'unknown')}" + " has no functionalChannels" ) # Multi-channel handling if self._is_multi_channel: - # Prefer real index mapping when provided to avoid ordering issues. + # Prefer real index mapping when provided to avoid + # ordering issues. if self._channel_real_index is not None: for channel in functional_channels: if channel.index == self._channel_real_index: return channel raise ValueError( - f"Real channel index {self._channel_real_index} not found for device " - f"{getattr(self._device, 'id', 'unknown')}" + f"Real channel index" + f" {self._channel_real_index}" + " not found for device" + f" {getattr(self._device, 'id', 'unknown')}" ) # Fallback: positional channel (already sorted as strings upstream). if self._channel is not None and 0 <= self._channel < len( @@ -306,8 +394,9 @@ class HomematicipGenericEntity(Entity): ): return functional_channels[self._channel] raise ValueError( - f"Channel position {self._channel} invalid for device " - f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})" + f"Channel position {self._channel} invalid for" + f" device {getattr(self._device, 'id', 'unknown')}" + f" (len={len(functional_channels)})" ) # Single-channel / non multi-channel entity: choose second element if available @@ -319,7 +408,8 @@ class HomematicipGenericEntity(Entity): """Return the correct channel index for this entity. Prefers channel_real_index if set, otherwise returns channel. - This ensures the correct channel is used even if the functionalChannels list is not numerically ordered. + This ensures the correct channel is used even if the + functionalChannels list is not numerically ordered. """ if self._channel_real_index is not None: return self._channel_real_index @@ -333,6 +423,7 @@ class HomematicipGenericEntity(Entity): """Return the FunctionalChannel or raise an error if not found.""" if not self.functional_channel: raise ValueError( - f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}" + f"No functional channel found for device" + f" {getattr(self._device, 'id', 'unknown')}" ) return self.functional_channel diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index f98b078ab73..f0d08065a7d 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -2,9 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from homematicip.base.channel_event import ChannelEvent +from homematicip.base.enums import FunctionalChannelType from homematicip.base.functionalChannels import FunctionalChannel from homematicip.device import Device @@ -24,20 +24,41 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP class HmipEventEntityDescription(EventEntityDescription): """Description of a HomematicIP Cloud event.""" - channel_event_types: list[str] | None = None - channel_selector_fn: Callable[[FunctionalChannel], bool] | None = None + event_type_map: dict[str, str] + channel_selector_fn: Callable[[FunctionalChannel], bool] + is_multi_channel: bool = False -EVENT_DESCRIPTIONS = { - "doorbell": HmipEventEntityDescription( +EVENT_DESCRIPTIONS: tuple[HmipEventEntityDescription, ...] = ( + HmipEventEntityDescription( key="doorbell", translation_key="doorbell", device_class=EventDeviceClass.DOORBELL, event_types=["ring"], - channel_event_types=["DOOR_BELL_SENSOR_EVENT"], + event_type_map={"DOOR_BELL_SENSOR_EVENT": "ring"}, channel_selector_fn=lambda channel: channel.channelRole == "DOOR_BELL_INPUT", ), -} + # Button event types follow the standard names proposed in + # home-assistant/architecture#1377: short_release, long_press, + # long_release. HmIP doesn't expose a separate press-down ("initial_press") + # event for short presses; KEY_PRESS_LONG_START is mapped to long_press + # (no separate initial_press fires for the hold sequence either). + HmipEventEntityDescription( + key="button", + translation_key="button", + device_class=EventDeviceClass.BUTTON, + event_types=["short_release", "long_press", "long_release"], + event_type_map={ + "KEY_PRESS_SHORT": "short_release", + "KEY_PRESS_LONG_START": "long_press", + "KEY_PRESS_LONG_STOP": "long_release", + }, + channel_selector_fn=lambda channel: ( + channel.functionalChannelType == FunctionalChannelType.SINGLE_KEY_CHANNEL + ), + is_multi_channel=True, + ), +) async def async_setup_entry( @@ -45,49 +66,43 @@ async def async_setup_entry( config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the HomematicIP cover from a config entry.""" + """Set up the HomematicIP events from a config entry.""" hap = config_entry.runtime_data - entities: list[HomematicipGenericEntity] = [] - entities.extend( - HomematicipDoorBellEvent( - hap, - device, - channel.index, - description, - ) - for description in EVENT_DESCRIPTIONS.values() + async_add_entities( + HomematicipChannelEvent(hap, device, channel, description) + for description in EVENT_DESCRIPTIONS for device in hap.home.devices for channel in device.functionalChannels - if description.channel_selector_fn and description.channel_selector_fn(channel) + if description.channel_selector_fn(channel) ) - async_add_entities(entities) +class HomematicipChannelEvent(HomematicipGenericEntity, EventEntity): + """Event entity backed by a HomematicIP functional channel.""" -class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): - """Event class for HomematicIP doorbell events.""" - - _attr_device_class = EventDeviceClass.DOORBELL entity_description: HmipEventEntityDescription def __init__( self, hap: HomematicipHAP, device: Device, - channel: int, + channel: FunctionalChannel, description: HmipEventEntityDescription, ) -> None: - """Initialize the event.""" + """Initialize the channel-backed event entity.""" super().__init__( hap, device, - post=description.key, - channel=channel, - is_multi_channel=False, + channel=channel.index, + channel_real_index=channel.index if description.is_multi_channel else None, + is_multi_channel=description.is_multi_channel, + feature_id=description.key, + use_description_name=description.is_multi_channel, ) - self.entity_description = description + if description.is_multi_channel: + self._attr_translation_placeholders = {"channel": str(channel.index)} async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -99,24 +114,15 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): @callback def _async_handle_event(self, *args, **kwargs) -> None: """Handle the event fired by the functional channel.""" - raised_channel_event = self._get_channel_event_from_args(*args) - - if not self._should_raise(raised_channel_event): + raw_channel_event_type = self._get_channel_event_from_args(*args) + public_event = self.entity_description.event_type_map.get( + raw_channel_event_type + ) + if public_event is None: return - - event_types = self.entity_description.event_types - if TYPE_CHECKING: - assert event_types is not None - - self._trigger_event(event_type=event_types[0]) + self._trigger_event(event_type=public_event) self.async_write_ha_state() - def _should_raise(self, event_type: str) -> bool: - """Check if the event should be raised.""" - if self.entity_description.channel_event_types is None: - return False - return event_type in self.entity_description.channel_event_types - def _get_channel_event_from_args(self, *args) -> str: """Get the channel event.""" if isinstance(args[0], ChannelEvent): diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 7d213e71e07..b003f9a085c 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,7 +1,5 @@ """Access point for the HomematicIP Cloud component.""" -from __future__ import annotations - import asyncio from collections.abc import Callable import logging @@ -183,7 +181,10 @@ class HomematicipHAP: await self.hass.config_entries.async_reload(self.config_entry.entry_id) async def _try_get_state(self) -> None: - """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + """Call get_state in a loop until no error occurs. + + Uses exponential backoff on error. + """ # Wait until WebSocket connection is established. while not self.home.websocket_is_connected(): @@ -223,7 +224,8 @@ class HomematicipHAP: ) else: _LOGGER.info( - "Updating state after HMIP access point reconnect finished successfully", + "Updating state after HMIP access point" + " reconnect finished successfully", ) def set_all_to_unavailable(self) -> None: @@ -284,9 +286,10 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" + """Handle websocket reconnection.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", + "Websocket connection to HomematicIP Cloud trying" + " to reconnect due to reason: %s", reason, ) diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 041b6eb54d8..151abf3f63a 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Homematicip Cloud Integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps import json @@ -46,7 +44,9 @@ def handle_errors[_HomematicipGenericEntityT: HomematicipGenericEntity, **_P]( json.dumps(result), ) raise HomeAssistantError( - f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information." + f"Error while execute function {func.__name__}:" + f" {result.get('errorCode')}." + " See log for more information." ) return inner diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 6affad00b3f..7336b98806f 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud lights.""" -from __future__ import annotations - import logging from typing import Any @@ -126,7 +124,7 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the light entity.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="light") @property def is_on(self) -> bool: @@ -147,7 +145,13 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: """Initialize the light entity.""" - super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + super().__init__( + hap, + device, + channel=channel_index, + is_multi_channel=True, + feature_id="color_light", + ) def _supports_color(self) -> bool: """Return true if device supports hue/saturation color control.""" @@ -243,7 +247,11 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity): ) -> None: """Initialize the dimmer light entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="dimmer", ) @property @@ -290,7 +298,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device, channel: int, post: str) -> None: """Initialize the notification light entity.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="notification_light", + ) self._color_switcher: dict[str, tuple[float, float]] = { RGBColorState.WHITE: (0.0, 0.0), @@ -335,11 +350,6 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): return state_attr - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._post}_{self._device.id}" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" # Use hs_color from kwargs, @@ -513,6 +523,7 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity): channel=channel_index, is_multi_channel=True, channel_real_index=channel_index, + feature_id="optical_signal_light", ) @property @@ -595,7 +606,7 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity): class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntity): - """Representation of the HomematicIP combination signalling device light (HmIP-MP3P).""" + """Representation of the HomematicIP combination signalling device light.""" _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} @@ -614,7 +625,13 @@ class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntit self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the combination signalling light entity.""" - super().__init__(hap, device, channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + channel=1, + is_multi_channel=False, + feature_id="combination_signalling_light", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index bae075e1a17..5a6d0b143ca 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud lock devices.""" -from __future__ import annotations - import logging from typing import Any @@ -13,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity -from .hap import HomematicIPConfigEntry +from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -53,6 +51,10 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): _attr_supported_features = LockEntityFeature.OPEN + def __init__(self, hap: HomematicipHAP, device: DoorLockDrive) -> None: + """Initialize the door lock drive.""" + super().__init__(hap, device, feature_id="lock") + @property def is_locked(self) -> bool | None: """Return true if device is locked.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index e8192660fe5..5ef2179bb61 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.7.0"] + "requirements": ["homematicip==2.12.0"] } diff --git a/homeassistant/components/homematicip_cloud/migration.py b/homeassistant/components/homematicip_cloud/migration.py new file mode 100644 index 00000000000..2dd9651dce1 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/migration.py @@ -0,0 +1,232 @@ +"""Unique ID migration for HomematicIP Cloud entities.""" + +from dataclasses import dataclass +import logging +import re + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _MigrationConfig: + """Configuration for migrating a single entity class to the new unique_id format.""" + + feature_id: str + channel: int | None = None + is_group: bool = False + + +UNIQUE_ID_MIGRATION_MAP: dict[str, _MigrationConfig] = { + # binary_sensor + "HomematicipCloudConnectionSensor": _MigrationConfig( + "cloud_connection", is_group=True + ), + "HomematicipAccelerationSensor": _MigrationConfig("acceleration", channel=1), + "HomematicipTiltVibrationSensor": _MigrationConfig("tilt_vibration", channel=1), + "HomematicipMultiContactInterface": _MigrationConfig("contact"), + "HomematicipContactInterface": _MigrationConfig("contact", channel=1), + "HomematicipShutterContact": _MigrationConfig("shutter_contact", channel=1), + "HomematicipMotionDetector": _MigrationConfig("motion", channel=1), + "HomematicipPresenceDetector": _MigrationConfig("presence", channel=1), + "HomematicipSmokeDetector": _MigrationConfig("smoke", channel=1), + "HomematicipWaterDetector": _MigrationConfig("water", channel=1), + "HomematicipStormSensor": _MigrationConfig("storm", channel=1), + "HomematicipRainSensor": _MigrationConfig("rain", channel=1), + "HomematicipSunshineSensor": _MigrationConfig("sunshine", channel=1), + "HomematicipBatterySensor": _MigrationConfig("battery", channel=0), + "HomematicipPluggableMainsFailureSurveillanceSensor": _MigrationConfig( + "mains_failure", channel=1 + ), + "HomematicipSecurityZoneSensorGroup": _MigrationConfig( + "security_zone", is_group=True + ), + "HomematicipSecuritySensorGroup": _MigrationConfig("security", is_group=True), + "HomematicipFullFlushLockControllerLocked": _MigrationConfig( + "lock_locked", channel=1 + ), + "HomematicipFullFlushLockControllerGlassBreak": _MigrationConfig( + "glass_break", channel=1 + ), + "HomematicipSmokeDetectorChamberDegraded": _MigrationConfig( + "chamber_degraded", channel=1 + ), + # sensor + "HomematicipAccesspointDutyCycle": _MigrationConfig("duty_cycle", channel=0), + "HomematicipHeatingThermostat": _MigrationConfig("valve_position", channel=1), + "HomematicipHumiditySensor": _MigrationConfig("humidity", channel=1), + "HomematicipTemperatureSensor": _MigrationConfig("temperature", channel=1), + "HomematicipAbsoluteHumiditySensor": _MigrationConfig( + "absolute_humidity", channel=1 + ), + "HomematicipIlluminanceSensor": _MigrationConfig("illuminance", channel=1), + "HomematicipPowerSensor": _MigrationConfig("power", channel=1), + "HomematicipEnergySensor": _MigrationConfig("energy", channel=1), + "HomematicipWindspeedSensor": _MigrationConfig("wind_speed", channel=1), + "HomematicipTodayRainSensor": _MigrationConfig("today_rain", channel=1), + "HomematicipPassageDetectorDeltaCounter": _MigrationConfig( + "passage_counter", channel=1 + ), + "HomematicipWaterFlowSensor": _MigrationConfig("water_flow"), + "HomematicipWaterVolumeSensor": _MigrationConfig("water_volume"), + "HomematicipWaterVolumeSinceOpenSensor": _MigrationConfig( + "water_volume_since_open" + ), + "HomematicipTiltAngleSensor": _MigrationConfig("tilt_angle", channel=1), + "HomematicipTiltStateSensor": _MigrationConfig("tilt_state", channel=1), + "HomematicipFloorTerminalBlockMechanicChannelValve": _MigrationConfig( + "ftb_valve_position" + ), + "HomematicpTemperatureExternalSensorCh1": _MigrationConfig( + "temperature_external_ch1", channel=1 + ), + "HomematicpTemperatureExternalSensorCh2": _MigrationConfig( + "temperature_external_ch2", channel=1 + ), + "HomematicpTemperatureExternalSensorDelta": _MigrationConfig( + "temperature_external_delta", channel=1 + ), + "HmipEsiIecPowerConsumption": _MigrationConfig("esi_iec_power", channel=1), + "HmipEsiIecEnergyCounterHighTariff": _MigrationConfig( + "esi_iec_energy_high", channel=1 + ), + "HmipEsiIecEnergyCounterLowTariff": _MigrationConfig( + "esi_iec_energy_low", channel=1 + ), + "HmipEsiIecEnergyCounterInputSingleTariff": _MigrationConfig( + "esi_iec_energy_input", channel=1 + ), + "HmipEsiGasCurrentGasFlow": _MigrationConfig("esi_gas_flow", channel=1), + "HmipEsiGasGasVolume": _MigrationConfig("esi_gas_volume", channel=1), + "HmipEsiLedCurrentPowerConsumption": _MigrationConfig("esi_led_power", channel=1), + "HmipEsiLedEnergyCounterHighTariff": _MigrationConfig( + "esi_led_energy_high", channel=1 + ), + "HomematicipSoilMoistureSensor": _MigrationConfig("soil_moisture", channel=1), + "HomematicipSoilTemperatureSensor": _MigrationConfig("soil_temperature", channel=1), + # light + "HomematicipLight": _MigrationConfig("light", channel=1), + "HomematicipLightHS": _MigrationConfig("light"), + "HomematicipLightMeasuring": _MigrationConfig("light", channel=1), + "HomematicipMultiDimmer": _MigrationConfig("dimmer"), + "HomematicipDimmer": _MigrationConfig("dimmer", channel=1), + "HomematicipNotificationLight": _MigrationConfig("notification_light"), + "HomematicipNotificationLightV2": _MigrationConfig("notification_light"), + "HomematicipColorLight": _MigrationConfig("color_light", channel=1), + "HomematicipOpticalSignalLight": _MigrationConfig( + "optical_signal_light", channel=1 + ), + "HomematicipCombinationSignallingLight": _MigrationConfig( + "combination_signalling_light", channel=1 + ), + # switch + "HomematicipMultiSwitch": _MigrationConfig("switch"), + "HomematicipSwitch": _MigrationConfig("switch", channel=1), + "HomematicipGroupSwitch": _MigrationConfig("switch", is_group=True), + "HomematicipSwitchMeasuring": _MigrationConfig("switch", channel=1), + # cover + "HomematicipBlindModule": _MigrationConfig("blind", channel=1), + "HomematicipMultiCoverShutter": _MigrationConfig("shutter"), + "HomematicipCoverShutter": _MigrationConfig("shutter", channel=1), + "HomematicipMultiCoverSlats": _MigrationConfig("slats"), + "HomematicipCoverSlats": _MigrationConfig("slats", channel=1), + "HomematicipGarageDoorModule": _MigrationConfig("garage_door", channel=1), + "HomematicipCoverShutterGroup": _MigrationConfig("shutter", is_group=True), + # climate + "HomematicipHeatingGroup": _MigrationConfig("climate", is_group=True), + # weather + "HomematicipWeatherSensor": _MigrationConfig("weather", channel=1), + "HomematicipWeatherSensorPro": _MigrationConfig("weather", channel=1), + "HomematicipHomeWeather": _MigrationConfig("home_weather", is_group=True), + # valve + "HomematicipWateringValve": _MigrationConfig("watering"), + # lock + "HomematicipDoorLockDrive": _MigrationConfig("lock", channel=1), + # button + "HomematicipGarageDoorControllerButton": _MigrationConfig( + "garage_button", channel=1 + ), + "HomematicipFullFlushLockControllerButton": _MigrationConfig( + "lock_opener_button", channel=1 + ), + # event + "HomematicipDoorBellEvent": _MigrationConfig("doorbell", channel=1), + # alarm_control_panel + "HomematicipAlarmControlPanelEntity": _MigrationConfig("alarm", is_group=True), + # siren + "HomematicipMP3Siren": _MigrationConfig("siren", channel=1), +} + +# Sorted by length descending so longer class names match before shorter ones +# (e.g., "HomematicipSwitchMeasuring" before "HomematicipSwitch") +_SORTED_CLASS_NAMES = sorted(UNIQUE_ID_MIGRATION_MAP, key=len, reverse=True) + +_CHANNEL_RE = re.compile(r"^Channel(\d+)_(.+)$") +_NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$") + +_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3} + + +def _match_legacy_class_name(old_unique_id: str) -> str | None: + """Return the legacy class name that prefixes ``old_unique_id``, if any.""" + for class_name in _SORTED_CLASS_NAMES: + if old_unique_id.startswith(class_name + "_"): + return class_name + return None + + +def _migrate_unique_id(old_unique_id: str) -> str | None: + """Convert an old-format unique_id to the new format. + + Old formats: + {ClassName}_{device_id} + {ClassName}_Channel{N}_{device_id} + {ClassName}_{Top|Bottom}_{device_id} (NotificationLight only) + + New format: + {device_id}_{channel}_{feature_id} (device entities) + {device_id}_{feature_id} (group/home entities) + """ + matched_class = _match_legacy_class_name(old_unique_id) + if matched_class is None: + return None + + config = UNIQUE_ID_MIGRATION_MAP[matched_class] + remainder = old_unique_id[len(matched_class) + 1 :] + + # Parse remainder to extract channel and device_id + channel: int | None = None + device_id: str + + # Check for Channel{N}_{rest} pattern + channel_match = _CHANNEL_RE.match(remainder) + if channel_match: + channel = int(channel_match.group(1)) + device_id = channel_match.group(2) + elif matched_class in ( + "HomematicipNotificationLight", + "HomematicipNotificationLightV2", + ): + # Check for Top/Bottom pattern + notif_match = _NOTIFICATION_LIGHT_RE.match(remainder) + if notif_match: + channel = _NOTIFICATION_LIGHT_CHANNEL_MAP[notif_match.group(1)] + device_id = notif_match.group(2) + else: + device_id = remainder + channel = config.channel + else: + device_id = remainder + channel = config.channel + + # Build new unique_id + if config.is_group: + return f"{device_id}_{config.feature_id}" + + if channel is not None: + return f"{device_id}_{channel}_{config.feature_id}" + + _LOGGER.warning( + "Cannot determine channel for unique_id: %s", + old_unique_id, + ) + return None diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 211dddd8811..34222a51af7 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -383,7 +381,14 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity): self, hap: HomematicipHAP, device: Device, channel: int, post: str ) -> None: """Initialize the watering flow sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="water_flow", + ) @property def native_value(self) -> float | None: @@ -405,9 +410,17 @@ class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity): channel: int, post: str, attribute: str, + feature_id: str = "water_volume", ) -> None: """Initialize the watering volume sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id=feature_id, + ) self._attribute_name = attribute @property @@ -430,6 +443,7 @@ class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor): channel=channel, post="waterVolumeSinceOpen", attribute="waterVolumeSinceOpen", + feature_id="water_volume_since_open", ) @@ -441,7 +455,7 @@ class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt angle sensor device.""" - super().__init__(hap, device, post="Tilt Angle") + super().__init__(hap, device, post="Tilt Angle", feature_id="tilt_angle") @property def native_value(self) -> int | None: @@ -458,7 +472,7 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt sensor device.""" - super().__init__(hap, device, post="Tilt State") + super().__init__(hap, device, post="Tilt State", feature_id="tilt_state") @property def native_value(self) -> str | None: @@ -502,6 +516,7 @@ class HomematicipFloorTerminalBlockMechanicChannelValve( channel=channel, is_multi_channel=is_multi_channel, post="Valve Position", + feature_id="ftb_valve_position", ) @property @@ -520,7 +535,7 @@ class HomematicipFloorTerminalBlockMechanicChannelValve( @property def native_value(self) -> int | None: - """Return the state of the floor terminal block mechanical channel valve position.""" + """Return the floor terminal block valve position.""" channel = next( channel for channel in self._device.functionalChannels @@ -540,7 +555,9 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize access point status entity.""" - super().__init__(hap, device, post="Duty Cycle") + super().__init__( + hap, device, post="Duty Cycle", channel=0, feature_id="duty_cycle" + ) @property def native_value(self) -> float: @@ -555,7 +572,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize heating thermostat device.""" - super().__init__(hap, device, post="Heating") + super().__init__(hap, device, post="Heating", feature_id="valve_position") @property def icon(self) -> str | None: @@ -583,7 +600,7 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Humidity") + super().__init__(hap, device, post="Humidity", feature_id="humidity") @property def native_value(self) -> int: @@ -600,7 +617,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Temperature") + super().__init__(hap, device, post="Temperature", feature_id="temperature") @property def native_value(self) -> float: @@ -633,7 +650,9 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Absolute Humidity") + super().__init__( + hap, device, post="Absolute Humidity", feature_id="absolute_humidity" + ) @property def native_value(self) -> float | None: @@ -654,7 +673,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Illuminance") + super().__init__(hap, device, post="Illuminance", feature_id="illuminance") @property def native_value(self) -> float: @@ -685,7 +704,7 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Power") + super().__init__(hap, device, post="Power", feature_id="power") @property def native_value(self) -> float: @@ -702,7 +721,7 @@ class HomematicipEnergySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Energy") + super().__init__(hap, device, post="Energy", feature_id="energy") @property def native_value(self) -> float: @@ -719,7 +738,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the windspeed sensor.""" - super().__init__(hap, device, post="Windspeed") + super().__init__(hap, device, post="Windspeed", feature_id="wind_speed") @property def native_value(self) -> float: @@ -751,7 +770,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Today Rain") + super().__init__(hap, device, post="Today Rain", feature_id="today_rain") @property def native_value(self) -> float: @@ -768,7 +787,12 @@ class HomematicpTemperatureExternalSensorCh1(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 1 Temperature") + super().__init__( + hap, + device, + post="Channel 1 Temperature", + feature_id="temperature_external_ch1", + ) @property def native_value(self) -> float: @@ -785,7 +809,12 @@ class HomematicpTemperatureExternalSensorCh2(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 2 Temperature") + super().__init__( + hap, + device, + post="Channel 2 Temperature", + feature_id="temperature_external_ch2", + ) @property def native_value(self) -> float: @@ -802,7 +831,12 @@ class HomematicpTemperatureExternalSensorDelta(HomematicipGenericEntity, SensorE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Delta Temperature") + super().__init__( + hap, + device, + post="Delta Temperature", + feature_id="temperature_external_delta", + ) @property def native_value(self) -> float: @@ -820,6 +854,7 @@ class HmipEsiSensorEntity(HomematicipGenericEntity, SensorEntity): key: str, value_fn: Callable[[FunctionalChannel], StateType], type_fn: Callable[[FunctionalChannel], str], + feature_id: str, ) -> None: """Initialize Sensor Entity.""" super().__init__( @@ -828,6 +863,7 @@ class HmipEsiSensorEntity(HomematicipGenericEntity, SensorEntity): channel=1, post=key, is_multi_channel=False, + feature_id=feature_id, ) self._value_fn = value_fn @@ -862,6 +898,7 @@ class HmipEsiIecPowerConsumption(HmipEsiSensorEntity): key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_iec_power", ) @@ -880,6 +917,7 @@ class HmipEsiIecEnergyCounterHighTariff(HmipEsiSensorEntity): key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: channel.energyCounterOneType, + feature_id="esi_iec_energy_high", ) @@ -898,6 +936,7 @@ class HmipEsiIecEnergyCounterLowTariff(HmipEsiSensorEntity): key=ESI_TYPE_ENERGY_COUNTER_USAGE_LOW_TARIFF, value_fn=lambda channel: channel.energyCounterTwo, type_fn=lambda channel: channel.energyCounterTwoType, + feature_id="esi_iec_energy_low", ) @@ -916,6 +955,7 @@ class HmipEsiIecEnergyCounterInputSingleTariff(HmipEsiSensorEntity): key=ESI_TYPE_ENERGY_COUNTER_INPUT_SINGLE_TARIFF, value_fn=lambda channel: channel.energyCounterThree, type_fn=lambda channel: channel.energyCounterThreeType, + feature_id="esi_iec_energy_input", ) @@ -934,6 +974,7 @@ class HmipEsiGasCurrentGasFlow(HmipEsiSensorEntity): key="CurrentGasFlow", value_fn=lambda channel: channel.currentGasFlow, type_fn=lambda channel: "CurrentGasFlow", + feature_id="esi_gas_flow", ) @@ -952,6 +993,7 @@ class HmipEsiGasGasVolume(HmipEsiSensorEntity): key="GasVolume", value_fn=lambda channel: channel.gasVolume, type_fn=lambda channel: "GasVolume", + feature_id="esi_gas_volume", ) @@ -970,6 +1012,7 @@ class HmipEsiLedCurrentPowerConsumption(HmipEsiSensorEntity): key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_led_power", ) @@ -988,12 +1031,17 @@ class HmipEsiLedEnergyCounterHighTariff(HmipEsiSensorEntity): key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, + feature_id="esi_led_energy_high", ) class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP passage detector delta counter.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the passage detector delta counter.""" + super().__init__(hap, device, feature_id="passage_counter") + @property def native_value(self) -> int: """Return the passage detector delta counter value.""" @@ -1022,7 +1070,7 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity): description: HmipSmokeDetectorSensorDescription, ) -> None: """Initialize the smoke detector sensor.""" - super().__init__(hap, device, post=description.key) + super().__init__(hap, device, feature_id="smoke_detector_sensor") self.entity_description = description self._sensor_unique_id = f"{device.id}_{description.key}" @@ -1047,7 +1095,12 @@ class HomematicipSoilMoistureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil moisture sensor device.""" super().__init__( - hap, device, post="Soil Moisture", channel=1, is_multi_channel=True + hap, + device, + post="Soil Moisture", + channel=1, + is_multi_channel=True, + feature_id="soil_moisture", ) @property @@ -1068,7 +1121,12 @@ class HomematicipSoilTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil temperature sensor device.""" super().__init__( - hap, device, post="Soil Temperature", channel=1, is_multi_channel=True + hap, + device, + post="Soil Temperature", + channel=1, + is_multi_channel=True, + feature_id="soil_temperature", ) @property diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 9e663ae5490..275afc294c1 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud devices.""" -from __future__ import annotations - import logging from pathlib import Path diff --git a/homeassistant/components/homematicip_cloud/siren.py b/homeassistant/components/homematicip_cloud/siren.py index 5fb4d73a27b..cffbec0975c 100644 --- a/homeassistant/components/homematicip_cloud/siren.py +++ b/homeassistant/components/homematicip_cloud/siren.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud sirens.""" -from __future__ import annotations - import logging from typing import Any @@ -60,7 +58,14 @@ class HomematicipMP3Siren(HomematicipGenericEntity, SirenEntity): self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the siren entity.""" - super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + post="Siren", + channel=1, + is_multi_channel=False, + feature_id="siren", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 6fe481dd673..51664f5bd90 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -37,6 +37,31 @@ } }, "entity": { + "binary_sensor": { + "chamber_degraded": { + "name": "Chamber degraded" + }, + "cloud_connection": { + "name": "Cloud connection" + }, + "raining": { + "name": "Raining" + } + }, + "event": { + "button": { + "name": "Button {channel}", + "state_attributes": { + "event_type": { + "state": { + "long_press": "Long press", + "long_release": "Long release", + "short_release": "Short release" + } + } + } + } + }, "light": { "optical_signal_light": { "state_attributes": { diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 59216c904a4..50f515684bb 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud switches.""" -from __future__ import annotations - from typing import Any from homematicip.base.enums import DeviceType, FunctionalChannelType @@ -109,7 +107,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): ) -> None: """Initialize the multi switch device.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="switch", ) @property @@ -140,10 +142,12 @@ class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): """Representation of the HomematicIP switching group.""" + _attr_has_entity_name = False + def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post) + super().__init__(hap, device, post, feature_id="switch") @property def is_on(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py index a97ec157d17..d759b7cf242 100644 --- a/homeassistant/components/homematicip_cloud/valve.py +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -42,7 +42,12 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: """Initialize the valve.""" super().__init__( - hap, device=device, channel=channel, post="watering", is_multi_channel=True + hap, + device=device, + channel=channel, + post="watering", + is_multi_channel=True, + feature_id="watering", ) async def async_open_valve(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 061f6642bb2..0367f2bb89e 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud weather devices.""" -from __future__ import annotations - from homematicip.base.enums import WeatherCondition from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro @@ -37,7 +35,9 @@ HOME_WEATHER_CONDITION = { WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW: ATTR_CONDITION_SNOWY, WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW_RAIN: ATTR_CONDITION_SNOWY_RAINY, WeatherCondition.HEAVILY_CLOUDY_WITH_THUNDER: ATTR_CONDITION_LIGHTNING, - WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN_AND_THUNDER: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN_AND_THUNDER: ( + ATTR_CONDITION_LIGHTNING_RAINY + ), WeatherCondition.FOGGY: ATTR_CONDITION_FOG, WeatherCondition.STRONG_WIND: ATTR_CONDITION_WINDY, WeatherCondition.UNKNOWN: "", @@ -72,12 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" - super().__init__(hap, device) - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._device.label + super().__init__(hap, device, feature_id="weather") @property def native_temperature(self) -> float: @@ -118,6 +113,7 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor): class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): """Representation of the HomematicIP home weather.""" + _attr_has_entity_name = False _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR _attr_attribution = "Powered by Homematic IP" @@ -125,7 +121,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" hap.home.modelType = "HmIP-Home-Weather" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="home_weather") @property def available(self) -> bool: diff --git a/homeassistant/components/homevolt/__init__.py b/homeassistant/components/homevolt/__init__.py index fb0f3093b28..7a999b51084 100644 --- a/homeassistant/components/homevolt/__init__.py +++ b/homeassistant/components/homevolt/__init__.py @@ -1,7 +1,5 @@ """The Homevolt integration.""" -from __future__ import annotations - from homevolt import Homevolt from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform diff --git a/homeassistant/components/homevolt/config_flow.py b/homeassistant/components/homevolt/config_flow.py index 25acbc65312..ad2f2a293e6 100644 --- a/homeassistant/components/homevolt/config_flow.py +++ b/homeassistant/components/homevolt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Homevolt integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/homevolt/const.py b/homeassistant/components/homevolt/const.py index d700bd8fc45..69173018503 100644 --- a/homeassistant/components/homevolt/const.py +++ b/homeassistant/components/homevolt/const.py @@ -1,7 +1,5 @@ """Constants for the Homevolt integration.""" -from __future__ import annotations - from datetime import timedelta DOMAIN = "homevolt" diff --git a/homeassistant/components/homevolt/coordinator.py b/homeassistant/components/homevolt/coordinator.py index 0109d4df9f2..4e8b9f59ccd 100644 --- a/homeassistant/components/homevolt/coordinator.py +++ b/homeassistant/components/homevolt/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Homevolt integration.""" -from __future__ import annotations - import logging from homevolt import ( diff --git a/homeassistant/components/homevolt/diagnostics.py b/homeassistant/components/homevolt/diagnostics.py index 4d3c3907c61..a643d2a108f 100644 --- a/homeassistant/components/homevolt/diagnostics.py +++ b/homeassistant/components/homevolt/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Homevolt.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/homevolt/entity.py b/homeassistant/components/homevolt/entity.py index 7cfb14aa083..aa029b047b4 100644 --- a/homeassistant/components/homevolt/entity.py +++ b/homeassistant/components/homevolt/entity.py @@ -1,7 +1,5 @@ """Shared entity helpers for Homevolt.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate @@ -52,12 +50,14 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P]( translation_key="auth_failed", ) from error except HomevoltConnectionError as error: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="communication_error", translation_placeholders={"error": str(error)}, ) from error except HomevoltError as error: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="unknown_error", diff --git a/homeassistant/components/homevolt/sensor.py b/homeassistant/components/homevolt/sensor.py index 9140fd3f64e..4710270f6ca 100644 --- a/homeassistant/components/homevolt/sensor.py +++ b/homeassistant/components/homevolt/sensor.py @@ -1,7 +1,5 @@ """Support for Homevolt sensors.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/homevolt/switch.py b/homeassistant/components/homevolt/switch.py index 1ce3efc1237..c2dc63a28a1 100644 --- a/homeassistant/components/homevolt/switch.py +++ b/homeassistant/components/homevolt/switch.py @@ -1,7 +1,5 @@ """Support for Homevolt switch entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index fdc571e1dca..50ca88bca4a 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -84,8 +84,10 @@ def get_main_device( if not device_entries: return None - # Get first device that is not a sub-device, as this is the main device in HomeWizard - # This is relevant for the P1 Meter which may create sub-devices for external utility meters + # Get first device that is not a sub-device, as this is the + # main device in HomeWizard. This is relevant for the P1 + # Meter which may create sub-devices for external utility + # meters. return next( (device for device in device_entries if device.via_device_id is None), None ) @@ -102,7 +104,9 @@ async def async_check_v2_support_and_create_issue( title = entry.title # Try to get the name from the device registry - # This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time + # This is to make it clearer which device needs + # reconfiguration, as the config entry title is kept default + # most of the time if main_device := get_main_device(hass, entry): device_name = main_device.name_by_user or main_device.name diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 68dc54aef0e..1a540f03e5b 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -1,7 +1,5 @@ """Config flow for HomeWizard.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -88,8 +86,9 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Step where we attempt to get a token.""" assert self.ip_address - # Tell device we want a token, user must now press the button within 30 seconds - # The first attempt will always fail, but this opens the window to press the button + # Tell device we want a token, user must now press the + # button within 30 seconds. The first attempt will always + # fail, but this opens the window to press the button. token = await async_request_token(self.hass, self.ip_address) errors: dict[str, str] | None = None @@ -219,7 +218,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Handle re-auth if API was disabled.""" self.ip_address = entry_data[CONF_IP_ADDRESS] - # If token exists, we assume we use the v2 API and that the token has been invalidated + # If token exists, we assume we use the v2 API and that + # the token has been invalidated if entry_data.get(CONF_TOKEN): return await self.async_step_reauth_confirm_update_token() @@ -229,7 +229,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" + """Confirm reauth dialog to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ed1c140a23b..05f97925db6 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -1,13 +1,12 @@ """Constants for the Homewizard integration.""" -from __future__ import annotations - from datetime import timedelta import logging from homeassistant.const import Platform DOMAIN = "homewizard" +ISSUE_BATTERY_MODE_CLOUD_DISABLED = "battery_mode_cloud_disabled" PLATFORMS = [ Platform.BUTTON, Platform.NUMBER, @@ -24,3 +23,8 @@ CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" UPDATE_INTERVAL = timedelta(seconds=5) + + +def battery_mode_cloud_issue_id(entry_id: str) -> str: + """Build issue id for battery mode/cloud incompatibility.""" + return f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_{entry_id}" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index e87381c5fa9..f7a94ea7a46 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -1,17 +1,22 @@ """Update coordinator for HomeWizard.""" -from __future__ import annotations - from homewizard_energy import HomeWizardEnergy from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError -from homewizard_energy.models import CombinedModels as DeviceResponseEntry +from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import ( + DOMAIN, + ISSUE_BATTERY_MODE_CLOUD_DISABLED, + LOGGER, + UPDATE_INTERVAL, + battery_mode_cloud_issue_id, +) type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator] @@ -40,12 +45,41 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] ) self.api = api + def _update_battery_mode_cloud_repair_issue( + self, data: DeviceResponseEntry + ) -> None: + """Update repair issue for incompatible battery mode and cloud state.""" + battery_mode_cloud_issue_active = ( + data.batteries is not None + and data.system is not None + and data.batteries.mode == Batteries.Mode.PREDICTIVE.value + and data.system.cloud_enabled is False + ) + issue_id = battery_mode_cloud_issue_id(self.config_entry.entry_id) + issue_exists = ( + ir.async_get(self.hass).async_get_issue(DOMAIN, issue_id) is not None + ) + if battery_mode_cloud_issue_active and not issue_exists: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=False, + translation_key=ISSUE_BATTERY_MODE_CLOUD_DISABLED, + severity=ir.IssueSeverity.WARNING, + data={"entry_id": self.config_entry.entry_id}, + ) + elif not battery_mode_cloud_issue_active and issue_exists: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" try: data = await self.api.combined() except RequestError as ex: + # pylint: disable-next=home-assistant-exception-message-with-translation raise UpdateFailed( ex, translation_domain=DOMAIN, translation_key="communication_error" ) from ex @@ -56,11 +90,13 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] # Do not reload when performing first refresh if self.data is not None: - # Reload config entry to let init flow handle retrying and trigger repair flow + # Reload config entry to let init flow handle + # retrying and trigger repair flow self.hass.config_entries.async_schedule_reload( self.config_entry.entry_id ) + # pylint: disable-next=home-assistant-exception-message-with-translation raise UpdateFailed( ex, translation_domain=DOMAIN, translation_key="api_disabled" ) from ex @@ -69,6 +105,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] raise ConfigEntryAuthFailed from ex self.api_disabled = False + self._update_battery_mode_cloud_repair_issue(data) self.data = data return data diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a3ae2555173..027a565bd49 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for P1 Monitor.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 1090f561838..3523f4b9721 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -1,8 +1,6 @@ """Base entity for the HomeWizard integration.""" -from __future__ import annotations - -from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_SERIAL_NUMBER from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -30,3 +28,4 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): (CONNECTION_NETWORK_MAC, serial_number) } self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} + self._attr_device_info[ATTR_SERIAL_NUMBER] = serial_number diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 6197ec73e20..d6bb1cf977a 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -1,7 +1,5 @@ """Helpers for HomeWizard.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index c008ec02b0a..a1741d5d231 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -13,6 +13,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==10.0.1"], + "requirements": ["python-homewizard-energy==10.1.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index a4c5c5c64a0..81ec32a7c1a 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -1,7 +1,5 @@ """Creates HomeWizard Number entities.""" -from __future__ import annotations - from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py index 60790202032..604a6e7e2b3 100644 --- a/homeassistant/components/homewizard/repairs.py +++ b/homeassistant/components/homewizard/repairs.py @@ -1,15 +1,18 @@ """Repairs for HomeWizard integration.""" -from __future__ import annotations +from homewizard_energy.errors import RequestError -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult from .config_flow import async_request_token +from .const import ISSUE_BATTERY_MODE_CLOUD_DISABLED class MigrateToV2ApiRepairFlow(RepairsFlow): @@ -21,14 +24,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: @@ -40,13 +43,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): async def async_step_authorize( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the authorize step of a fix flow.""" ip_address = self.entry.data[CONF_IP_ADDRESS] - # Tell device we want a token, user must now press the button within 30 seconds - # The first attempt will always fail, but this opens the window to press the button + # Tell device we want a token, user must now press the + # button within 30 seconds. The first attempt will always + # fail, but this opens the window to press the button. token = await async_request_token(self.hass, ip_address) errors: dict[str, str] | None = None @@ -62,18 +66,54 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): return self.async_create_entry(data={}) +class BatteryModeCloudDisabledRepairFlow(RepairsFlow): + """Handler for a battery mode/cloud incompatibility fix flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + self.entry = entry + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the confirm step of a fix flow.""" + errors: dict[str, str] | None = None + if user_input is not None: + coordinator = self.entry.runtime_data + try: + await coordinator.api.system(cloud_enabled=True) + except RequestError: + errors = {"base": "network_error"} + else: + await coordinator.async_refresh() + return self.async_create_entry(data={}) + + return self.async_show_form(step_id="confirm", errors=errors) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" - assert data is not None - assert isinstance(data["entry_id"], str) + if data is None or not isinstance(entry_id := data.get("entry_id"), str): + return ConfirmRepairFlow() if issue_id.startswith("migrate_to_v2_api_") and ( - entry := hass.config_entries.async_get_entry(data["entry_id"]) + entry := hass.config_entries.async_get_entry(entry_id) ): return MigrateToV2ApiRepairFlow(entry) + if issue_id.startswith(f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_") and ( + entry := hass.config_entries.async_get_entry(entry_id) + ): + return BatteryModeCloudDisabledRepairFlow(entry) + raise ValueError(f"unknown repair {issue_id}") # pragma: no cover diff --git a/homeassistant/components/homewizard/select.py b/homeassistant/components/homewizard/select.py index 53a6e7c3a6f..61bee78d397 100644 --- a/homeassistant/components/homewizard/select.py +++ b/homeassistant/components/homewizard/select.py @@ -1,7 +1,5 @@ """Support for HomeWizard select platform.""" -from __future__ import annotations - from homewizard_energy.models import Batteries from homeassistant.components.select import SelectEntity, SelectEntityDescription diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 3d15a34c7e7..7e2b94d0a00 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -1,7 +1,5 @@ """Creates HomeWizard sensor entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -37,7 +35,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from homeassistant.util.variance import ignore_variance from .const import DOMAIN from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator @@ -68,13 +65,6 @@ def to_percentage(value: float | None) -> float | None: return value * 100 if value is not None else None -def uptime_to_datetime(value: int) -> datetime: - """Convert seconds to datetime timestamp.""" - return utcnow().replace(microsecond=0) - timedelta(seconds=value) - - -uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5)) - SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", @@ -642,10 +632,36 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.measurement.cycles is not None, value_fn=lambda data: data.measurement.cycles, ), + HomeWizardSensorEntityDescription( + key="battery_group_power_w", + translation_key="battery_group_power_w", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + has_fn=lambda data: data.batteries is not None, + value_fn=lambda data: ( + data.batteries.power_w if data.batteries is not None else None + ), + ), + HomeWizardSensorEntityDescription( + key="battery_group_target_power_w", + translation_key="battery_group_target_power_w", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + has_fn=lambda data: data.batteries is not None, + value_fn=lambda data: ( + data.batteries.target_power_w if data.batteries is not None else None + ), + ), HomeWizardSensorEntityDescription( key="uptime", translation_key="uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, has_fn=( @@ -653,7 +669,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), value_fn=( lambda data: ( - uptime_to_stable_datetime(data.system.uptime_s) + utcnow() - timedelta(seconds=data.system.uptime_s) if data.system is not None and data.system.uptime_s is not None else None ) @@ -675,11 +691,13 @@ EXTERNAL_SENSORS = { state_class=SensorStateClass.TOTAL_INCREASING, device_name="Heat meter", ), - ExternalDevice.DeviceType.WARM_WATER_METER: HomeWizardExternalSensorEntityDescription( - key="warm_water_meter", - suggested_device_class=SensorDeviceClass.WATER, - state_class=SensorStateClass.TOTAL_INCREASING, - device_name="Warm water meter", + ExternalDevice.DeviceType.WARM_WATER_METER: ( + HomeWizardExternalSensorEntityDescription( + key="warm_water_meter", + suggested_device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Warm water meter", + ) ), ExternalDevice.DeviceType.WATER_METER: HomeWizardExternalSensorEntityDescription( key="water_meter", @@ -687,11 +705,13 @@ EXTERNAL_SENSORS = { state_class=SensorStateClass.TOTAL_INCREASING, device_name="Water meter", ), - ExternalDevice.DeviceType.INLET_HEAT_METER: HomeWizardExternalSensorEntityDescription( - key="inlet_heat_meter", - suggested_device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - device_name="Inlet heat meter", + ExternalDevice.DeviceType.INLET_HEAT_METER: ( + HomeWizardExternalSensorEntityDescription( + key="inlet_heat_meter", + suggested_device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Inlet heat meter", + ) ), } diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 5c03df7fa10..475bff8640f 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -61,13 +61,14 @@ }, "select": { "battery_group_mode": { - "name": "Battery group mode", + "name": "Battery group charging strategy", "state": { + "predictive": "Smart charging", "standby": "Standby", - "to_full": "Manual charge mode", - "zero": "Zero mode", - "zero_charge_only": "Zero mode (charge only)", - "zero_discharge_only": "Zero mode (discharge only)" + "to_full": "One-time full charge", + "zero": "Net zero", + "zero_charge_only": "Net zero (charge only)", + "zero_discharge_only": "Net zero (discharge only)" } } }, @@ -105,6 +106,12 @@ "any_power_fail_count": { "name": "Power failures detected" }, + "battery_group_power_w": { + "name": "Battery group power" + }, + "battery_group_target_power_w": { + "name": "Battery group target power" + }, "cycles": { "name": "Battery cycles" }, @@ -181,6 +188,20 @@ } }, "issues": { + "battery_mode_cloud_disabled": { + "fix_flow": { + "error": { + "network_error": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "confirm": { + "description": "Smart charging strategy is enabled for your battery group, but cloud connection is disabled. These settings are not compatible, as smart charging requires cloud connectivity.\n\nSelect **Submit** to enable cloud connection.", + "title": "[%key:component::homewizard::issues::battery_mode_cloud_disabled::title%]" + } + } + }, + "title": "Enable cloud connection for smart charging strategy" + }, "migrate_to_v2_api": { "fix_flow": { "error": { diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 786475c26f7..db9f7a8093b 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -1,7 +1,5 @@ """Creates HomeWizard switch entities.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 4beea27374a..0a4a31f500b 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -1,7 +1,5 @@ """Support for Lutron Homeworks Series 4 and 8 systems.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import logging @@ -18,6 +16,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_COMMAND, CONF_HOST, CONF_ID, CONF_NAME, @@ -41,8 +40,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] -CONF_COMMAND = "command" - EVENT_BUTTON_PRESS = "homeworks_button_press" EVENT_BUTTON_RELEASE = "homeworks_button_release" diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py index 9c2b2e12bc2..6ca84b4c3a4 100644 --- a/homeassistant/components/homeworks/binary_sensor.py +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Lutron Homeworks binary sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index 47c92a323ee..b2fefc824cd 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -1,7 +1,5 @@ """Support for Lutron Homeworks buttons.""" -from __future__ import annotations - import asyncio from pyhomeworks.pyhomeworks import Homeworks diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index d1fa7774ef6..aa82b1b7509 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -1,6 +1,5 @@ """Lutron Homeworks Series 4 and 8 config flow.""" - -from __future__ import annotations +# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas from functools import partial import logging diff --git a/homeassistant/components/homeworks/const.py b/homeassistant/components/homeworks/const.py index 8baf1b6299d..db83f20d167 100644 --- a/homeassistant/components/homeworks/const.py +++ b/homeassistant/components/homeworks/const.py @@ -1,7 +1,5 @@ """Constants for the Lutron Homeworks integration.""" -from __future__ import annotations - DOMAIN = "homeworks" CONF_ADDR = "addr" diff --git a/homeassistant/components/homeworks/entity.py b/homeassistant/components/homeworks/entity.py index 49abfb9241e..434aae217d6 100644 --- a/homeassistant/components/homeworks/entity.py +++ b/homeassistant/components/homeworks/entity.py @@ -1,7 +1,5 @@ """Support for Lutron Homeworks Series 4 and 8 systems.""" -from __future__ import annotations - from pyhomeworks.pyhomeworks import Homeworks from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index a9ed35f859e..718d2ed6c27 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -1,7 +1,5 @@ """Support for Lutron Homeworks lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 5fe84aadd75..2d58b0cd3ac 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,7 +1,5 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" -from __future__ import annotations - import datetime from typing import Any @@ -151,8 +149,9 @@ def remove_stale_devices( break if device_id is None or device_id not in all_device_ids: - # If device_id is None an invalid device entry was found for this config entry. - # If the device_id is not in existing device ids it's a stale device entry. + # If device_id is None an invalid device entry was + # found for this config entry. If the device_id is not + # in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry.entry_id @@ -473,7 +472,9 @@ class HoneywellUSThermostat(ClimateEntity): except SomeComfortError as err: _LOGGER.error( - "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", + "Temperature out of range. Mode: %s," + " Heat Temperature: %.1f," + " Cool Temperature: %.1f", mode, self._heat_away_temp, self._cool_away_temp, diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c18bb0296aa..6e61a359992 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the honeywell integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py index b266e06d110..0bf2ad370dd 100644 --- a/homeassistant/components/honeywell/diagnostics.py +++ b/homeassistant/components/honeywell/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Honeywell.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py index 77776f84a2e..54bfe5518c5 100644 --- a/homeassistant/components/honeywell/humidifier.py +++ b/homeassistant/components/honeywell/humidifier.py @@ -1,7 +1,5 @@ """Support for Honeywell (de)humidifiers.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 75ac6b1b6d3..0b346e6fb4a 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -1,7 +1,5 @@ """Support for Honeywell (US) Total Connect Comfort sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py index 06c79bf4b1d..1f4b96d7040 100644 --- a/homeassistant/components/honeywell/switch.py +++ b/homeassistant/components/honeywell/switch.py @@ -1,7 +1,5 @@ """Support for Honeywell switches.""" -from __future__ import annotations - from typing import Any from aiosomecomfort import SomeComfortError diff --git a/homeassistant/components/honeywell_string_lights/__init__.py b/homeassistant/components/honeywell_string_lights/__init__.py new file mode 100644 index 00000000000..06917e0327a --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/__init__.py @@ -0,0 +1,18 @@ +"""The Honeywell String Lights integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Honeywell String Lights from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/honeywell_string_lights/config_flow.py b/homeassistant/components/honeywell_string_lights/config_flow.py new file mode 100644 index 00000000000..015f621022b --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -0,0 +1,65 @@ +"""Config flow for the Honeywell String Lights integration.""" + +from typing import Any + +from rf_protocols import RadioFrequencyCommand +from rf_protocols.codes.honeywell.string_lights import CODES +import voluptuous as vol + +from homeassistant.components.radio_frequency import async_get_transmitters +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .const import CONF_TRANSMITTER, DOMAIN + + +class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Honeywell String Lights.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job( + CODES.load_command, "turn_on" + ) + try: + transmitters = async_get_transmitters( + self.hass, sample_command.frequency, sample_command.modulation + ) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort( + reason="no_compatible_transmitters", + description_placeholders={ + "frequency": f"{sample_command.frequency / 1_000_000} MHz", + "modulation": sample_command.modulation.name, + }, + ) + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + await self.async_set_unique_id(entity_entry.id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Honeywell String Lights", + data={CONF_TRANSMITTER: entity_entry.id}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_TRANSMITTER): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + } + ), + ) diff --git a/homeassistant/components/honeywell_string_lights/const.py b/homeassistant/components/honeywell_string_lights/const.py new file mode 100644 index 00000000000..4e925cb6ce6 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/const.py @@ -0,0 +1,7 @@ +"""Constants for the Honeywell String Lights integration.""" + +from typing import Final + +DOMAIN: Final = "honeywell_string_lights" + +CONF_TRANSMITTER: Final = "transmitter" diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py new file mode 100644 index 00000000000..90816651311 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -0,0 +1,74 @@ +"""Common entity for Honeywell String Lights integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HoneywellStringLightsEntity(Entity): + """Honeywell String Lights base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Honeywell", + model="String Lights", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + # Set initial availability based on current transmitter entity state + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py new file mode 100644 index 00000000000..24dfe7adc63 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -0,0 +1,61 @@ +"""Light platform for Honeywell String Lights.""" + +from typing import Any + +from rf_protocols.codes.honeywell.string_lights import CODES + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .entity import HoneywellStringLightsEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Honeywell String Lights light platform.""" + async_add_entities([HoneywellStringLight(config_entry)]) + + +class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity): + """Representation of a Honeywell String Lights set controlled via RF.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_name = None + _attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """Restore last known state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self._async_send_command("turn_on") + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._async_send_command("turn_off") + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send_command(self, name: str) -> None: + """Load the named command and send it via the configured transmitter.""" + command = await CODES.async_load_command(name) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/honeywell_string_lights/manifest.json b/homeassistant/components/honeywell_string_lights/manifest.json new file mode 100644 index 00000000000..af3a3061c1b --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "honeywell_string_lights", + "name": "Honeywell String Lights", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/honeywell_string_lights/quality_scale.yaml b/homeassistant/components/honeywell_string_lights/quality_scale.yaml new file mode 100644 index 00000000000..54bcb3f12c1 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/quality_scale.yaml @@ -0,0 +1,124 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options. + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The single entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The single entity represents the primary device function. + entity-translations: + status: exempt + comment: | + The entity uses the device name. + exception-translations: todo + icon-translations: + status: exempt + comment: | + Light uses the default icon for its state. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/honeywell_string_lights/strings.json b/homeassistant/components/honeywell_string_lights/strings.json new file mode 100644 index 00000000000..c04ba369cf9 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "[%key:common::config_flow::abort::no_compatible_radio_frequency_transmitters%]", + "no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]" + }, + "step": { + "user": { + "data": { + "transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]" + }, + "data_description": { + "transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]" + } + } + } + } +} diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index d1b733ab84a..269fc54dd61 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -1,7 +1,5 @@ """Support for the Unitymedia Horizon HD Recorder.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -151,6 +149,7 @@ class HorizonDevice(MediaPlayerEntity): try: self._select_channel(int(media_id)) self._attr_state = MediaPlayerState.PLAYING + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: _LOGGER.error("Invalid channel: %s", media_id) else: diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index e812535c936..7068664713a 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -1,7 +1,5 @@ """Support for information from HP iLO sensors.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/hr_energy_qube/__init__.py b/homeassistant/components/hr_energy_qube/__init__.py index 13bee4ce42e..7e74035e23a 100644 --- a/homeassistant/components/hr_energy_qube/__init__.py +++ b/homeassistant/components/hr_energy_qube/__init__.py @@ -1,7 +1,5 @@ """The Qube Heat Pump integration.""" -from __future__ import annotations - from dataclasses import dataclass from python_qube_heatpump import QubeClient diff --git a/homeassistant/components/hr_energy_qube/binary_sensor.py b/homeassistant/components/hr_energy_qube/binary_sensor.py new file mode 100644 index 00000000000..1b56b9f9aeb --- /dev/null +++ b/homeassistant/components/hr_energy_qube/binary_sensor.py @@ -0,0 +1,290 @@ +"""Binary sensor platform for Qube Heat Pump.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory + +from .coordinator import QubeData +from .entity import QubeEntity + +PARALLEL_UPDATES = 0 + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + from . import QubeConfigEntry + from .coordinator import QubeCoordinator + + +@dataclass(frozen=True, kw_only=True) +class QubeBinarySensorEntityDescription(BinarySensorEntityDescription): + """Binary sensor entity description for Qube Heat Pump.""" + + value_fn: Callable[[QubeData], bool | None] + + +BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = ( + # Outputs + QubeBinarySensorEntityDescription( + key="source_pump", + translation_key="source_pump", + value_fn=lambda data: data.state.dout_srcpmp_val, + ), + QubeBinarySensorEntityDescription( + key="user_pump", + translation_key="user_pump", + value_fn=lambda data: data.state.dout_usrpmp_val, + ), + QubeBinarySensorEntityDescription( + key="four_way_valve", + translation_key="four_way_valve", + value_fn=lambda data: data.state.dout_fourwayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="cooling_output", + translation_key="cooling_output", + value_fn=lambda data: data.state.dout_cooling_val, + ), + QubeBinarySensorEntityDescription( + key="three_way_valve", + translation_key="three_way_valve", + value_fn=lambda data: data.state.dout_threewayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="buffer_pump", + translation_key="buffer_pump", + value_fn=lambda data: data.state.dout_bufferpmp_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_1", + translation_key="heater_step_1", + value_fn=lambda data: data.state.dout_heaterstep1_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_2", + translation_key="heater_step_2", + value_fn=lambda data: data.state.dout_heaterstep2_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_3", + translation_key="heater_step_3", + value_fn=lambda data: data.state.dout_heaterstep3_val, + ), + # System status + QubeBinarySensorEntityDescription( + key="keypad", + translation_key="keypad", + value_fn=lambda data: data.state.keybonoff, + ), + QubeBinarySensorEntityDescription( + key="day_mode", + translation_key="day_mode", + value_fn=lambda data: data.state.daynightmode, + ), + # Alarms + QubeBinarySensorEntityDescription( + key="alarm_antilegionella_timeout", + translation_key="alarm_antilegionella_timeout", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.al_maxtime_antileg_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_dhw_timeout", + translation_key="alarm_dhw_timeout", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.al_maxtime_dhw_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_dewpoint", + translation_key="alarm_dewpoint", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.al_dewpoint_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_supply_too_hot", + translation_key="alarm_supply_too_hot", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.al_underfloorsafety_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_flow", + translation_key="alarm_flow", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.alrm_flw, + ), + QubeBinarySensorEntityDescription( + key="alarm_central_heating", + translation_key="alarm_central_heating", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.usralrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_cooling", + translation_key="alarm_cooling", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.coolingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_heating", + translation_key="alarm_heating", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.heatingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_working_hours", + translation_key="alarm_working_hours", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.alarmmng_al_workinghour, + ), + QubeBinarySensorEntityDescription( + key="alarm_source", + translation_key="alarm_source", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.srsalrm, + ), + QubeBinarySensorEntityDescription( + key="alarm_global", + translation_key="alarm_global", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.glbal, + ), + QubeBinarySensorEntityDescription( + key="alarm_compressor", + translation_key="alarm_compressor", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.state.alarmmng_al_pwrplus, + ), + # Sensor/controller status + QubeBinarySensorEntityDescription( + key="room_sensor_enabled", + translation_key="room_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.state.roomprb_en, + ), + QubeBinarySensorEntityDescription( + key="plant_sensor_enabled", + translation_key="plant_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.state.plantprb_en, + ), + QubeBinarySensorEntityDescription( + key="buffer_sensor_enabled", + translation_key="buffer_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.state.bufferprb_en, + ), + QubeBinarySensorEntityDescription( + key="dhw_controller_enabled", + translation_key="dhw_controller_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.state.en_dhwpid, + ), + # Demand signals + QubeBinarySensorEntityDescription( + key="plant_demand", + translation_key="plant_demand", + value_fn=lambda data: data.state.plantdemand, + ), + QubeBinarySensorEntityDescription( + key="external_demand", + translation_key="external_demand", + value_fn=lambda data: data.state.id_demand, + ), + QubeBinarySensorEntityDescription( + key="thermostat_demand", + translation_key="thermostat_demand", + value_fn=lambda data: data.state.thermostatdemand, + ), + # Digital inputs + QubeBinarySensorEntityDescription( + key="summer_mode", + translation_key="summer_mode", + value_fn=lambda data: data.state.id_summerwinter, + ), + QubeBinarySensorEntityDescription( + key="dewpoint", + translation_key="dewpoint", + value_fn=lambda data: data.state.dewpoint, + ), + QubeBinarySensorEntityDescription( + key="booster_security", + translation_key="booster_security", + value_fn=lambda data: data.state.boostersecurity, + ), + QubeBinarySensorEntityDescription( + key="source_flow", + translation_key="source_flow", + value_fn=lambda data: data.state.srcflw, + ), + QubeBinarySensorEntityDescription( + key="anti_legionella", + translation_key="anti_legionella", + value_fn=lambda data: data.state.req_antileg_1, + ), + # Energy + QubeBinarySensorEntityDescription( + key="pv_surplus", + translation_key="pv_surplus", + value_fn=lambda data: data.state.surplus_pv, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QubeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Qube binary sensors.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + QubeBinarySensor(coordinator, entry, description) + for description in BINARY_SENSOR_TYPES + ) + + +class QubeBinarySensor(QubeEntity, BinarySensorEntity): + """Qube binary sensor entity.""" + + entity_description: QubeBinarySensorEntityDescription + + def __init__( + self, + coordinator: QubeCoordinator, + entry: QubeConfigEntry, + description: QubeBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/hr_energy_qube/config_flow.py b/homeassistant/components/hr_energy_qube/config_flow.py index 2ade8f0d1e9..4246f49fbd4 100644 --- a/homeassistant/components/hr_energy_qube/config_flow.py +++ b/homeassistant/components/hr_energy_qube/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Qube Heat Pump integration.""" -from __future__ import annotations - from typing import Any from python_qube_heatpump import QubeClient diff --git a/homeassistant/components/hr_energy_qube/const.py b/homeassistant/components/hr_energy_qube/const.py index a71233fd803..53309128449 100644 --- a/homeassistant/components/hr_energy_qube/const.py +++ b/homeassistant/components/hr_energy_qube/const.py @@ -3,7 +3,12 @@ from homeassistant.const import Platform DOMAIN = "hr_energy_qube" -PLATFORMS = (Platform.SENSOR,) +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.WATER_HEATER, +) DEFAULT_PORT = 502 DEFAULT_SCAN_INTERVAL = 15 diff --git a/homeassistant/components/hr_energy_qube/coordinator.py b/homeassistant/components/hr_energy_qube/coordinator.py index 24266ca2aa6..d7e9de4cd1d 100644 --- a/homeassistant/components/hr_energy_qube/coordinator.py +++ b/homeassistant/components/hr_energy_qube/coordinator.py @@ -1,7 +1,6 @@ """DataUpdateCoordinator for Qube Heat Pump.""" -from __future__ import annotations - +from dataclasses import dataclass from datetime import timedelta import logging from typing import TYPE_CHECKING @@ -20,7 +19,15 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -class QubeCoordinator(DataUpdateCoordinator[QubeState]): +@dataclass +class QubeData: + """Data from the Qube coordinator.""" + + state: QubeState + switches: dict[str, bool | None] + + +class QubeCoordinator(DataUpdateCoordinator[QubeData]): """Qube Heat Pump data coordinator.""" def __init__( @@ -36,16 +43,17 @@ class QubeCoordinator(DataUpdateCoordinator[QubeState]): config_entry=entry, ) - async def _async_update_data(self) -> QubeState: + async def _async_update_data(self) -> QubeData: """Fetch data from the device.""" try: - data = await self.client.get_all_data() + state = await self.client.get_all_data() + switches = await self.client.read_all_switches() except (ConnectionError, TimeoutError, OSError) as exc: raise UpdateFailed( f"Error communicating with Qube heat pump: {exc}" ) from exc - if data is None: + if state is None: raise UpdateFailed("No data received from Qube heat pump") - return data + return QubeData(state=state, switches=switches) diff --git a/homeassistant/components/hr_energy_qube/entity.py b/homeassistant/components/hr_energy_qube/entity.py index 7678541ff66..100df9db859 100644 --- a/homeassistant/components/hr_energy_qube/entity.py +++ b/homeassistant/components/hr_energy_qube/entity.py @@ -1,7 +1,5 @@ """Base entity for Qube Heat Pump.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/hr_energy_qube/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json index 4c279e0ee37..5992fbed079 100644 --- a/homeassistant/components/hr_energy_qube/manifest.json +++ b/homeassistant/components/hr_energy_qube/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["python_qube_heatpump"], "quality_scale": "bronze", - "requirements": ["python-qube-heatpump==1.8.0"] + "requirements": ["python-qube-heatpump==1.11.0"] } diff --git a/homeassistant/components/hr_energy_qube/sensor.py b/homeassistant/components/hr_energy_qube/sensor.py index 7ddd2feffe1..8fd78002fdd 100644 --- a/homeassistant/components/hr_energy_qube/sensor.py +++ b/homeassistant/components/hr_energy_qube/sensor.py @@ -1,13 +1,9 @@ """Sensor platform for Qube Heat Pump.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING -from python_qube_heatpump.models import QubeState - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -23,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.typing import StateType +from .coordinator import QubeData from .entity import QubeEntity PARALLEL_UPDATES = 0 @@ -54,12 +51,12 @@ STATUS_MAP: dict[int, str] = { class QubeSensorEntityDescription(SensorEntityDescription): """Sensor entity description for Qube Heat Pump.""" - value_fn: Callable[[QubeState], StateType] + value_fn: Callable[[QubeData], StateType] -def _status_value(data: QubeState) -> StateType: +def _status_value(data: QubeData) -> StateType: """Return status string from status code.""" - code = data.status_code + code = data.state.status_code if code is None: return None return STATUS_MAP.get(code) @@ -73,7 +70,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_supply, + value_fn=lambda data: data.state.temp_supply, ), QubeSensorEntityDescription( key="temp_return", @@ -82,7 +79,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_return, + value_fn=lambda data: data.state.temp_return, ), QubeSensorEntityDescription( key="temp_source_in", @@ -91,7 +88,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_source_in, + value_fn=lambda data: data.state.temp_source_in, ), QubeSensorEntityDescription( key="temp_source_out", @@ -100,7 +97,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_source_out, + value_fn=lambda data: data.state.temp_source_out, ), QubeSensorEntityDescription( key="temp_room", @@ -109,7 +106,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_room, + value_fn=lambda data: data.state.temp_room, ), QubeSensorEntityDescription( key="temp_dhw", @@ -118,7 +115,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_dhw, + value_fn=lambda data: data.state.temp_dhw, ), QubeSensorEntityDescription( key="temp_outside", @@ -127,7 +124,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.temp_outside, + value_fn=lambda data: data.state.temp_outside, ), QubeSensorEntityDescription( key="power_thermic", @@ -136,7 +133,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda data: data.power_thermic, + value_fn=lambda data: data.state.power_thermic, ), QubeSensorEntityDescription( key="power_electric", @@ -145,7 +142,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda data: data.power_electric, + value_fn=lambda data: data.state.power_electric, ), QubeSensorEntityDescription( key="energy_total_electric", @@ -154,7 +151,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=3, - value_fn=lambda data: data.energy_total_electric, + value_fn=lambda data: data.state.energy_total_electric, ), QubeSensorEntityDescription( key="energy_total_thermic", @@ -163,14 +160,14 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=3, - value_fn=lambda data: data.energy_total_thermic, + value_fn=lambda data: data.state.energy_total_thermic, ), QubeSensorEntityDescription( key="cop_calc", translation_key="cop_calc", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.cop_calc, + value_fn=lambda data: data.state.cop_calc, ), QubeSensorEntityDescription( key="compressor_speed", @@ -178,7 +175,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda data: data.compressor_speed, + value_fn=lambda data: data.state.compressor_speed, ), QubeSensorEntityDescription( key="flow_rate", @@ -187,7 +184,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda data: data.flow_rate, + value_fn=lambda data: data.state.flow_rate, ), QubeSensorEntityDescription( key="setpoint_room_heat_day", @@ -196,7 +193,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.setpoint_room_heat_day, + value_fn=lambda data: data.state.setpoint_room_heat_day, ), QubeSensorEntityDescription( key="setpoint_room_heat_night", @@ -205,7 +202,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.setpoint_room_heat_night, + value_fn=lambda data: data.state.setpoint_room_heat_night, ), QubeSensorEntityDescription( key="setpoint_room_cool_day", @@ -214,7 +211,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.setpoint_room_cool_day, + value_fn=lambda data: data.state.setpoint_room_cool_day, ), QubeSensorEntityDescription( key="setpoint_room_cool_night", @@ -223,7 +220,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, - value_fn=lambda data: data.setpoint_room_cool_night, + value_fn=lambda data: data.state.setpoint_room_cool_night, ), QubeSensorEntityDescription( key="status_heatpump", diff --git a/homeassistant/components/hr_energy_qube/strings.json b/homeassistant/components/hr_energy_qube/strings.json index e2b87ed5c74..1ce2d7a4dfe 100644 --- a/homeassistant/components/hr_energy_qube/strings.json +++ b/homeassistant/components/hr_energy_qube/strings.json @@ -20,6 +20,116 @@ } }, "entity": { + "binary_sensor": { + "alarm_antilegionella_timeout": { + "name": "Anti-legionella timeout alarm" + }, + "alarm_central_heating": { + "name": "Central heating alarm" + }, + "alarm_compressor": { + "name": "Compressor alarm" + }, + "alarm_cooling": { + "name": "Cooling alarm" + }, + "alarm_dewpoint": { + "name": "Dewpoint alarm" + }, + "alarm_dhw_timeout": { + "name": "DHW timeout alarm" + }, + "alarm_flow": { + "name": "Flow alarm" + }, + "alarm_global": { + "name": "Global alarm" + }, + "alarm_heating": { + "name": "Heating alarm" + }, + "alarm_source": { + "name": "Source alarm" + }, + "alarm_supply_too_hot": { + "name": "Supply too hot alarm" + }, + "alarm_working_hours": { + "name": "Working hours alarm" + }, + "anti_legionella": { + "name": "Anti-legionella" + }, + "booster_security": { + "name": "Booster security" + }, + "buffer_pump": { + "name": "Buffer pump" + }, + "buffer_sensor_enabled": { + "name": "Buffer sensor enabled" + }, + "cooling_output": { + "name": "Cooling output" + }, + "day_mode": { + "name": "Day mode" + }, + "dewpoint": { + "name": "Dewpoint" + }, + "dhw_controller_enabled": { + "name": "DHW controller enabled" + }, + "external_demand": { + "name": "External demand" + }, + "four_way_valve": { + "name": "Four-way valve" + }, + "heater_step_1": { + "name": "Heater step 1" + }, + "heater_step_2": { + "name": "Heater step 2" + }, + "heater_step_3": { + "name": "Heater step 3" + }, + "keypad": { + "name": "Keypad" + }, + "plant_demand": { + "name": "Plant demand" + }, + "plant_sensor_enabled": { + "name": "Plant sensor enabled" + }, + "pv_surplus": { + "name": "PV surplus" + }, + "room_sensor_enabled": { + "name": "Room sensor enabled" + }, + "source_flow": { + "name": "Source flow" + }, + "source_pump": { + "name": "Source pump" + }, + "summer_mode": { + "name": "Summer mode" + }, + "thermostat_demand": { + "name": "Thermostat demand" + }, + "three_way_valve": { + "name": "Three-way valve" + }, + "user_pump": { + "name": "User pump" + } + }, "sensor": { "compressor_speed": { "name": "Compressor speed" @@ -89,6 +199,33 @@ "temp_supply": { "name": "Supply temperature CH" } + }, + "switch": { + "anti_legionella_cycle": { + "name": "Anti-legionella cycle" + }, + "heating_curve": { + "name": "Heating curve" + }, + "heating_demand": { + "name": "Heating demand" + }, + "summer_mode": { + "name": "Summer mode" + } + }, + "water_heater": { + "water_heater": { + "name": "Domestic hot water" + } + } + }, + "exceptions": { + "set_temperature_failed": { + "message": "Failed to set the target temperature." + }, + "switch_command_failed": { + "message": "Failed to send command to the heat pump." } } } diff --git a/homeassistant/components/hr_energy_qube/switch.py b/homeassistant/components/hr_energy_qube/switch.py new file mode 100644 index 00000000000..901b376307d --- /dev/null +++ b/homeassistant/components/hr_energy_qube/switch.py @@ -0,0 +1,117 @@ +"""Switch platform for Qube Heat Pump.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import QubeConfigEntry +from .const import DOMAIN +from .coordinator import QubeCoordinator +from .entity import QubeEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class QubeSwitchEntityDescription(SwitchEntityDescription): + """Switch entity description for Qube Heat Pump.""" + + register_key: str + + +SWITCH_TYPES: tuple[QubeSwitchEntityDescription, ...] = ( + QubeSwitchEntityDescription( + key="summer_mode", + translation_key="summer_mode", + register_key="bms_summerwinter", + ), + QubeSwitchEntityDescription( + key="anti_legionella_cycle", + translation_key="anti_legionella_cycle", + register_key="antilegionella_frcstart_ant", + ), + QubeSwitchEntityDescription( + key="heating_curve", + translation_key="heating_curve", + entity_category=EntityCategory.CONFIG, + register_key="en_plantsetp_compens", + ), + QubeSwitchEntityDescription( + key="heating_demand", + translation_key="heating_demand", + register_key="modbus_demand", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QubeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Qube switches.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + QubeSwitch(coordinator, entry, description) for description in SWITCH_TYPES + ) + + +class QubeSwitch(QubeEntity, SwitchEntity): + """Qube switch entity.""" + + entity_description: QubeSwitchEntityDescription + + def __init__( + self, + coordinator: QubeCoordinator, + entry: QubeConfigEntry, + description: QubeSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}-{description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.entity_description.register_key in self.coordinator.data.switches + ) + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + return self.coordinator.data.switches.get(self.entity_description.register_key) + + async def _async_write_switch(self, value: bool) -> None: + """Write switch value to the device.""" + register_key = self.entity_description.register_key + try: + success = await self.coordinator.client.write_switch(register_key, value) + except (ConnectionError, TimeoutError, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_command_failed", + ) from err + if not success: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_command_failed", + ) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_write_switch(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_write_switch(False) diff --git a/homeassistant/components/hr_energy_qube/water_heater.py b/homeassistant/components/hr_energy_qube/water_heater.py new file mode 100644 index 00000000000..6d91407f5bb --- /dev/null +++ b/homeassistant/components/hr_energy_qube/water_heater.py @@ -0,0 +1,119 @@ +"""Water heater platform for Qube Heat Pump.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import QubeConfigEntry +from .const import DOMAIN +from .coordinator import QubeCoordinator +from .entity import QubeEntity + +PARALLEL_UPDATES = 1 + +DHW_BOOST_KEY = "tapw_timeprogram_bms_forced" +DHW_SETPOINT_KEY = "setpoint_dhw" +DHW_MIN_TEMP = 40 +DHW_MAX_TEMP = 65 + +OPERATION_MODES = [STATE_HEAT_PUMP, STATE_PERFORMANCE] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QubeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Qube water heater.""" + coordinator = entry.runtime_data.coordinator + async_add_entities([QubeWaterHeater(coordinator, entry)]) + + +class QubeWaterHeater(QubeEntity, WaterHeaterEntity): + """Qube DHW water heater entity.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = DHW_MIN_TEMP + _attr_max_temp = DHW_MAX_TEMP + _attr_operation_list = OPERATION_MODES + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + _attr_translation_key = "water_heater" + + def __init__( + self, + coordinator: QubeCoordinator, + entry: QubeConfigEntry, + ) -> None: + """Initialize the water heater.""" + super().__init__(coordinator, entry) + self._attr_unique_id = entry.entry_id + + @property + def current_temperature(self) -> float | None: + """Return the current DHW temperature.""" + return self.coordinator.data.state.temp_dhw + + @property + def target_temperature(self) -> float | None: + """Return the target DHW temperature.""" + return self.coordinator.data.state.setpoint_dhw + + @property + def current_operation(self) -> str | None: + """Return the current operation mode.""" + boost = self.coordinator.data.switches.get(DHW_BOOST_KEY) + if boost is None: + return None + if boost: + return STATE_PERFORMANCE + return STATE_HEAT_PUMP + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target DHW temperature.""" + temperature = kwargs.get("temperature") + if temperature is None: + return + try: + success = await self.coordinator.client.write_setpoint( + DHW_SETPOINT_KEY, temperature + ) + except (ConnectionError, TimeoutError, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_failed", + ) from err + if not success: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_failed", + ) + await self.coordinator.async_request_refresh() + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set the operation mode.""" + boost = operation_mode == STATE_PERFORMANCE + try: + success = await self.coordinator.client.write_switch(DHW_BOOST_KEY, boost) + except (ConnectionError, TimeoutError, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_command_failed", + ) from err + if not success: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_command_failed", + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/html5/config_flow.py b/homeassistant/components/html5/config_flow.py index ae409d1366e..78a44be59db 100644 --- a/homeassistant/components/html5/config_flow.py +++ b/homeassistant/components/html5/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the html5 component.""" -from __future__ import annotations - import binascii from typing import Any, cast diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index cc0aeb282c7..dd447b0e4c1 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -15,7 +15,6 @@ ATTR_ACTIONS = "actions" ATTR_BADGE = "badge" ATTR_DATA = "data" ATTR_DIR = "dir" -ATTR_ICON = "icon" ATTR_IMAGE = "image" ATTR_LANG = "lang" ATTR_RENOTIFY = "renotify" diff --git a/homeassistant/components/html5/entity.py b/homeassistant/components/html5/entity.py index 71b85208271..929f6741862 100644 --- a/homeassistant/components/html5/entity.py +++ b/homeassistant/components/html5/entity.py @@ -1,7 +1,5 @@ """Base entities for HTML5 integration.""" -from __future__ import annotations - from typing import NotRequired, TypedDict from aiohttp import ClientSession diff --git a/homeassistant/components/html5/event.py b/homeassistant/components/html5/event.py index 6f74d61d83d..7fb8a98c792 100644 --- a/homeassistant/components/html5/event.py +++ b/homeassistant/components/html5/event.py @@ -1,7 +1,5 @@ """Event platform for HTML5 integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.event import EventEntity diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index b958ab46461..9a71b05e348 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/html5", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], "requirements": ["pywebpush==2.3.0", "py_vapid==1.9.4"], diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index c3ea03d01b9..ecbdc7abff0 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -1,7 +1,5 @@ """HTML5 Push Messaging notification service.""" -from __future__ import annotations - from contextlib import suppress from datetime import datetime, timedelta from http import HTTPStatus diff --git a/homeassistant/components/html5/quality_scale.yaml b/homeassistant/components/html5/quality_scale.yaml new file mode 100644 index 00000000000..75ebbccea46 --- /dev/null +++ b/homeassistant/components/html5/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: The integration does not poll + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: todo + docs-actions: done + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: todo + has-entity-name: done + runtime-data: + status: exempt + comment: The integration has no runtime data + test-before-configure: + status: exempt + comment: Only validates the VAPID private key, no live service interaction is available to test. + test-before-setup: + status: exempt + comment: The integration has no runtime behavior that can be tested prior to setup. + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: + status: exempt + comment: No service availability state to monitor or log. + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: No user authentication or credential refresh mechanism is used. + test-coverage: done + + # Gold + devices: todo + diagnostics: + status: exempt + comment: No runtime data available and configuration contains only sensitive information. + discovery-update-info: + status: exempt + comment: The integration does not support discovery. + discovery: + status: exempt + comment: The integration does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: exempt + comment: Notify platform does not support device classes, and the event platform does not provide a suitable device class for the entities. + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: Device name is used for main entities + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Nothing to reconfigure + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py index 06b6e5f92a0..b2f196e017a 100644 --- a/homeassistant/components/html5/services.py +++ b/homeassistant/components/html5/services.py @@ -9,6 +9,7 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, DOMAIN as NOTIFY_DOMAIN, ) +from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, service @@ -17,7 +18,6 @@ from .const import ( ATTR_ACTIONS, ATTR_BADGE, ATTR_DIR, - ATTR_ICON, ATTR_IMAGE, ATTR_LANG, ATTR_RENOTIFY, diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a4db676ffe3..9089644474b 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,7 +1,5 @@ """Support to serve the Home Assistant API as WSGI application.""" -from __future__ import annotations - import asyncio from collections.abc import Collection from dataclasses import dataclass @@ -51,7 +49,6 @@ from homeassistant.helpers.http import ( from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.setup import ( SetupPhases, async_start_setup, @@ -175,7 +172,6 @@ class ConfData(TypedDict, total=False): ssl_profile: str -@bind_hass async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None: """Return the last known working config.""" store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 50b3812dd7d..e2c6871939f 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,10 +1,7 @@ """Authentication for HTTP component.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from datetime import timedelta -from ipaddress import ip_address import logging import secrets import time @@ -24,16 +21,14 @@ from yarl import URL from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY -from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes -from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store -from homeassistant.util.network import is_local +from .auth_util import async_user_not_allowed_do_auth from .const import ( KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, @@ -99,38 +94,6 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -@callback -def async_user_not_allowed_do_auth( - hass: HomeAssistant, user: User, request: Request | None = None -) -> str | None: - """Validate that user is not allowed to do auth things.""" - if not user.is_active: - return "User is not active" - - if not user.local_only: - return None - - # User is marked as local only, check if they are allowed to do auth - if request is None: - request = current_request.get() - - if not request: - return "No request available to validate local access" - - if is_cloud_connection(hass): - return "User is local only" - - try: - remote_address = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - return "Invalid remote IP" - - if is_local(remote_address): - return None - - return "User cannot authenticate remotely" - - async def async_setup_auth( # noqa: C901 hass: HomeAssistant, app: Application, @@ -217,6 +180,9 @@ async def async_setup_auth( # noqa: C901 if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True diff --git a/homeassistant/components/http/auth_util.py b/homeassistant/components/http/auth_util.py new file mode 100644 index 00000000000..4eed144347b --- /dev/null +++ b/homeassistant/components/http/auth_util.py @@ -0,0 +1,43 @@ +"""Auth utilities for the HTTP component.""" + +from ipaddress import ip_address + +from aiohttp.web import Request + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local + + +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if is_cloud_connection(hass): + return "User is local only" + + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return "Invalid remote IP" + + if is_local(remote_address): + return None + + return "User cannot authenticate remotely" diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index e2ec1ad95a3..a21499f613a 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -1,7 +1,5 @@ """Ban logic for HTTP component.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Awaitable, Callable, Coroutine from contextlib import suppress diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index b7e53a6bebf..e42a19c5a80 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,7 +1,5 @@ """Provide CORS support for the HTTP component.""" -from __future__ import annotations - from typing import Final, cast from aiohttp.hdrs import ACCEPT, AUTHORIZATION, CONTENT_TYPE, ORIGIN diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index abfeadc7189..b2cc982b747 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,7 +1,5 @@ """Decorator for view methods to help with data validation.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from http import HTTPStatus diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 19a0a5d1c55..9eca097acb3 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -1,7 +1,5 @@ """Decorators for the Home Assistant API.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate, overload diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 9d19ac3dcae..1178052aa96 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -1,7 +1,5 @@ """Middleware to handle forwarded data by a reverse proxy.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from ipaddress import IPv4Network, IPv6Network, ip_address import logging @@ -47,8 +45,9 @@ def async_setup_forwarded( e.g., `X-Forwarded-Host: example.com, proxy.example.com, backend.example.com` OR `X-Forwarded-Host: example.com` (one entry, even with multiple proxies) - If the previous headers are processed successfully, and the X-Forwarded-Host is - present, the last one in the list will be used (set by the proxy nearest to the backend). + If the previous headers are processed successfully, and the + X-Forwarded-Host is present, the last one in the list will be used + (set by the proxy nearest to the backend). Multiple headers are valid as stated in https://www.rfc-editor.org/rfc/rfc7239#section-7.1 If multiple headers are present, they are handled according to @@ -149,7 +148,8 @@ def async_setup_forwarded( X_FORWARDED_PROTO, [] ) if forwarded_proto_headers: - # Process multiple X-Forwarded-Proto from the right side (by reversing the list) + # Process multiple X-Forwarded-Proto from the right side + # (by reversing the list) forwarded_proto_split = list( reversed( [ @@ -193,7 +193,8 @@ def async_setup_forwarded( # Handle X-Forwarded-Host forwarded_host_headers: list[str] = request.headers.getall(X_FORWARDED_HOST, []) if forwarded_host_headers: - # Process multiple X-Forwarded-Host from the right side (by reversing the list) + # Process multiple X-Forwarded-Host from the right side + # (by reversing the list) forwarded_host = list( reversed( [ diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index fdb325c7b74..58114038d90 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -1,7 +1,5 @@ """Middleware that helps with the control of headers in our responses.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import Final diff --git a/homeassistant/components/http/request_context.py b/homeassistant/components/http/request_context.py index c5fcdfb18f3..a4dbd54492a 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -1,7 +1,5 @@ """Middleware to set the request context.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from contextvars import ContextVar diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index 524d125b857..98df4a815d3 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -1,7 +1,5 @@ """Middleware to add some basic security filtering to requests.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from functools import lru_cache import logging diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 99877eaf0be..17edd997001 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,5 @@ """Static file handling for HTTP component.""" -from __future__ import annotations - from collections.abc import Mapping from pathlib import Path from typing import Final diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 712b4e9894f..88f30904149 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -1,7 +1,5 @@ """Support for views.""" -from __future__ import annotations - from homeassistant.helpers.http import ( # noqa: F401 HomeAssistantView, request_handler_factory, diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index a28b69ba9d3..59f9f7a4929 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -1,7 +1,5 @@ """HomeAssistant specific aiohttp Site.""" -from __future__ import annotations - import asyncio from pathlib import Path import socket diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a7bd90baefd..6b843bbcb39 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -1,14 +1,12 @@ """Support for Huawei LTE routers.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass, field from datetime import timedelta import logging -from typing import Any, NamedTuple, cast +from typing import Any, cast from xml.parsers.expat import ExpatError from huawei_lte_api.Client import Client @@ -63,6 +61,7 @@ from .const import ( DEFAULT_MANUFACTURER, DEFAULT_NOTIFY_SERVICE_NAME, DOMAIN, + HUAWEI_LTE_CONFIG, KEY_DEVICE_BASIC_INFORMATION, KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, @@ -107,7 +106,7 @@ class Router: """Class for router state.""" hass: HomeAssistant - config_entry: ConfigEntry + config_entry: HuaweiLteConfigEntry connection: Connection url: str @@ -277,14 +276,10 @@ class Router: self.connection.requests_session.close() -class HuaweiLteData(NamedTuple): - """Shared state.""" - - hass_config: ConfigType - routers: dict[str, Router] +type HuaweiLteConfigEntry = ConfigEntry[Router] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HuaweiLteConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" url = entry.data[CONF_URL] @@ -351,7 +346,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False # Store reference to router - hass.data[DOMAIN].routers[entry.entry_id] = router + entry.runtime_data = router # Clear all subscriptions, enabled entities will push back theirs router.subscriptions.clear() @@ -416,13 +411,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT), }, - hass.data[DOMAIN].hass_config, + hass.data[HUAWEI_LTE_CONFIG], ) def _update_router(*_: Any) -> None: """Update router data. - Separate passthrough function because lambdas don't work with track_time_interval. + Separate passthrough function because lambdas don't work + with track_time_interval. """ router.update() @@ -439,15 +435,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HuaweiLteConfigEntry +) -> bool: """Unload config entry.""" # Forward config entry unload to platforms await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - # Forget about the router and invoke its cleanup - router = hass.data[DOMAIN].routers.pop(config_entry.entry_id) - await hass.async_add_executor_job(router.cleanup) + # Invoke router cleanup + await hass.async_add_executor_job(config_entry.runtime_data.cleanup) return True @@ -455,8 +452,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Huawei LTE component.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = HuaweiLteData(hass_config=config, routers={}) + hass.data[HUAWEI_LTE_CONFIG] = config def service_handler(service: ServiceCall) -> None: """Apply a service. @@ -464,21 +460,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: We key this using the router URL instead of its unique id / serial number, because the latter is not available anywhere in the UI. """ - routers = hass.data[DOMAIN].routers + routers = [ + entry.runtime_data + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ] if url := service.data.get(CONF_URL): - router = next( - (router for router in routers.values() if router.url == url), None - ) + router = next((router for router in routers if router.url == url), None) elif not routers: _LOGGER.error("%s: no routers configured", service.service) return elif len(routers) == 1: - router = next(iter(routers.values())) + router = routers[0] else: _LOGGER.error( "%s: more than one router configured, must specify one of URLs %s", service.service, - sorted(router.url for router in routers.values()), + sorted(router.url for router in routers), ) return if not router: @@ -508,7 +505,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HuaweiLteConfigEntry +) -> bool: """Migrate config entry to new version.""" if config_entry.version == 1: options = dict(config_entry.options) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 41f4638b713..af6088093ed 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Huawei LTE binary sensors.""" -from __future__ import annotations - import logging from typing import Any @@ -12,13 +10,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HuaweiLteConfigEntry from .const import ( - DOMAIN, KEY_MONITORING_CHECK_NOTIFICATIONS, KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH, @@ -30,11 +27,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data entities: list[Entity] = [] if router.data.get(KEY_MONITORING_STATUS): diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py index 04480a85e03..e2c4da7d647 100644 --- a/homeassistant/components/huawei_lte/button.py +++ b/homeassistant/components/huawei_lte/button.py @@ -1,7 +1,5 @@ """Huawei LTE buttons.""" -from __future__ import annotations - import logging from huawei_lte_api.enums.device import ControlModeEnum @@ -11,12 +9,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from .const import DOMAIN +from . import HuaweiLteConfigEntry from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) @@ -24,11 +21,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: """Set up Huawei LTE buttons.""" - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data buttons = [ ClearTrafficStatisticsButton(router), RestartButton(router), diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 120d96e7d78..ab1be0cf603 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Huawei LTE platform.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -21,12 +19,7 @@ from requests.exceptions import SSLError, Timeout from url_normalize import url_normalize import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_MAC, CONF_NAME, @@ -47,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import HuaweiLteConfigEntry from .const import ( CONF_MANUFACTURER, CONF_TRACK_WIRED_CLIENTS, @@ -76,7 +70,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, ) -> HuaweiLteOptionsFlow: """Get options flow.""" return HuaweiLteOptionsFlow() @@ -252,6 +246,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): info, wlan_settings = await self.hass.async_add_executor_job( get_device_info, conn ) + # pylint: disable-next=home-assistant-sequential-executor-jobs await self.hass.async_add_executor_job(self._disconnect, conn) user_input.update( @@ -373,7 +368,8 @@ class HuaweiLteOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Handle options flow.""" - # Recipients are persisted as a list, but handled as comma separated string in UI + # Recipients are persisted as a list, but handled as comma + # separated string in UI if user_input is not None: # Preserve existing options, for example *_from_yaml markers @@ -386,6 +382,8 @@ class HuaweiLteOptionsFlow(OptionsFlow): data_schema = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional( CONF_NAME, default=self.config_entry.options.get( diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index bc114f56e99..09b61db546d 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -1,7 +1,12 @@ """Huawei LTE constants.""" +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey + DOMAIN = "huawei_lte" +HUAWEI_LTE_CONFIG: HassKey[ConfigType] = HassKey(DOMAIN) + CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 58e61c80bfe..2a1464473ec 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,7 +1,5 @@ """Support for device tracking of Huawei LTE routers.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -9,7 +7,6 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,11 +14,10 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import snakecase -from . import Router +from . import HuaweiLteConfigEntry, Router from .const import ( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS, - DOMAIN, KEY_LAN_HOST_INFO, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL, @@ -50,7 +46,7 @@ def _get_hosts( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" @@ -58,7 +54,7 @@ async def async_setup_entry( # Grab hosts list once to examine whether the initial fetch has got some data for # us, i.e. if wlan host list is supported. Only set up a subscription and proceed # with adding and tracking entities if it is. - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data if (hosts := _get_hosts(router, True)) is None: return @@ -119,7 +115,8 @@ def _is_connected(host: _HostType | None) -> bool: def _is_us(host: _HostType) -> bool: """Try to determine if the host entry is us, the HA instance.""" - # LAN host info entries have an "isLocalDevice" property, "1" / "0"; WLAN host list ones don't. + # LAN host info entries have an "isLocalDevice" property, + # "1" / "0"; WLAN host list ones don't. return cast(str, host.get("isLocalDevice", "0")) == "1" @@ -213,7 +210,8 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): self._is_connected = _is_connected(host) if host is not None: # IpAddress can contain multiple semicolon separated addresses. - # Pick one for model sanity; e.g. the dhcp component to which it is fed, parses and expects to see just one. + # Pick one for model sanity; e.g. the dhcp component + # to which it is fed, parses and expects to see just one. self._ip_address = (host.get("IpAddress") or "").split(";", 2)[0] or None self._hostname = host.get("HostName") self._extra_state_attributes = { diff --git a/homeassistant/components/huawei_lte/diagnostics.py b/homeassistant/components/huawei_lte/diagnostics.py index 975ab476e6c..865b4c151db 100644 --- a/homeassistant/components/huawei_lte/diagnostics.py +++ b/homeassistant/components/huawei_lte/diagnostics.py @@ -1,14 +1,11 @@ """Diagnostics support for Huawei LTE.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import HuaweiLteConfigEntry ENTRY_FIELDS_DATA_TO_REDACT = { "mac", @@ -74,13 +71,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: HuaweiLteConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return async_redact_data( { "entry": entry.data, - "router": hass.data[DOMAIN].routers[entry.entry_id].data, + "router": entry.runtime_data.data, }, TO_REDACT, ) diff --git a/homeassistant/components/huawei_lte/entity.py b/homeassistant/components/huawei_lte/entity.py index b69d2e79fb6..d1ed37d6524 100644 --- a/homeassistant/components/huawei_lte/entity.py +++ b/homeassistant/components/huawei_lte/entity.py @@ -1,7 +1,5 @@ """Support for Huawei LTE routers.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index e4f211ffcee..a18af6ff83f 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], - "requirements": ["huawei-lte-api==1.11.0", "url-normalize==2.2.1"], + "requirements": ["huawei-lte-api==1.11.0", "url-normalize==3.0.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 7543eb71d88..29740b5e664 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,7 +1,5 @@ """Support for Huawei LTE router notifications.""" -from __future__ import annotations - import logging from typing import Any @@ -12,8 +10,7 @@ from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import Router -from .const import DOMAIN +from . import HuaweiLteConfigEntry, Router _LOGGER = logging.getLogger(__name__) @@ -27,7 +24,11 @@ async def async_get_service( if discovery_info is None: return None - router = hass.data[DOMAIN].routers[discovery_info[ATTR_CONFIG_ENTRY_ID]] + entry: HuaweiLteConfigEntry | None = hass.config_entries.async_get_entry( + discovery_info[ATTR_CONFIG_ENTRY_ID] + ) + assert entry is not None + router = entry.runtime_data default_targets = discovery_info[CONF_RECIPIENT] or [] return HuaweiLteSmsNotificationService(router, default_targets) @@ -59,5 +60,6 @@ class HuaweiLteSmsNotificationService(BaseNotificationService): phone_numbers=targets, message=message ) _LOGGER.debug("Sent to %s: %s", targets, resp) + # pylint: disable-next=home-assistant-action-swallowed-exception except ResponseErrorException as ex: _LOGGER.error("Could not send to %s: %s", targets, ex) diff --git a/homeassistant/components/huawei_lte/quality_scale.yaml b/homeassistant/components/huawei_lte/quality_scale.yaml index 96b48a5827e..169dd0c6342 100644 --- a/homeassistant/components/huawei_lte/quality_scale.yaml +++ b/homeassistant/components/huawei_lte/quality_scale.yaml @@ -22,7 +22,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index 43961b4ec73..d1c737bf94f 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -1,11 +1,10 @@ """Support for Huawei LTE selects.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial import logging +from typing import Any from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum @@ -14,14 +13,13 @@ from homeassistant.components.select import ( SelectEntity, SelectEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import Router -from .const import DOMAIN, KEY_NET_NET_MODE +from . import HuaweiLteConfigEntry, Router +from .const import KEY_NET_NET_MODE from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) @@ -31,16 +29,16 @@ _LOGGER = logging.getLogger(__name__) class HuaweiSelectEntityDescription(SelectEntityDescription): """Class describing Huawei LTE select entities.""" - setter_fn: Callable[[str], None] + setter_fn: Callable[[str], Any] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data selects: list[Entity] = [] desc = HuaweiSelectEntityDescription( diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index aaf71c9195b..4e09ac95c0c 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -1,7 +1,5 @@ """Support for Huawei LTE sensors.""" -from __future__ import annotations - from bisect import bisect from collections.abc import Callable, Sequence from dataclasses import dataclass @@ -17,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -31,9 +28,8 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import Router +from . import HuaweiLteConfigEntry, Router from .const import ( - DOMAIN, KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, KEY_MONITORING_CHECK_NOTIFICATIONS, @@ -159,7 +155,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { key="WanIPAddress", translation_key="wan_ip_address", entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "WanIPv6Address": HuaweiSensorEntityDescription( key="WanIPv6Address", @@ -334,7 +329,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "nrrsrq": HuaweiSensorEntityDescription( key="nrrsrq", @@ -344,7 +338,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { icon_fn=lambda x: signal_icon((-20, -15, -10), x), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "nrsinr": HuaweiSensorEntityDescription( key="nrsinr", @@ -355,7 +348,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "nrtxpower": HuaweiSensorEntityDescription( key="nrtxpower", @@ -422,7 +414,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "rsrq": HuaweiSensorEntityDescription( key="rsrq", @@ -432,7 +423,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { icon_fn=lambda x: signal_icon((-20, -15, -10), x), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "rssi": HuaweiSensorEntityDescription( key="rssi", @@ -443,7 +433,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "rxlev": HuaweiSensorEntityDescription( key="rxlev", @@ -465,7 +454,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=True, ), "tac": HuaweiSensorEntityDescription( key="tac", @@ -795,11 +783,11 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data sensors: list[Entity] = [] for key in SENSOR_KEYS: if not (items := router.data.get(key)): @@ -816,11 +804,12 @@ async def async_setup_entry( _LOGGER.debug("Ignoring sensor %s.%s due to None value", key, item) continue if not (desc := SENSOR_META[key].descriptions.get(item)): - _LOGGER.debug( # pylint: disable=hass-logger-period # false positive + _LOGGER.debug( # pylint: disable=home-assistant-logger-period # false positive ( "Ignoring unknown sensor %s.%s. " "Opening an issue at GitHub against the " - "huawei_lte integration would be appreciated, so we may be able to " + "huawei_lte integration would be appreciated, " + "so we may be able to " "add support for it in a future release. " 'Include the sensor name "%s.%s" in the issue, ' "as well as any information you may have about it, " diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index ac8bca4234c..4fea62f475b 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -1,7 +1,5 @@ """Support for Huawei LTE switches.""" -from __future__ import annotations - import logging from typing import Any @@ -10,16 +8,12 @@ from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - DOMAIN, - KEY_DIALUP_MOBILE_DATASWITCH, - KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, -) +from . import HuaweiLteConfigEntry +from .const import KEY_DIALUP_MOBILE_DATASWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) @@ -27,11 +21,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HuaweiLteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.entry_id] + router = config_entry.runtime_data switches: list[Entity] = [] if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH): diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index 2225fb13ffc..ae39c33bc16 100644 --- a/homeassistant/components/huawei_lte/utils.py +++ b/homeassistant/components/huawei_lte/utils.py @@ -1,7 +1,5 @@ """Utilities for the Huawei LTE integration.""" -from __future__ import annotations - from contextlib import suppress import re from urllib.parse import urlparse diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index ec6f3099679..98cca2e0b7c 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -58,13 +58,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, unique_id=unique_id) elif other_entry.source == SOURCE_IGNORE: - # There is another entry but it is ignored, delete that one and update this one + # There is another entry but it is ignored, delete that + # one and update this one hass.async_create_task( hass.config_entries.async_remove(other_entry.entry_id) ) hass.config_entries.async_update_entry(entry, unique_id=unique_id) else: - # There is another entry that already has the right unique ID. Delete this entry + # There is another entry that already has the right unique + # ID. Delete this entry hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False @@ -80,7 +82,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: model_id=api.config.model_id, sw_version=api.config.software_version, ) - # create persistent notification if we found a bridge version with security vulnerability + # create persistent notification if we found a bridge version + # with security vulnerability if ( api.config.model_id == "BSB002" and api.config.software_version < "1935144040" diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index 1d5f10a8c91..3d666ccc55c 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Hue binary sensors.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 74ae5483242..2a67d92161c 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -1,7 +1,5 @@ """Code to handle a Hue bridge.""" -from __future__ import annotations - import asyncio from collections.abc import Callable import logging diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index f29bd47a24e..4f89a58d1e1 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Philips Hue.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -81,8 +79,10 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): bridge = await discover_bridge( host, websession=aiohttp_client.async_get_clientsession( - # NOTE: we disable SSL verification for now due to the fact that the (BSB003) - # Hue bridge uses a certificate from a on-bridge root authority. + # NOTE: we disable SSL verification for now + # due to the fact that the (BSB003) Hue bridge + # uses a certificate from a on-bridge root + # authority. # We need to specifically handle this case in a follow-up update. self.hass, verify_ssl=False, @@ -337,7 +337,8 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): bridge_device.id, # overwrite identifiers with new bridge id new_identifiers={(DOMAIN, bridge.id)}, - # overwrite mac addresses with empty set to drop the old (incorrect) addresses + # overwrite mac addresses with empty set to drop + # the old (incorrect) addresses # this will be auto corrected once the integration is loaded new_connections=set(), ) diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 9592be69e7e..86fef4d56bd 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Philips Hue events.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.device_automation import InvalidDeviceAutomationConfig diff --git a/homeassistant/components/hue/diagnostics.py b/homeassistant/components/hue/diagnostics.py index a45813151e4..40073da4ea5 100644 --- a/homeassistant/components/hue/diagnostics.py +++ b/homeassistant/components/hue/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Hue.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index c13cccd48e6..bf0e5f539d7 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -1,7 +1,5 @@ """Hue event entities from Button resources.""" -from __future__ import annotations - from typing import Any from aiohue.v2 import HueBridgeV2 diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 33a129a1aa4..92079afce58 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -1,6 +1,12 @@ { "entity": { "light": { + "hue_grouped_light": { + "default": "mdi:lightbulb-group", + "state": { + "off": "mdi:lightbulb-group-off" + } + }, "hue_light": { "state_attributes": { "effect": { diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 332dc6978ad..989e0c5970d 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,7 +1,5 @@ """Support for Hue lights.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 55edf7d5565..1ca730efb37 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -158,9 +158,10 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: HueConfigEntry) - ent.entity_id, new_unique_id=new_unique_id ) except ValueError: - # assume edge case where the entity was already migrated in a previous run - # which got aborted somehow and we do not want - # to crash the entire integration init + # assume edge case where the entity was already + # migrated in a previous run which got aborted + # somehow and we do not want to crash the entire + # integration init LOGGER.warning( "Skip migration of %s because it already exists", ent.entity_id, @@ -203,9 +204,10 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: HueConfigEntry) - try: ent_reg.async_update_entity(ent.entity_id, new_unique_id=new_unique_id) except ValueError: - # assume edge case where the entity was already migrated in a previous run - # which got aborted somehow and we do not want - # to crash the entire integration init + # assume edge case where the entity was already + # migrated in a previous run which got aborted + # somehow and we do not want to crash the entire + # integration init LOGGER.warning( "Skip migration of %s because it already exists", ent.entity_id, diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 8a4aac098ff..912504a8721 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -1,7 +1,6 @@ """Support for scene platform for Hue scenes (V2 only).""" -from __future__ import annotations - +import logging from typing import Any from aiohue.v2 import HueBridgeV2 @@ -29,6 +28,8 @@ ATTR_DYNAMIC = "dynamic" ATTR_SPEED = "speed" ATTR_BRIGHTNESS = "brightness" +LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -49,10 +50,18 @@ async def async_setup_entry( event_type: EventType, resource: HueScene | HueSmartScene ) -> None: """Add entity from Hue resource.""" - if isinstance(resource, HueSmartScene): - async_add_entities([HueSmartSceneEntity(bridge, api.scenes, resource)]) - else: - async_add_entities([HueSceneEntity(bridge, api.scenes, resource)]) + # Catch creation errors to continue adding other scenes even if one fails + try: + entity: HueSceneEntityBase + if isinstance(resource, HueSmartScene): + entity = HueSmartSceneEntity(bridge, api.scenes, resource) + else: + entity = HueSceneEntity(bridge, api.scenes, resource) + except KeyError, StopIteration: + LOGGER.exception("Unable to create Hue scene entity for %s", resource.id) + return + + async_add_entities([entity]) # add all current items in controller for item in api.scenes: diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 60845c0be7a..dac9cfc5b55 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,7 +1,5 @@ """Support for Hue sensors.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 1a70e98e5b3..8d73c050e89 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -1,7 +1,5 @@ """Handle Hue Service calls.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index a9ea57d7828..dc38addafad 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -31,15 +31,16 @@ activate_scene: dynamic: selector: boolean: - speed: - advanced: true - selector: - number: - min: 0 - max: 100 - brightness: - advanced: true - selector: - number: - min: 1 - max: 255 + scene_customization: + collapsed: true + fields: + speed: + selector: + number: + min: 0 + max: 100 + brightness: + selector: + number: + min: 1 + max: 255 diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index cd6bcbe60e9..2b1d3830cb7 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -184,7 +184,12 @@ "name": "Transition" } }, - "name": "Activate Hue scene" + "name": "Activate Hue scene", + "sections": { + "scene_customization": { + "name": "Scene customization" + } + } }, "hue_activate_scene": { "description": "Activates a Hue scene stored in the Hue hub.", diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index 33dfe02dd49..23ca10adc0c 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -1,7 +1,5 @@ """Support for switch platform for Hue resources (V2 only).""" -from __future__ import annotations - from typing import Any from aiohue.v2 import HueBridgeV2 @@ -76,7 +74,7 @@ async def async_setup_entry( class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): - """Representation of a Switch entity from a Hue resource that can be toggled enabled.""" + """Represent a Switch entity from a Hue resource that toggles.""" controller: BehaviorInstanceController | LightLevelController | MotionController resource: BehaviorInstance | LightLevel | Motion @@ -115,7 +113,6 @@ class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): key="behavior_instance", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, - has_entity_name=False, ) @property diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py index 3654c5c6f1d..cbd5e980ada 100644 --- a/homeassistant/components/hue/v1/binary_sensor.py +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HuePresence(GenericZLLSensor, BinarySensorEntity): """The presence sensor entity for a Hue motion sensor device.""" diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index c55573899d2..c0a6bb2ba1b 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Philips Hue events in V1 bridge/api.""" -from __future__ import annotations - from typing import TYPE_CHECKING import voluptuous as vol diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 3afa0945572..373c233f3b9 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -1,7 +1,5 @@ """Support for the Philips Hue lights.""" -from __future__ import annotations - import asyncio from datetime import timedelta from functools import partial @@ -315,7 +313,7 @@ def hass_to_hue_brightness(value): return max(1, round((value / 255) * 254)) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueLight(CoordinatorEntity, LightEntity): """Representation of a Hue light.""" diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py index 1f8ad7b1f9a..8adbc21b6e8 100644 --- a/homeassistant/components/hue/v1/sensor.py +++ b/homeassistant/components/hue/v1/sensor.py @@ -40,12 +40,12 @@ async def async_setup_entry( await bridge.sensor_manager.async_register_component("sensor", async_add_entities) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): """Parent class for all 'gauge' Hue device sensors.""" -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" @@ -81,7 +81,7 @@ class HueLightLevel(GenericHueGaugeSensorEntity): return attributes -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueTemperature(GenericHueGaugeSensorEntity): """The temperature sensor entity for a Hue motion sensor device.""" @@ -98,7 +98,7 @@ class HueTemperature(GenericHueGaugeSensorEntity): return self.sensor.temperature / 100 -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueBattery(GenericHueSensor, SensorEntity): """Battery class for when a batt-powered device is only represented as an event.""" diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 9cb836386e0..32b324be5e2 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -1,7 +1,5 @@ """Support for the Philips Hue sensors as a platform.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -166,7 +164,7 @@ class SensorManager: self._component_add_entities[platform](value) -class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass-enforce-class-module +class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=home-assistant-enforce-class-module """Representation of a Hue sensor.""" should_poll = False diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index a18f2176f67..068cf825458 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from ..const import CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE, DOMAIN -class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-module +class GenericHueDevice(entity.Entity): # pylint: disable=home-assistant-enforce-class-module """Representation of a Hue device.""" def __init__(self, sensor, name, bridge, primary_sensor=None): diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index ec80ad1f4bb..9d5dd85e075 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Hue binary sensors.""" -from __future__ import annotations - from functools import partial from aiohue.v2 import HueBridgeV2 @@ -71,11 +69,13 @@ def _resource_valid(resource: SensorType, controller: ControllerType) -> bool: ResourceTypes.SERVICE_GROUP, ): return False - # guard against GroupedMotion without parent (should not happen, but just in case) + # guard against GroupedMotion without parent + # (should not happen, but just in case) if not (parent := controller.get_parent(resource.id)): return False - # filter out GroupedMotion sensors that have only one member, because Hue creates one - # default grouped Motion sensor per zone/room, which is not useful to expose in HA + # filter out GroupedMotion sensors that have only one member, + # because Hue creates one default grouped Motion sensor per + # zone/room, which is not useful to expose in HA if len(parent.children) <= 1: return False # default/other checks can go here (none for now) @@ -126,7 +126,7 @@ async def async_setup_entry( register_items(api.sensors.security_area_motion, HueMotionAwareSensor) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueMotionSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Motion sensor.""" @@ -152,7 +152,7 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity): return motion_feature.motion -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueGroupedMotionSensor(HueMotionSensor): """Representation of a Hue Grouped Motion sensor.""" @@ -175,7 +175,7 @@ class HueGroupedMotionSensor(HueMotionSensor): ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueMotionAwareSensor(HueMotionSensor): """Representation of a Motion sensor based on Hue Motion Aware. @@ -190,9 +190,7 @@ class HueMotionAwareSensor(HueMotionSensor): resource: SecurityAreaMotion entity_description = BinarySensorEntityDescription( - key="motion_sensor", - device_class=BinarySensorDeviceClass.MOTION, - has_entity_name=False, + key="motion_sensor", device_class=BinarySensorDeviceClass.MOTION ) @property @@ -229,7 +227,7 @@ class HueMotionAwareSensor(HueMotionSensor): ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" @@ -237,9 +235,7 @@ class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): resource: EntertainmentConfiguration entity_description = BinarySensorEntityDescription( - key="entertainment_active_sensor", - device_class=BinarySensorDeviceClass.RUNNING, - has_entity_name=False, + key="entertainment_active_sensor", device_class=BinarySensorDeviceClass.RUNNING ) @property @@ -253,7 +249,7 @@ class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): return self.resource.metadata.name -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueContactSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Contact sensor.""" @@ -275,7 +271,7 @@ class HueContactSensor(HueBaseEntity, BinarySensorEntity): return self.resource.contact_report.state != ContactState.CONTACT -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueTamperSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Tamper sensor.""" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index e6bded7a7f7..4a18a1f59a2 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -1,7 +1,5 @@ """Handles Hue resource of type `device` mapping to Home Assistant device.""" -from __future__ import annotations - from typing import TYPE_CHECKING from aiohue.v2 import HueBridgeV2 diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index c35093a9f9c..9ac568fb106 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Philips Hue events.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from aiohue.v2.models.resource import ResourceTypes @@ -89,6 +87,8 @@ def async_get_triggers( # Get Hue device id from device identifier hue_dev_id = get_hue_device_id(device_entry) + if hue_dev_id is None or hue_dev_id not in api.devices: + return [] # extract triggers from all button resources of this Hue device triggers: list[dict[str, Any]] = [] model_id = api.devices[hue_dev_id].product_data.product_name diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index e472009286d..82ad498ecd5 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -1,7 +1,5 @@ """Generic Hue Entity Model.""" -from __future__ import annotations - from typing import TYPE_CHECKING from aiohue.v2.controllers.base import BaseResourcesController @@ -34,7 +32,7 @@ RESOURCE_TYPE_NAMES = { } -class HueBaseEntity(Entity): # pylint: disable=hass-enforce-class-module +class HueBaseEntity(Entity): # pylint: disable=home-assistant-enforce-class-module """Generic Entity Class for a Hue resource.""" _attr_should_poll = False @@ -126,7 +124,8 @@ class HueBaseEntity(Entity): # pylint: disable=hass-enforce-class-module def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # cleanup entities that are not strictly device-bound and have the bridge as parent + # cleanup entities that are not strictly device-bound + # and have the bridge as parent if self.device is None and resource.id == self.resource.id: ent_reg = er.async_get(self.hass) ent_reg.async_remove(self.entity_id) @@ -143,7 +142,8 @@ class HueBaseEntity(Entity): # pylint: disable=hass-enforce-class-module # return if we already processed this entity if self._ignore_availability is not None: return - # only do the availability check for entities connected to a device (with `on` feature) + # only do the availability check for entities connected to + # a device (with `on` feature) if self.device is None or not hasattr(self.resource, "on"): self._ignore_availability = False return @@ -194,7 +194,8 @@ class HueBaseEntity(Entity): # pylint: disable=hass-enforce-class-module self.device.product_data.model_id, self.device.product_data.software_version, ) - # set attribute to false because we only want to log once per light/device. - # a user must opt-in to ignore availability through integration options + # set attribute to false because we only want to log + # once per light/device. a user must opt-in to + # ignore availability through integration options self._ignore_availability = False self._last_state = cur_state diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 8a1168d992e..797fab8fd14 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -1,7 +1,5 @@ """Support for Hue groups (room/zone).""" -from __future__ import annotations - import asyncio from typing import Any @@ -81,13 +79,12 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class GroupedHueLight(HueBaseEntity, LightEntity): """Representation of a Grouped Hue light.""" entity_description = LightEntityDescription( key="hue_grouped_light", - icon="mdi:lightbulb-group", has_entity_name=True, name=None, ) diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 12c0d6d10e8..dd0c2c97f91 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Philips Hue v2.""" -from __future__ import annotations - from homeassistant.util import color as color_util diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 2eace5139af..f89caea1e01 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -1,7 +1,5 @@ """Handle forward of events transmitted by Hue devices to HASS.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -51,7 +49,8 @@ async def async_setup_hue_events(bridge: HueBridge): # Fire event data = { - # send slugified entity name as id = backwards compatibility with previous version + # send slugified entity name as id = backwards + # compatibility with previous version CONF_ID: slugify(f"{hue_device.metadata.name} Button"), CONF_DEVICE_ID: device.id, # type: ignore[union-attr] CONF_UNIQUE_ID: hue_resource.id, @@ -80,7 +79,9 @@ async def async_setup_hue_events(bridge: HueBridge): CONF_DEVICE_ID: device.id, # type: ignore[union-attr] CONF_UNIQUE_ID: hue_resource.id, CONF_TYPE: hue_resource.relative_rotary.rotary_report.action.value, - CONF_SUBTYPE: hue_resource.relative_rotary.rotary_report.rotation.direction.value, + CONF_SUBTYPE: ( + hue_resource.relative_rotary.rotary_report.rotation.direction.value + ), CONF_DURATION: hue_resource.relative_rotary.rotary_report.rotation.duration, CONF_STEPS: hue_resource.relative_rotary.rotary_report.rotation.steps, } diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index e22d2c09f43..5b687eff196 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -1,7 +1,5 @@ """Support for Hue lights.""" -from __future__ import annotations - from functools import partial from typing import Any @@ -72,7 +70,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" @@ -179,18 +177,22 @@ class HueLight(HueBaseEntity, LightEntity): @property def max_color_temp_mireds(self) -> int: - """Return the warmest color_temp in mireds (so highest number) that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_maximum - # return a fallback value if the light doesn't provide limits + """Return the warmest color_temp in mireds that this light supports.""" + if (color_temp := self.resource.color_temperature) and ( + mirek_max := color_temp.mirek_schema.mirek_maximum + ): + return mirek_max + # return a fallback value if the light doesn't provide valid limits return FALLBACK_MAX_MIREDS @property def min_color_temp_mireds(self) -> int: - """Return the coldest color_temp in mireds (so lowest number) that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_minimum - # return a fallback value if the light doesn't provide limits + """Return the coldest color_temp in mireds that this light supports.""" + if (color_temp := self.resource.color_temperature) and ( + mirek_min := color_temp.mirek_schema.mirek_minimum + ): + return mirek_min + # return a fallback value if the light doesn't provide valid limits return FALLBACK_MIN_MIREDS @property @@ -283,10 +285,11 @@ class HueLight(HueBaseEntity, LightEntity): if flash is not None: await self.async_set_flash(flash) - # flash cannot be sent with other commands at the same time or result will be flaky - # Hue's default behavior is that a light returns to its previous state for short - # flash (identify) and the light is kept turned on for long flash (breathe effect) - # Why is this flash alert/effect hidden in the turn_on/off commands ? + # flash cannot be sent with other commands at the same + # time or result will be flaky. Hue's default behavior + # is that a light returns to its previous state for + # short flash (identify) and the light is kept turned + # on for long flash (breathe effect) return await self.bridge.async_request_call( @@ -309,9 +312,11 @@ class HueLight(HueBaseEntity, LightEntity): if flash is not None: await self.async_set_flash(flash) - # flash cannot be sent with other commands at the same time or result will be flaky - # Hue's default behavior is that a light returns to its previous state for short - # flash (identify) and the light is kept turned on for long flash (breathe effect) + # flash cannot be sent with other commands at the same + # time or result will be flaky. Hue's default behavior + # is that a light returns to its previous state for + # short flash (identify) and the light is kept turned + # on for long flash (breathe effect) return await self.bridge.async_request_call( diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 0c92b0c8b3e..c5e621affce 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -1,7 +1,5 @@ """Support for Hue sensors.""" -from __future__ import annotations - from functools import partial from typing import Any @@ -54,20 +52,24 @@ def _resource_valid( ) -> bool: """Return True if the resource is valid.""" if isinstance(resource, GroupedLightLevel): - # filter out GroupedLightLevel sensors that are not linked to a valid group/parent + # filter out GroupedLightLevel sensors that are not linked + # to a valid group/parent if resource.owner.rtype not in ( ResourceTypes.ROOM, ResourceTypes.ZONE, ResourceTypes.SERVICE_GROUP, ): return False - # guard against GroupedLightLevel without parent (should not happen, but just in case) + # guard against GroupedLightLevel without parent + # (should not happen, but just in case) parent_id = resource.owner.rid parent = api.groups.get(parent_id) or api.config.get(parent_id) if not parent: return False - # filter out GroupedLightLevel sensors that have only one member, because Hue creates one - # default grouped LightLevel sensor per zone/room, which is not useful to expose in HA + # filter out GroupedLightLevel sensors that have only one + # member, because Hue creates one default grouped + # LightLevel sensor per zone/room, which is not useful + # to expose in HA if len(parent.children) <= 1: return False # default/other checks can go here (none for now) @@ -117,7 +119,7 @@ async def async_setup_entry( register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueSensorBase(HueBaseEntity, SensorEntity): """Representation of a Hue sensor.""" @@ -133,7 +135,7 @@ class HueSensorBase(HueBaseEntity, SensorEntity): self.controller = controller -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueTemperatureSensor(HueSensorBase): """Representation of a Hue Temperature sensor.""" @@ -151,7 +153,7 @@ class HueTemperatureSensor(HueSensorBase): return round(self.resource.temperature.value, 1) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueLightLevelSensor(HueSensorBase): """Representation of a Hue LightLevel (illuminance) sensor.""" @@ -180,9 +182,9 @@ class HueLightLevelSensor(HueSensorBase): } -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueGroupedLightLevelSensor(HueLightLevelSensor): - """Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource.""" + """Representation of a LightLevel sensor from a Hue GroupedLightLevel resource.""" controller: GroupedLightLevelController resource: GroupedLightLevel @@ -205,7 +207,7 @@ class HueGroupedLightLevelSensor(HueLightLevelSensor): ) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" @@ -231,7 +233,7 @@ class HueBatterySensor(HueSensorBase): return {"battery_state": self.resource.power_state.battery_state.value} -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" diff --git a/homeassistant/components/hue_ble/config_flow.py b/homeassistant/components/hue_ble/config_flow.py index fff171609fa..b1fe6f15b19 100644 --- a/homeassistant/components/hue_ble/config_flow.py +++ b/homeassistant/components/hue_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hue BLE integration.""" -from __future__ import annotations - from enum import Enum import logging from typing import Any diff --git a/homeassistant/components/hue_ble/light.py b/homeassistant/components/hue_ble/light.py index 9302ec7349b..acddc78bd94 100644 --- a/homeassistant/components/hue_ble/light.py +++ b/homeassistant/components/hue_ble/light.py @@ -1,7 +1,5 @@ """Hue BLE light platform.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index 707594fcde1..fffc31c3e93 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["bleak", "HueBLE"], "quality_scale": "bronze", - "requirements": ["HueBLE==2.1.0"] + "requirements": ["HueBLE==2.2.2"] } diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index d6049e58550..212d3a03105 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 8ee4a1eaf78..fcebeb32885 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with humidifier devices.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum import logging @@ -24,7 +22,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 @@ -78,7 +75,6 @@ DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] # mypy: disallow-any-generics -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the humidifier is on based on the statemachine. diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index 2a96eaffe37..8c3182243c8 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -4,8 +4,15 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_MODE, + CONF_MODE, + CONF_OPTIONS, + PERCENTAGE, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec @@ -13,8 +20,8 @@ from homeassistant.helpers.condition import ( ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionConfig, + EntityNumericalConditionBase, EntityStateConditionBase, - make_entity_numerical_condition, make_entity_state_condition, ) from homeassistant.helpers.entity import get_supported_features @@ -27,8 +34,6 @@ from .const import ( HumidifierEntityFeature, ) -CONF_MODE = "mode" - IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { vol.Required(CONF_OPTIONS): { @@ -46,6 +51,20 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo return False +class IsTargetHumidityCondition(EntityNumericalConditionBase): + """Condition for humidifier target humidity.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _valid_unit = PERCENTAGE + + def _should_include(self, state: State) -> bool: + """Skip humidifier entities that do not expose a target humidity.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_HUMIDITY) is not None + ) + + class IsModeCondition(EntityStateConditionBase): """Condition for humidifier mode.""" @@ -79,10 +98,7 @@ CONDITIONS: dict[str, type[Condition]] = { {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), "is_mode": IsModeCondition, - "is_target_humidity": make_entity_numerical_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit=PERCENTAGE, - ), + "is_target_humidity": IsTargetHumidityCondition, } diff --git a/homeassistant/components/humidifier/conditions.yaml b/homeassistant/components/humidifier/conditions.yaml index 25c29301f26..4c049060112 100644 --- a/homeassistant/components/humidifier/conditions.yaml +++ b/homeassistant/components/humidifier/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: .humidity_threshold_entity: &humidity_threshold_entity - domain: input_number @@ -36,6 +38,7 @@ is_mode: target: *condition_humidifier_target fields: behavior: *condition_behavior + for: *condition_for mode: context: filter_target: target @@ -49,6 +52,7 @@ is_target_humidity: target: *condition_humidifier_target fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 9ff36412418..d3ae95cfc5d 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Humidifier.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 7ea9899bba7..f3ac0c11605 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -1,7 +1,5 @@ """Provide the device automations for Humidifier.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 80e0ef8df58..7fe5adac40f 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Climate.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 490143c728d..346616d53ed 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -1,7 +1,5 @@ """Intents for the humidifier integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index 7caff04acdb..45941f32737 100644 --- a/homeassistant/components/humidifier/reproduce_state.py +++ b/homeassistant/components/humidifier/reproduce_state.py @@ -1,7 +1,5 @@ """Module that groups code required to handle state restore for component.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py index dcf89f2eba9..c8d588512a0 100644 --- a/homeassistant/components/humidifier/significant_change.py +++ b/homeassistant/components/humidifier/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Humidifier state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index ff7f28a2e5f..9838f9a7967 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", - "trigger_behavior_name": "Trigger when" + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_drying": { @@ -10,6 +12,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is drying" @@ -19,6 +24,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is humidifying" @@ -29,6 +37,9 @@ "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" + }, "mode": { "description": "The operation modes to check for.", "name": "Mode" @@ -41,6 +52,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is off" @@ -50,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is on" @@ -60,6 +77,9 @@ "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::humidifier::common::condition_threshold_name%]" } @@ -154,21 +174,6 @@ "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "set_humidity": { "description": "Sets the target humidity of a humidifier.", @@ -211,6 +216,9 @@ "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" + }, "mode": { "description": "The operation modes to trigger on.", "name": "Mode" @@ -223,6 +231,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started drying" @@ -232,6 +243,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started humidifying" @@ -241,6 +255,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier turned off" @@ -250,6 +267,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier turned on" diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index b0df9126733..a4fe2ac479c 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -2,14 +2,14 @@ import voluptuous as vol -from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_MODE, CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, EntityTargetStateTriggerBase, Trigger, TriggerConfig, @@ -18,9 +18,7 @@ from homeassistant.helpers.trigger import ( from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature -CONF_MODE = "mode" - -MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( +MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( { vol.Required(CONF_OPTIONS): { vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 12072ab71eb..706fb446960 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: started_drying: *trigger_common started_humidifying: *trigger_common @@ -23,6 +24,7 @@ mode_changed: target: *trigger_humidifier_target fields: behavior: *trigger_behavior + for: *trigger_for mode: context: filter_target: target diff --git a/homeassistant/components/humidity/__init__.py b/homeassistant/components/humidity/__init__.py index 59840a5f14f..bac8ab70e2f 100644 --- a/homeassistant/components/humidity/__init__.py +++ b/homeassistant/components/humidity/__init__.py @@ -1,7 +1,5 @@ """Integration for humidity triggers and conditions.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index 101815a4009..b06c0b285e1 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -1,7 +1,5 @@ """Provides conditions for humidity.""" -from __future__ import annotations - from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY, DOMAIN as CLIMATE_DOMAIN, @@ -16,9 +14,9 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.condition import Condition, make_entity_numerical_condition +from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase HUMIDITY_DOMAIN_SPECS = { CLIMATE_DOMAIN: DomainSpec( @@ -33,8 +31,31 @@ HUMIDITY_DOMAIN_SPECS = { ), } + +class HumidityCondition(EntityNumericalConditionBase): + """Condition for humidity value across multiple domains.""" + + _domain_specs = HUMIDITY_DOMAIN_SPECS + _valid_unit = PERCENTAGE + + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the humidity attribute. + + Mirrors the humidity trigger: for climate / humidifier / weather + (attribute-based), the entity is filtered when the source attribute + is absent; sensor entities (state-value-based) fall through to the + base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + + CONDITIONS: dict[str, type[Condition]] = { - "is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE), + "is_value": HumidityCondition, } diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 06818a57974..9eac07e9359 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -25,11 +25,13 @@ is_value: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: threshold: required: true selector: diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json index 20df0ca139a..7f51d21f4f4 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::humidity::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::humidity::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Relative humidity" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Humidity", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::humidity::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::humidity::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 53347675045..69c22ebdbd3 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for humidity.""" -from __future__ import annotations - from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY, DOMAIN as CLIMATE_DOMAIN, @@ -15,12 +13,13 @@ from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, DOMAIN as WEATHER_DOMAIN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, Trigger, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, ) HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { @@ -38,13 +37,46 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { ), } + +class _HumidityTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for humidity triggers providing entity filtering.""" + + _domain_specs = HUMIDITY_DOMAIN_SPECS + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the humidity attribute. + + For domains whose tracked value comes from an attribute + (climate / humidifier / weather), require the attribute to be + present; otherwise the all/count check would treat an entity that + cannot report a humidity as a non-match and block behavior=last. + Sensor entities source their value from `state.state`, so they + fall through to the base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + + +class HumidityChangedTrigger( + _HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase +): + """Trigger for humidity value changes across multiple domains.""" + + +class HumidityCrossedThresholdTrigger( + _HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase +): + """Trigger for humidity value crossing a threshold across multiple domains.""" + + TRIGGERS: dict[str, type[Trigger]] = { - "changed": make_entity_numerical_state_changed_trigger( - HUMIDITY_DOMAIN_SPECS, valid_unit="%" - ), - "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - HUMIDITY_DOMAIN_SPECS, valid_unit="%" - ), + "changed": HumidityChangedTrigger, + "crossed_threshold": HumidityCrossedThresholdTrigger, } diff --git a/homeassistant/components/humidity/triggers.yaml b/homeassistant/components/humidity/triggers.yaml index 0b29fcf871f..66af94121b2 100644 --- a/homeassistant/components/humidity/triggers.yaml +++ b/homeassistant/components/humidity/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .humidity_threshold_entity: &humidity_threshold_entity - domain: input_number @@ -47,6 +48,7 @@ crossed_threshold: target: *trigger_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index c0bcac3a7df..e25565e99c0 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -1,7 +1,5 @@ """Buttons for Hunter Douglas Powerview advanced features.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index c53c08c8ac7..cf7d7b75984 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Hunter Douglas PowerView integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, Self diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 8bc89e10c1f..c08ddcf58c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -1,7 +1,5 @@ """Coordinate data for powerview devices.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -28,7 +26,7 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, shades: Shades, hub: Hub ) -> None: - """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" + """Initialize DataUpdateCoordinator to gather data for specific Hub.""" self.shades = shades self.hub = hub # The hub tends to crash if there are multiple radio operations at the same time diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index b78d0be0865..fc2358e66fd 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -1,7 +1,5 @@ """Support for hunter douglas shades.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import replace from datetime import datetime, timedelta @@ -135,6 +133,11 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """ return self._is_hard_wired + @property + def available(self) -> bool: + """Return True if shade position data is available.""" + return super().available and self.positions.primary is not None + @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" @@ -288,7 +291,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): await self.async_update() self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( @@ -537,7 +540,8 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): class PowerViewShadeTopDown(PowerViewShadeBase): """Representation of a shade that lowers from the roof to the floor. - These shades are inverted where MAX_POSITION equates to closed and MIN_POSITION is open + These shades are inverted where MAX_POSITION equates to closed + and MIN_POSITION is open API Class: ShadeTopDown Type 6 - Top Down @@ -913,7 +917,8 @@ class PowerViewShadeDualOverlappedCombinedTilt( Sibling Class: PowerViewShadeDualOverlappedFront, PowerViewShadeDualOverlappedRear API Class: ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 - Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear opaque (non-tilting) shade) + Type 9 - Duolite with 90° Tilt (front bottom up shade that also + tilts plus a rear opaque (non-tilting) shade) Type 10 - Duolite with 180° Tilt """ diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index d7d88a849b4..eb90737faba 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Powerview Hunter Douglas.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py index 407de86368f..3e5ec3d517c 100644 --- a/homeassistant/components/hunterdouglas_powerview/model.py +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -1,7 +1,5 @@ """Define Hunter Douglas data models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 5016b590f91..b09dddbe759 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -1,7 +1,5 @@ """Support for Powerview scenes from a Powerview hub.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 932ff3ce3bd..216c751612e 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -1,7 +1,5 @@ """Support for hunterdouglass_powerview settings.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 6ebf8e2b278..2dd18a5a1f1 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,7 +136,7 @@ class PowerViewSensor(ShadeEntity, SensorEntity): """Return the class of this entity.""" return self.entity_description.device_class_fn(self._shade) - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hunterdouglas_powerview/util.py b/homeassistant/components/hunterdouglas_powerview/util.py index 3551a78e627..77f384cb0f7 100644 --- a/homeassistant/components/hunterdouglas_powerview/util.py +++ b/homeassistant/components/hunterdouglas_powerview/util.py @@ -1,7 +1,5 @@ """Coordinate data for powerview devices.""" -from __future__ import annotations - from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.hub import Hub diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py index 8a9a31b926a..7c486219076 100644 --- a/homeassistant/components/husqvarna_automower/api.py +++ b/homeassistant/components/husqvarna_automower/api.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) class AsyncConfigEntryAuth(AbstractAuth): - """Provide Husqvarna Automower authentication tied to an OAuth2 based config entry.""" + """Provide Husqvarna Automower authentication tied to an OAuth2 config entry.""" def __init__( self, diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index ac7447bc3c0..5277bc6ff0a 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -82,8 +82,9 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): work_area_name = self.mower_attributes.work_area_dict[ program_event.work_area_id ] + name_str = make_name_string(work_area_name, program_event.schedule_no) return CalendarEvent( - summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", + summary=f"{self.device_name} {name_str}", start=program_event.start, end=program_event.end, rrule=program_event.rrule_str, @@ -110,9 +111,10 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): work_area_name = self.mower_attributes.work_area_dict[ program_event.work_area_id ] + name_str = make_name_string(work_area_name, program_event.schedule_no) calendar_events.append( CalendarEvent( - summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", + summary=f"{self.device_name} {name_str}", start=program_event.start.replace(tzinfo=start_date.tzinfo), end=program_event.end.replace(tzinfo=start_date.tzinfo), rrule=program_event.rrule_str, diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 31ca5eef0cd..b4540f28fa1 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -104,7 +104,9 @@ class HusqvarnaConfigFlowHandler( return self.async_show_form( step_id="missing_scope", description_placeholders={ - "application_url": f"{HUSQVARNA_DEV_PORTAL_URL}/{token_structured.client_id}" + "application_url": ( + f"{HUSQVARNA_DEV_PORTAL_URL}/{token_structured.client_id}" + ) }, ) return await self.async_step_user() diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index aa1682923c8..359c369699d 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -1,7 +1,5 @@ """Data UpdateCoordinator for the Husqvarna Automower integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import datetime, timedelta diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py index ceeec0f3e0d..bb0d7bb4143 100644 --- a/homeassistant/components/husqvarna_automower/diagnostics.py +++ b/homeassistant/components/husqvarna_automower/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Husqvarna Automower.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 92d35616b2d..d3cf11059cf 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -1,12 +1,10 @@ """Platform for Husqvarna Automower base entity.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import functools import logging -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload +from typing import TYPE_CHECKING, Any, Concatenate, overload from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea @@ -37,18 +35,14 @@ ERROR_STATES = [ ] -_Entity = TypeVar("_Entity", bound="AutomowerBaseEntity") -_P = ParamSpec("_P") - - @overload -def handle_sending_exception( +def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( _func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]: ... @overload -def handle_sending_exception( +def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( *, poll_after_sending: bool = False, ) -> Callable[ @@ -57,7 +51,7 @@ def handle_sending_exception( ]: ... -def handle_sending_exception( +def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( _func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]] | None = None, *, poll_after_sending: bool = False, @@ -85,8 +79,9 @@ def handle_sending_exception( ) from exception else: if poll_after_sending: - # As there are no updates from the websocket for this attribute, - # we need to wait until the command is executed and then poll the API. + # As there are no updates from the websocket for + # this attribute, we need to wait until the + # command is executed and then poll the API. await asyncio.sleep(EXECUTION_TIME_DELAY) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py index 2d7edcf1c73..b3a98b50444 100644 --- a/homeassistant/components/husqvarna_automower/event.py +++ b/homeassistant/components/husqvarna_automower/event.py @@ -10,6 +10,7 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,8 +24,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 ATTR_SEVERITY = "severity" -ATTR_LATITUDE = "latitude" -ATTR_LONGITUDE = "longitude" ATTR_DATE_TIME = "date_time" @@ -104,7 +103,7 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): @callback def _handle(self, msg: SingleMessageData) -> None: - """Handle a message event from the API and trigger the event entity if it matches the entity's mower ID.""" + """Handle a message event from the API and trigger for matching mower ID.""" if msg.id != self.mower_id: return message = msg.attributes.message diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index aa77ae2f7b7..e34e4181ac8 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.7.3"] + "requirements": ["aioautomower==2.7.6"] } diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml index d0435c51eee..f98dd4770fd 100644 --- a/homeassistant/components/husqvarna_automower/quality_scale.yaml +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: status: exempt diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 89de3336440..ff048b51946 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -1,13 +1,12 @@ """The Husqvarna Autoconnect Bluetooth integration.""" -from __future__ import annotations - from automower_ble.mower import Mower from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address, get_device from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform from homeassistant.core import HomeAssistant @@ -53,11 +52,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> ) if response_result != ResponseResult.OK: raise ConfigEntryNotReady( - f"Unable to connect to device {address}, mower returned {response_result}" + f"Unable to connect to device {address}, " + f"mower returned {response_result}" ) except (TimeoutError, BleakError) as exception: raise ConfigEntryNotReady( - f"Unable to connect to device {address} due to {exception}" + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={ + "address": address, + "error": str(exception) or type(exception).__name__, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) from exception LOGGER.debug("connected and paired") diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index d36b89f2d13..d693813afeb 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Husqvarna Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Mapping import random from typing import Any @@ -11,7 +9,8 @@ from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import get_device from gardena_bluetooth.const import ScanService -from gardena_bluetooth.parse import ManufacturerData, ProductType +from gardena_bluetooth.parse import ProductType +from gardena_bluetooth.scan import async_get_manufacturer_data import voluptuous as vol from homeassistant.components import bluetooth @@ -37,43 +36,6 @@ USER_SCHEMA = vol.Schema( REAUTH_SCHEMA = BLUETOOTH_SCHEMA -def _is_supported(discovery_info: BluetoothServiceInfo): - """Check if device is supported.""" - if ScanService not in discovery_info.service_uuids: - LOGGER.debug( - "Unsupported device, missing service %s: %s", ScanService, discovery_info - ) - return False - - if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)): - LOGGER.debug( - "Unsupported device, missing manufacturer data %s: %s", - ManufacturerData.company, - discovery_info, - ) - return False - - manufacturer_data = ManufacturerData.decode(data) - product_type = ProductType.from_manufacturer_data(manufacturer_data) - - # Some mowers only expose the serial number in the manufacturer data - # and not the product type, so we allow None here as well. - if product_type not in (ProductType.MOWER, ProductType.UNKNOWN): - LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info) - return False - - if not manufacturer_data.pairable: - LOGGER.error( - "The mower does not appear to be pairable. " - "Ensure the mower is in pairing mode before continuing. " - "If the mower isn't pariable you will receive authentication " - "errors and be unable to connect" - ) - - LOGGER.debug("Supported device: %s", manufacturer_data) - return True - - def _pin_valid(pin: str) -> bool: """Check if the pin is valid.""" try: @@ -91,6 +53,32 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): address: str | None = None mower_name: str = "" pin: str | None = None + pairable: bool | None = None + + async def _is_supported(self, discovery_info: BluetoothServiceInfo): + """Check if device is supported.""" + if ScanService not in discovery_info.service_uuids: + LOGGER.debug( + "Unsupported device, missing service %s: %s", + ScanService, + discovery_info, + ) + return False + + manufacturer_data = ( + await async_get_manufacturer_data({discovery_info.address}) + )[discovery_info.address] + + if manufacturer_data.product_type is not ProductType.MOWER: + LOGGER.debug( + "Unsupported device: %s (%s)", manufacturer_data, discovery_info + ) + return False + + self.pairable = manufacturer_data.pairable + + LOGGER.debug("Supported device: %s", manufacturer_data) + return True async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -98,7 +86,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the bluetooth discovery step.""" LOGGER.debug("Discovered device: %s", discovery_info) - if not _is_supported(discovery_info): + if not await self._is_supported(discovery_info): return self.async_abort(reason="no_devices_found") self.context["title_placeholders"] = { @@ -122,6 +110,13 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_pin" else: self.pin = user_input[CONF_PIN] + if self.pairable is False: + LOGGER.warning( + "The mower does not appear to be pairable. " + "Ensure the mower is in pairing mode before continuing. " + "If the mower isn't pairable you will receive authentication " + "errors and be unable to connect" + ) return await self.check_mower(user_input) return self.async_show_form( @@ -161,6 +156,10 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): assert self.address + if device is None: + LOGGER.debug("Could not find device with address '%s'", self.address) + return None + try: (manufacturer, device_type, _model) = await Mower( channel_id, self.address diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index cd5b4e06005..3a07356c440 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -1,7 +1,5 @@ """Provides the DataUpdateCoordinator.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/husqvarna_automower_ble/entity.py b/homeassistant/components/husqvarna_automower_ble/entity.py index 32e5873ab0e..0ec7bfa04e9 100644 --- a/homeassistant/components/husqvarna_automower_ble/entity.py +++ b/homeassistant/components/husqvarna_automower_ble/entity.py @@ -1,7 +1,5 @@ """Provides the HusqvarnaAutomowerBleEntity.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, DeviceInfo, diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index ffe05bac8a8..440ac703d3c 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -1,7 +1,5 @@ """The Husqvarna Autoconnect Bluetooth lawn mower platform.""" -from __future__ import annotations - from automower_ble.protocol import MowerActivity, MowerState, ResponseResult from homeassistant.components import bluetooth diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 9026532c00b..190891e3b3c 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"] + "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"] } diff --git a/homeassistant/components/husqvarna_automower_ble/sensor.py b/homeassistant/components/husqvarna_automower_ble/sensor.py index f747133c950..8994b7117fb 100644 --- a/homeassistant/components/husqvarna_automower_ble/sensor.py +++ b/homeassistant/components/husqvarna_automower_ble/sensor.py @@ -1,7 +1,5 @@ """Support for sensor entities.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/husqvarna_automower_ble/strings.json b/homeassistant/components/husqvarna_automower_ble/strings.json index ef366540db9..edf93d1bece 100644 --- a/homeassistant/components/husqvarna_automower_ble/strings.json +++ b/homeassistant/components/husqvarna_automower_ble/strings.json @@ -45,6 +45,9 @@ } }, "exceptions": { + "connection_failed": { + "message": "Unable to connect to device {address} due to {error}: {reason}" + }, "pin_required": { "message": "PIN is required for {domain_name}" } diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index d2dd7ff4fa3..07cfdce03b6 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -1,7 +1,5 @@ """The Huum integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py index cb5da1879c7..f6f8fc35e79 100644 --- a/homeassistant/components/huum/binary_sensor.py +++ b/homeassistant/components/huum/binary_sensor.py @@ -1,7 +1,5 @@ """Sensor for door state.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 319f2475ba7..36373457806 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -1,7 +1,5 @@ """Support for Huum wifi-enabled sauna.""" -from __future__ import annotations - from typing import Any from huum.const import SaunaStatus diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index c5cdc18107a..0139914ad98 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -1,7 +1,5 @@ """Config flow for huum integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -59,6 +57,43 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + try: + huum = Huum( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + await huum.status() + except Forbidden, NotAuthenticated: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + title=user_input[CONF_USERNAME], + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]}, + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py index fac9f234ea8..a37588bb253 100644 --- a/homeassistant/components/huum/coordinator.py +++ b/homeassistant/components/huum/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Huum.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/huum/diagnostics.py b/homeassistant/components/huum/diagnostics.py index ebfa7bafc20..d98c908aa96 100644 --- a/homeassistant/components/huum/diagnostics.py +++ b/homeassistant/components/huum/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Huum.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py index 5881d2d08b9..c668675a742 100644 --- a/homeassistant/components/huum/light.py +++ b/homeassistant/components/huum/light.py @@ -1,7 +1,5 @@ """Control for light.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/huum/number.py b/homeassistant/components/huum/number.py index f0d934bf351..a77f5ce1c5a 100644 --- a/homeassistant/components/huum/number.py +++ b/homeassistant/components/huum/number.py @@ -1,7 +1,5 @@ """Control for steamer.""" -from __future__ import annotations - import logging from huum.const import SaunaStatus diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index 72fc2db3428..6422498628a 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration has no repair scenarios. diff --git a/homeassistant/components/huum/sensor.py b/homeassistant/components/huum/sensor.py index 0ceed8510d0..4c403088cf0 100644 --- a/homeassistant/components/huum/sensor.py +++ b/homeassistant/components/huum/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Huum sauna integration.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 8b50fcd5eee..e7c59783807 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -20,6 +21,16 @@ "description": "The authentication for {username} is no longer valid. Please enter the current password.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::huum::config::step::user::data_description::password%]", + "username": "[%key:component::huum::config::step::user::data_description::username%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 6260fd9fef4..f4917223a6d 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -1,14 +1,19 @@ """Binary sensor platform for hvv_departures.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging from typing import Any from aiohttp import ClientConnectorError -from pygti.exceptions import InvalidAuth +from pygti.exceptions import GTIError +from pygti.models import ( + ElevatorState, + SDName, + SDNameType, + StationInformationRequest, + StationInformationResponse, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -40,20 +45,21 @@ async def async_setup_entry( station = entry.data[CONF_STATION] def get_elevator_entities_from_station_information( - station_name, station_information - ): + station_name: str, + station_information: StationInformationResponse | None, + ) -> dict[str, Any]: """Convert station information into a list of elevators.""" elevators = {} if station_information is None: return {} - for partial_station in station_information.get("partialStations", []): - for elevator in partial_station.get("elevators", []): - state = elevator.get("state") != "READY" - available = elevator.get("state") != "UNKNOWN" - label = elevator.get("label") - description = elevator.get("description") + for partial_station in station_information.partialStations or []: + for elevator in partial_station.elevators or []: + state = elevator.state != ElevatorState.READY + available = elevator.state != ElevatorState.UNKNOWN + label = elevator.label + description = elevator.description if label is not None: name = f"Elevator {label}" @@ -63,7 +69,7 @@ async def async_setup_entry( if description is not None: name += f" ({description})" - lines = elevator.get("lines") + lines = elevator.lines idx = f"{station_name}-{label}-{lines}" @@ -72,33 +78,35 @@ async def async_setup_entry( "name": name, "available": available, "attributes": { - "cabin_width": elevator.get("cabinWidth"), - "cabin_length": elevator.get("cabinLength"), - "door_width": elevator.get("doorWidth"), - "elevator_type": elevator.get("elevatorType"), - "button_type": elevator.get("buttonType"), - "cause": elevator.get("cause"), + "cabin_width": elevator.cabinWidth, + "cabin_length": elevator.cabinLength, + "door_width": elevator.doorWidth, + "elevator_type": elevator.elevatorType, + "button_type": elevator.buttonType, + "cause": elevator.cause, "lines": lines, }, } return elevators - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Fetch data from API endpoint. This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ - payload = {"station": {"id": station["id"], "type": station["type"]}} + payload = StationInformationRequest( + station=SDName(id=station["id"], type=SDNameType(station["type"])) + ) try: async with asyncio.timeout(10): return get_elevator_entities_from_station_information( - station_name, await hub.gti.stationInformation(payload) + station_name, await hub.gti.getStationInformation(payload) ) - except InvalidAuth as err: - raise UpdateFailed(f"Authentication failed: {err}") from err + except GTIError as err: + raise UpdateFailed(f"GTI API error: {err}") from err except ClientConnectorError as err: raise UpdateFailed(f"Network not available: {err}") from err except Exception as err: @@ -131,7 +139,12 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): _attr_has_entity_name = True _attr_device_class = BinarySensorDeviceClass.PROBLEM - def __init__(self, coordinator, idx, config_entry): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + idx: str, + config_entry: HVVConfigEntry, + ) -> None: """Initialize.""" super().__init__(coordinator) self.coordinator = coordinator @@ -142,7 +155,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ - ( + ( # type: ignore[arg-type] DOMAIN, config_entry.entry_id, config_entry.data[CONF_STATION]["id"], @@ -156,7 +169,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return entity state.""" - return self.coordinator.data[self.idx]["state"] + return bool(self.coordinator.data[self.idx]["state"]) @property def available(self) -> bool: diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 63d457bf302..c3ac3e35023 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -1,12 +1,19 @@ """Config flow for HVV integration.""" -from __future__ import annotations - import logging from typing import Any +from aiohttp import ClientConnectorError from pygti.auth import GTI_DEFAULT_HOST -from pygti.exceptions import CannotConnect, InvalidAuth +from pygti.exceptions import GTIError, GTIUnauthorizedError +from pygti.models import ( + CNRequest, + DLRequest, + GTITime, + RegionalSDNameType, + SDName, + SDNameType, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -68,10 +75,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): try: response = await self.hub.authenticate() _LOGGER.debug("Init gti: %r", response) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: + except GTIUnauthorizedError: errors["base"] = "invalid_auth" + except GTIError, ClientConnectorError: + errors["base"] = "cannot_connect" if not errors: self.data = user_input @@ -89,15 +96,14 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} check_name = await self.hub.gti.checkName( - {"theName": {"name": user_input[CONF_STATION]}, "maxList": 20} + CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20) ) - stations = check_name.get("results") - self.stations = { - f"{station.get('name')}": station - for station in stations - if station.get("type") == "STATION" + station.name: station + for station in (check_name.results or []) + if station.type == RegionalSDNameType.STATION + and station.name is not None } if not self.stations: @@ -123,7 +129,13 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="station_select", data_schema=schema) - self.data.update({"station": self.stations[user_input[CONF_STATION]]}) + self.data.update( + { + "station": self.stations[user_input[CONF_STATION]].model_dump( + mode="json", exclude_none=True + ) + } + ) title = self.data[CONF_STATION]["name"] @@ -153,32 +165,30 @@ class OptionsFlowHandler(OptionsFlow): """Manage the options.""" errors = {} if not self.departure_filters: - departure_list = {} hub = self.config_entry.runtime_data try: departure_list = await hub.gti.departureList( - { - "station": { - "type": "STATION", - "id": self.config_entry.data[CONF_STATION].get("id"), - }, - "time": {"date": "heute", "time": "jetzt"}, - "maxList": 5, - "maxTimeOffset": 200, - "useRealtime": True, - "returnFilters": True, - } + DLRequest( + station=SDName( + id=self.config_entry.data[CONF_STATION].get("id"), + type=SDNameType.STATION, + ), + time=GTITime(date="heute", time="jetzt"), + maxList=5, + maxTimeOffset=200, + useRealtime=True, + returnFilters=True, + ) ) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: + except GTIUnauthorizedError: errors["base"] = "invalid_auth" - - if not errors: + except GTIError, ClientConnectorError: + errors["base"] = "cannot_connect" + else: self.departure_filters = { - str(i): departure_filter - for i, departure_filter in enumerate(departure_list["filter"]) + str(i): f.model_dump(mode="json", exclude_none=True) + for i, f in enumerate(departure_list.filter or []) } if user_input is not None and not errors: @@ -208,8 +218,8 @@ class OptionsFlowHandler(OptionsFlow): vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select( { key: ( - f"{departure_filter['serviceName']}," - f" {departure_filter['label']}" + f"{departure_filter.get('serviceName', '')}," + f" {departure_filter.get('label', '')}" ) for key, departure_filter in self.departure_filters.items() } diff --git a/homeassistant/components/hvv_departures/hub.py b/homeassistant/components/hvv_departures/hub.py index 31523b72ba1..6bc83671d1a 100644 --- a/homeassistant/components/hvv_departures/hub.py +++ b/homeassistant/components/hvv_departures/hub.py @@ -1,6 +1,8 @@ """Hub.""" +from aiohttp import ClientSession from pygti.gti import GTI, Auth +from pygti.models import InitRequest, InitResponse from homeassistant.config_entries import ConfigEntry @@ -10,7 +12,9 @@ type HVVConfigEntry = ConfigEntry[GTIHub] class GTIHub: """GTI Hub.""" - def __init__(self, host, username, password, session): + def __init__( + self, host: str, username: str, password: str, session: ClientSession + ) -> None: """Initialize.""" self.host = host self.username = username @@ -18,7 +22,7 @@ class GTIHub: self.gti = GTI(Auth(session, self.username, self.password, self.host)) - async def authenticate(self): + async def authenticate(self) -> InitResponse: """Test if we can authenticate with the host.""" - return await self.gti.init() + return await self.gti.init(InitRequest()) diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json index 3e23d2da8c9..b94799a01ec 100644 --- a/homeassistant/components/hvv_departures/manifest.json +++ b/homeassistant/components/hvv_departures/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pygti"], - "requirements": ["pygti==0.9.4"] + "requirements": ["pygti==1.1.1"] } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 1b10451f22d..ab18af38c69 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -4,8 +4,9 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectorError -from pygti.exceptions import InvalidAuth +from aiohttp import ClientConnectorError, ClientSession +from pygti.exceptions import GTIError, GTIUnauthorizedError +from pygti.models import DLRequest, GTITime, SDName, SDNameType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import ATTR_ID, CONF_OFFSET @@ -16,8 +17,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone, utcnow -from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER -from .hub import HVVConfigEntry +from .const import ( + ATTRIBUTION, + CONF_FILTER, + CONF_REAL_TIME, + CONF_STATION, + DOMAIN, + MANUFACTURER, +) +from .hub import GTIHub, HVVConfigEntry MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) MAX_LIST = 20 @@ -62,11 +70,17 @@ class HVVDepartureSensor(SensorEntity): _attr_has_entity_name = True _attr_available = False - def __init__(self, hass, config_entry, session, hub): + def __init__( + self, + hass: HomeAssistant, + config_entry: HVVConfigEntry, + session: ClientSession, + hub: GTIHub, + ) -> None: """Initialize.""" self.config_entry = config_entry self.station_name = self.config_entry.data[CONF_STATION]["name"] - self._last_error = None + self._last_error: type[Exception] | Exception | None = None self._attr_extra_state_attributes = {} self.gti = hub.gti @@ -77,7 +91,7 @@ class HVVDepartureSensor(SensorEntity): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ - ( + ( # type: ignore[arg-type] DOMAIN, config_entry.entry_id, config_entry.data[CONF_STATION]["id"], @@ -99,39 +113,46 @@ class HVVDepartureSensor(SensorEntity): station = self.config_entry.data[CONF_STATION] - payload = { - "station": {"id": station["id"], "type": station["type"]}, - "time": { - "date": departure_time_tz_berlin.strftime("%d.%m.%Y"), - "time": departure_time_tz_berlin.strftime("%H:%M"), - }, - "maxList": MAX_LIST, - "maxTimeOffset": MAX_TIME_OFFSET, - "useRealtime": self.config_entry.options.get(CONF_REAL_TIME, False), - } - - if "filter" in self.config_entry.options: - payload.update({"filter": self.config_entry.options["filter"]}) + request = DLRequest( + station=SDName(id=station["id"], type=SDNameType(station["type"])), + time=GTITime( + date=departure_time_tz_berlin.strftime("%d.%m.%Y"), + time=departure_time_tz_berlin.strftime("%H:%M"), + ), + maxList=MAX_LIST, + maxTimeOffset=MAX_TIME_OFFSET, + useRealtime=self.config_entry.options.get(CONF_REAL_TIME, False), + filter=self.config_entry.options.get(CONF_FILTER), + ) try: - data = await self.gti.departureList(payload) - except InvalidAuth as error: - if self._last_error != InvalidAuth: + data = await self.gti.departureList(request) + except GTIUnauthorizedError as error: + if self._last_error != GTIUnauthorizedError: _LOGGER.error("Authentication failed: %r", error) - self._last_error = InvalidAuth + self._last_error = GTIUnauthorizedError self._attr_available = False + return + except GTIError as error: + if self._last_error != GTIError: + _LOGGER.warning("GTI API error: %r", error) + self._last_error = GTIError + self._attr_available = False + return except ClientConnectorError as error: if self._last_error != ClientConnectorError: _LOGGER.warning("Network unavailable: %r", error) self._last_error = ClientConnectorError self._attr_available = False + return except Exception as error: # noqa: BLE001 if self._last_error != error: _LOGGER.error("Error occurred while fetching data: %r", error) self._last_error = error self._attr_available = False + return - if not (data["returnCode"] == "OK" and data.get("departures")): + if not data.departures: self._attr_available = False return @@ -140,25 +161,27 @@ class HVVDepartureSensor(SensorEntity): self._last_error = None - departure = data["departures"][0] - line = departure["line"] - delay = departure.get("delay", 0) - cancelled = departure.get("cancelled", False) - extra = departure.get("extra", False) + departure = data.departures[0] + line = departure.line + delay = departure.delay if departure.delay is not None else 0 + cancelled = departure.cancelled if departure.cancelled is not None else False + extra = departure.extra if departure.extra is not None else False self._attr_available = True self._attr_native_value = ( departure_time - + timedelta(minutes=departure["timeOffset"]) + + timedelta( + minutes=departure.timeOffset if departure.timeOffset is not None else 0 + ) + timedelta(seconds=delay) ) self._attr_extra_state_attributes.update( { - ATTR_LINE: line["name"], - ATTR_ORIGIN: line["origin"], - ATTR_DIRECTION: line["direction"], - ATTR_TYPE: line["type"]["shortInfo"], - ATTR_ID: line["id"], + ATTR_LINE: line.name, + ATTR_ORIGIN: line.origin, + ATTR_DIRECTION: line.direction, + ATTR_TYPE: line.type.shortInfo, + ATTR_ID: line.id, ATTR_DELAY: delay, ATTR_CANCELLED: cancelled, ATTR_EXTRA: extra, @@ -166,21 +189,27 @@ class HVVDepartureSensor(SensorEntity): ) departures = [] - for departure in data["departures"]: - line = departure["line"] - delay = departure.get("delay", 0) - cancelled = departure.get("cancelled", False) - extra = departure.get("extra", False) + for departure in data.departures: + line = departure.line + delay = departure.delay if departure.delay is not None else 0 + cancelled = ( + departure.cancelled if departure.cancelled is not None else False + ) + extra = departure.extra if departure.extra is not None else False departures.append( { ATTR_DEPARTURE: departure_time - + timedelta(minutes=departure["timeOffset"]) + + timedelta( + minutes=departure.timeOffset + if departure.timeOffset is not None + else 0 + ) + timedelta(seconds=delay), - ATTR_LINE: line["name"], - ATTR_ORIGIN: line["origin"], - ATTR_DIRECTION: line["direction"], - ATTR_TYPE: line["type"]["shortInfo"], - ATTR_ID: line["id"], + ATTR_LINE: line.name, + ATTR_ORIGIN: line.origin, + ATTR_DIRECTION: line.direction, + ATTR_TYPE: line.type.shortInfo, + ATTR_ID: line.id, ATTR_DELAY: delay, ATTR_CANCELLED: cancelled, ATTR_EXTRA: extra, diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index d15df52bb71..03cb40fecce 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -46,6 +46,11 @@ async def async_setup_entry( water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator( hass, config_entry, hydrawise, main_coordinator ) + # async_track_zones is registered first on water_use_coordinator, + # so the water-use coordinator's data is in sync before + # callbacks below construct entities for newly added zones. + water_use_coordinator.async_track_zones() + main_coordinator.async_track_zones() await water_use_coordinator.async_config_entry_first_refresh() config_entry.runtime_data = HydrawiseUpdateCoordinators( main=main_coordinator, diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index d7344f56ab5..efec44e0822 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Hydrawise sprinkler binary sensors.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime @@ -23,6 +21,8 @@ from .const import SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index 3a61908ee2d..22363a3778e 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Hydrawise integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 308ffc23e36..70003f04686 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Hydrawise integration.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import dataclass, field @@ -84,6 +82,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): self.new_zones_callbacks: list[ Callable[[Iterable[tuple[Zone, Controller]]], None] ] = [] + + @callback + def async_track_zones(self) -> None: + """Begin tracking zone and controller add/remove on updates.""" self.async_add_listener(self._add_remove_zones) async def _async_update_data(self) -> HydrawiseData: @@ -200,6 +202,23 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): self.api = api self._main_coordinator = main_coordinator + @callback + def async_track_zones(self) -> None: + """Begin tracking zone and controller add/remove on updates.""" + self._main_coordinator.async_add_listener(self._sync_data_from_main) + + @callback + def _sync_data_from_main(self) -> None: + """Sync data references from the main coordinator after it updates.""" + if self.data is None or self._main_coordinator.data is None: + return # type: ignore[unreachable] + main_data = self._main_coordinator.data + self.data.user = main_data.user + self.data.controllers = main_data.controllers + self.data.zones = main_data.zones + self.data.zone_id_to_controller = main_data.zone_id_to_controller + self.data.sensors = main_data.sensors + async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" daily_water_summary: dict[int, ControllerWaterUseSummary] = {} diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 465da27a778..9e6f74b96d2 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,7 +1,5 @@ """Base classes for Hydrawise entities.""" -from __future__ import annotations - from pydrawise.schema import Controller, Sensor, Zone from homeassistant.core import callback diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 069ca3ef500..be00fad4854 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2026.3.0"] + "requirements": ["pydrawise==2026.4.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 19fcd0295a2..802eb9b3567 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,7 +1,5 @@ """Support for Hydrawise sprinkler sensors.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import timedelta @@ -22,6 +20,8 @@ from homeassistant.util import dt as dt_util from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class HydrawiseSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 238e249e1f6..8c6f1006fdf 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,7 +1,5 @@ """Support for Hydrawise cloud switches.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta @@ -22,6 +20,8 @@ from .const import DEFAULT_WATERING_TIME from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 56dd56e7d21..57830f9dc9d 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -1,7 +1,5 @@ """Support for Hydrawise sprinkler valves.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any @@ -19,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( ValveEntityDescription( key="zone", diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 83385b5ff19..957e33ab484 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -1,7 +1,5 @@ """The Hyperion integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from contextlib import suppress @@ -258,7 +256,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> hyperion_client.set_callbacks( { - f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": async_instances_to_clients, + f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": ( + async_instances_to_clients + ), } ) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index a839263dd65..5ac7936bf15 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -1,7 +1,5 @@ """Camera platform for Hyperion.""" -from __future__ import annotations - import asyncio import base64 import binascii @@ -156,7 +154,8 @@ class HyperionCamera(Camera): """Update Hyperion components.""" if not img: return - # Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions + # Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to + # KEY_RESULT for older server versions img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE) if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL): return diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 1ef53ad2951..31792445f52 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -1,7 +1,5 @@ """Hyperion config flow.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from contextlib import suppress @@ -46,11 +44,10 @@ from .const import ( _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -# +------------------+ +------------------+ +--------------------+ +--------------------+ -# |Step: SSDP | |Step: user | |Step: import | |Step: reauth | -# | | | | | | | | -# |Input: | |Input: | |Input: | |Input: | -# +------------------+ +------------------+ +--------------------+ +--------------------+ +# +-----------------+ +-----------------+ +-------------------+ +------------------+ +# |Step: SSDP | |Step: user | |Step: import | |Step: reauth | +# |Input: | |Input: host/port | |Input: import data | |Input: entry_data | +# +-----------------+ +-----------------+ +-------------------+ +------------------+ # v v v v # +-------------------+-----------------------+--------------------+ # Auth not | Auth | @@ -72,9 +69,9 @@ _LOGGER.setLevel(logging.DEBUG) # | +------------------+ # | | # | v -# | +---------------------------+ +--------------------------------+ -# | |Step: create_token_external|-->|Step: create_token_external_fail| -# | +---------------------------+ +--------------------------------+ +# | +------------------------+ +-----------------------------+ +# | |Step: create_token_ext |->|Step: create_token_ext_fail | +# | +------------------------+ +-----------------------------+ # | | # | v # | +-----------------------------------+ @@ -189,7 +186,8 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): # }, # 'ssdp_usn': 'uuid:f9aab089-f85a-55cf-b7c1-222a72faebe9', # 'ssdp_ext': '', - # 'ssdp_server': 'Raspbian GNU/Linux 10 (buster)/10 UPnP/1.0 Hyperion/2.0.0-alpha.8'} + # 'ssdp_server': + # 'Raspbian GNU/Linux 10 (buster)/10 UPnP/1.0 Hyperion/2.0.0-alpha.8'} # SSDP requires user confirmation. self._require_confirm = True @@ -267,7 +265,8 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): auth_resp: dict[str, Any] | None = None async with self._create_client(raw_connection=True) as hyperion_client: if hyperion_client: - # The Hyperion-py client has a default timeout of 3 minutes on this request. + # The Hyperion-py client has a default timeout of + # 3 minutes on this request. auth_resp = await hyperion_client.async_request_token( comment=DEFAULT_ORIGIN, id=auth_id ) diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 4cf0ed0f5e2..511ee1875ca 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -1,7 +1,5 @@ """Support for Hyperion-NG remotes.""" -from __future__ import annotations - from collections.abc import Callable, Mapping, Sequence import functools import logging @@ -205,7 +203,11 @@ class HyperionLight(LightEntity): @property def is_on(self) -> bool: - """Return true if light is on. Light is considered on when there is a source at the configured HA priority.""" + """Return true if light is on. + + Light is considered on when there is a source at the + configured HA priority. + """ return self._get_priority_entry_that_dictates_state() is not None async def async_turn_on(self, **kwargs: Any) -> None: @@ -230,8 +232,8 @@ class HyperionLight(LightEntity): and not await self._client.async_send_set_adjustment( **{ const.KEY_ADJUSTMENT: { - const.KEY_BRIGHTNESS: int( - round((float(brightness) * 100) / 255) + const.KEY_BRIGHTNESS: round( + (float(brightness) * 100) / 255 ), const.KEY_ID: item[const.KEY_ID], } @@ -297,7 +299,7 @@ class HyperionLight(LightEntity): if brightness_pct < 0 or brightness_pct > 100: return self._set_internal_state( - brightness=int(round((brightness_pct * 255) / float(100))) + brightness=round((brightness_pct * 255) / float(100)) ) self.async_write_ha_state() diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py index bec17cfbd2f..5009e62f832 100644 --- a/homeassistant/components/hyperion/sensor.py +++ b/homeassistant/components/hyperion/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Hyperion.""" -from __future__ import annotations - import functools from typing import Any diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index b1288936636..8759a9100ca 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -1,7 +1,5 @@ """Switch platform for Hyperion.""" -from __future__ import annotations - import functools from typing import Any @@ -70,7 +68,8 @@ def _component_to_unique_id(server_id: str, component: str, instance_num: int) - server_id, instance_num, slugify( - f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE} {KEY_COMPONENTID_TO_NAME[component]}" + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE}" + f" {KEY_COMPONENTID_TO_NAME[component]}" ), ) diff --git a/homeassistant/components/hypontech/__init__.py b/homeassistant/components/hypontech/__init__.py index ba0c0e5d459..6c07aa20e0b 100644 --- a/homeassistant/components/hypontech/__init__.py +++ b/homeassistant/components/hypontech/__init__.py @@ -1,7 +1,5 @@ """The Hypontech Cloud integration.""" -from __future__ import annotations - from hyponcloud import AuthenticationError, HyponCloud, RequestError from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform diff --git a/homeassistant/components/hypontech/config_flow.py b/homeassistant/components/hypontech/config_flow.py index a0f233b0039..90eb9e7f4e6 100644 --- a/homeassistant/components/hypontech/config_flow.py +++ b/homeassistant/components/hypontech/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Hypontech Cloud integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py index b3cae5d6e5d..2949d8ea894 100644 --- a/homeassistant/components/hypontech/coordinator.py +++ b/homeassistant/components/hypontech/coordinator.py @@ -1,7 +1,5 @@ """The coordinator for Hypontech Cloud integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py index a8abb23cf09..b7109be6226 100644 --- a/homeassistant/components/hypontech/entity.py +++ b/homeassistant/components/hypontech/entity.py @@ -1,7 +1,5 @@ """Base entity for the Hypontech Cloud integration.""" -from __future__ import annotations - from hyponcloud import PlantData from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/hypontech/sensor.py b/homeassistant/components/hypontech/sensor.py index 4552f445543..54ba36ceee2 100644 --- a/homeassistant/components/hypontech/sensor.py +++ b/homeassistant/components/hypontech/sensor.py @@ -1,7 +1,5 @@ """The read-only sensors for Hypontech integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 1604b37b967..b61cf0d816d 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -1,7 +1,5 @@ """iAlarm integration.""" -from __future__ import annotations - import asyncio from pyialarm import IAlarm diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index b2de9b3fefc..3ca6b4f5494 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -1,7 +1,5 @@ """Interfaces with iAlarm control panels.""" -from __future__ import annotations - from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, diff --git a/homeassistant/components/ialarm/coordinator.py b/homeassistant/components/ialarm/coordinator.py index 546e0b6b714..d14eb00d80b 100644 --- a/homeassistant/components/ialarm/coordinator.py +++ b/homeassistant/components/ialarm/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the iAlarm integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/iammeter/const.py b/homeassistant/components/iammeter/const.py index 0336007ef3e..ef0d541cbd6 100644 --- a/homeassistant/components/iammeter/const.py +++ b/homeassistant/components/iammeter/const.py @@ -1,7 +1,5 @@ """Constants for the Iammeter integration.""" -from __future__ import annotations - DOMAIN = "iammeter" # Default config for iammeter. diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 047281bdb27..7d72f9e9763 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -1,7 +1,5 @@ """Support for iammeter via local API.""" -from __future__ import annotations - from asyncio import timeout from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9a745a61f1f..d841e22b061 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -1,10 +1,7 @@ """Component to embed Aqualink devices.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from datetime import datetime from functools import wraps import logging from typing import Any, Concatenate @@ -18,20 +15,27 @@ from iaqualink.device import ( AqualinkSwitch, AqualinkThermostat, ) -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 -from .const import DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity +from .utils import error_detail _LOGGER = logging.getLogger(__name__) @@ -54,6 +58,7 @@ class AqualinkRuntimeData: """Runtime data for Aqualink.""" client: AqualinkClient + coordinators: dict[str, AqualinkDataUpdateCoordinator] # These will contain the initialized devices binary_sensors: list[AqualinkBinarySensor] lights: list[AqualinkLight] @@ -74,40 +79,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> ) try: await aqualink.login() - except AqualinkServiceException as login_exception: - _LOGGER.error("Failed to login: %s", login_exception) + except AqualinkServiceUnauthorizedException as auth_exception: await aqualink.close() - return False - except (TimeoutError, httpx.HTTPError) as aio_exception: + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting login: {aio_exception}" + f"Error while attempting login: {error_detail(aio_exception)}" ) from aio_exception try: systems = await aqualink.get_systems() - except AqualinkServiceException as svc_exception: + except AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as svc_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting to retrieve systems list: {svc_exception}" + "Error while attempting to retrieve systems list: " + f"{error_detail(svc_exception)}" ) from svc_exception - systems = list(systems.values()) - if not systems: - _LOGGER.error("No systems detected or supported") + systems_list = list(systems.values()) + if not systems_list: await aqualink.close() - return False + raise ConfigEntryError("No systems detected or supported") runtime_data = AqualinkRuntimeData( - aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[] + aqualink, + coordinators={}, + binary_sensors=[], + lights=[], + sensors=[], + switches=[], + thermostats=[], ) - for system in systems: + for system in systems_list: + coordinator = AqualinkDataUpdateCoordinator(hass, entry, system) + runtime_data.coordinators[system.serial] = coordinator + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryAuthFailed: + await aqualink.close() + raise + try: devices = await system.get_devices() - except AqualinkServiceException as svc_exception: + except AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception + except ( + AqualinkServiceException, + TimeoutError, + httpx.HTTPError, + ) as svc_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting to retrieve devices list: {svc_exception}" + "Error while attempting to retrieve devices list: " + f"{error_detail(svc_exception)}" ) from svc_exception device_registry = dr.async_get(hass) @@ -151,32 +186,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def _async_systems_update(_: datetime) -> None: - """Refresh internal state for all systems.""" - for system in systems: - prev = system.online - - try: - await system.update() - except (AqualinkServiceException, httpx.HTTPError) as svc_exception: - if prev is not None: - _LOGGER.warning( - "Failed to refresh system %s state: %s", - system.serial, - svc_exception, - ) - await system.aqualink.close() - else: - cur = system.online - if cur and not prev: - _LOGGER.warning("System %s reconnected to iAqualink", system.serial) - - async_dispatcher_send(hass, DOMAIN) - - entry.async_on_unload( - async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) - ) - return True @@ -197,6 +206,6 @@ def refresh_system[_AqualinkEntityT: AqualinkEntity, **_P]( ) -> None: """Call decorated function and send update signal to all entities.""" await func(self, *args, **kwargs) - async_dispatcher_send(self.hass, DOMAIN) + self.coordinator.async_update_listeners() return wrapper diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 3c260c7ef03..4ae489b5e1e 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Aqualink temperature sensors.""" -from __future__ import annotations - from iaqualink.device import AqualinkBinarySensor from homeassistant.components.binary_sensor import ( @@ -12,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -24,11 +23,10 @@ async def async_setup_entry( ) -> None: """Set up discovered binary sensors.""" async_add_entities( - ( - HassAqualinkBinarySensor(dev) - for dev in config_entry.runtime_data.binary_sensors - ), - True, + HassAqualinkBinarySensor( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.binary_sensors ) @@ -37,10 +35,11 @@ class HassAqualinkBinarySensor( ): """Representation of a binary sensor.""" - def __init__(self, dev: AqualinkBinarySensor) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkBinarySensor + ) -> None: """Initialize AquaLink binary sensor.""" - super().__init__(dev) - self._attr_name = dev.label + super().__init__(coordinator, dev) if dev.label == "Freeze Protection": self._attr_device_class = BinarySensorDeviceClass.COLD diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 36aec12976a..a5b034653a5 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -1,7 +1,5 @@ """Support for Aqualink Thermostats.""" -from __future__ import annotations - import logging from typing import Any @@ -19,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -34,8 +33,10 @@ async def async_setup_entry( ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkThermostat(dev) for dev in config_entry.runtime_data.thermostats), - True, + HassAqualinkThermostat( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.thermostats ) @@ -49,10 +50,11 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): | ClimateEntityFeature.TURN_ON ) - def __init__(self, dev: AqualinkThermostat) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkThermostat + ) -> None: """Initialize AquaLink thermostat.""" - super().__init__(dev) - self._attr_name = dev.label.split(" ")[0] + super().__init__(coordinator, dev) self._attr_temperature_unit = ( UnitOfTemperature.FAHRENHEIT if dev.unit == "F" @@ -72,9 +74,13 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Turn the underlying heater switch on or off.""" if hvac_mode == HVACMode.HEAT: - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) elif hvac_mode == HVACMode.OFF: - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) @@ -96,7 +102,11 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): @refresh_system async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE]))) + await await_or_reraise( + self.hass, + self.coordinator.config_entry, + self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])), + ) @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index b828c25c945..8800b55491e 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -1,7 +1,6 @@ -"""Config flow to configure zone component.""" - -from __future__ import annotations +"""Config flow for iAquaLink.""" +from collections.abc import Mapping from typing import Any import httpx @@ -12,19 +11,50 @@ from iaqualink.exception import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 from .const import DOMAIN +CREDENTIALS_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): - """Aqualink config flow.""" + """iAquaLink config flow.""" VERSION = 1 + async def _async_test_credentials( + self, user_input: dict[str, Any] + ) -> dict[str, str]: + """Validate credentials against iAquaLink.""" + try: + async with AqualinkClient( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + httpx_client=get_async_client( + self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2 + ), + ): + pass + except AqualinkServiceUnauthorizedException: + return {"base": "invalid_auth"} + except AqualinkServiceException, TimeoutError, httpx.HTTPError: + return {"base": "cannot_connect"} + + return {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -32,32 +62,57 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - try: - async with AqualinkClient( - username, - password, - httpx_client=get_async_client( - self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2 - ), - ): - pass - except AqualinkServiceUnauthorizedException: - errors["base"] = "invalid_auth" - except AqualinkServiceException, httpx.HTTPError: - errors["base"] = "cannot_connect" - else: - return self.async_create_entry(title=username, data=user_input) + errors = await self._async_test_credentials(user_input) + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), + data_schema=CREDENTIALS_DATA_SCHEMA, errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle flow triggered by an authentication failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle confirmation of reauthentication.""" + errors = {} + + config_entry = ( + self._get_reconfigure_entry() + if self.source == SOURCE_RECONFIGURE + else self._get_reauth_entry() + ) + if user_input is not None: + errors = await self._async_test_credentials(user_input) + if not errors: + return self.async_update_reload_and_abort( + config_entry, + title=user_input[CONF_USERNAME], + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id=( + "reconfigure" if self.source == SOURCE_RECONFIGURE else "reauth_confirm" + ), + data_schema=CREDENTIALS_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_reauth_confirm(user_input) diff --git a/homeassistant/components/iaqualink/const.py b/homeassistant/components/iaqualink/const.py index 1db4b5a6f16..893c02d483e 100644 --- a/homeassistant/components/iaqualink/const.py +++ b/homeassistant/components/iaqualink/const.py @@ -3,4 +3,9 @@ from datetime import timedelta DOMAIN = "iaqualink" -UPDATE_INTERVAL = timedelta(seconds=15) + +UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = { + "iaqua": timedelta(seconds=15), + "exo": timedelta(seconds=60), +} +UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30) diff --git a/homeassistant/components/iaqualink/coordinator.py b/homeassistant/components/iaqualink/coordinator.py new file mode 100644 index 00000000000..b43eb147dd4 --- /dev/null +++ b/homeassistant/components/iaqualink/coordinator.py @@ -0,0 +1,61 @@ +"""Data update coordinator for iaqualink.""" + +import logging +from typing import Any + +import httpx +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceThrottledException, + AqualinkServiceUnauthorizedException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT +from .utils import error_detail + +_LOGGER = logging.getLogger(__name__) + + +class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Data coordinator for Aqualink systems.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, system: Any + ) -> None: + """Initialize the coordinator.""" + update_interval = UPDATE_INTERVAL_BY_SYSTEM_TYPE.get( + system.NAME, UPDATE_INTERVAL_DEFAULT + ) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{system.serial}", + update_interval=update_interval, + ) + self.system = system + + async def _async_update_data(self) -> None: + """Refresh internal state for a system.""" + try: + await self.system.update() + except AqualinkServiceUnauthorizedException as err: + raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err + except AqualinkServiceThrottledException: + _LOGGER.warning( + "Rate limited by iAquaLink system %s, will retry later", + self.system.serial, + ) + return + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as err: + raise UpdateFailed( + "Unable to update iAquaLink system " + f"{self.system.serial}: {error_detail(err)}" + ) from err + if self.system.online is not True: + raise UpdateFailed(f"iAquaLink system {self.system.serial} is offline") diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index c0f44946b77..0838d6ddecd 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -1,30 +1,33 @@ """Component to embed Aqualink devices.""" -from __future__ import annotations - from iaqualink.device import AqualinkDevice from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import AqualinkDataUpdateCoordinator -class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity): +class AqualinkEntity[AqualinkDeviceT: AqualinkDevice]( + CoordinatorEntity[AqualinkDataUpdateCoordinator] +): """Abstract class for all Aqualink platforms. - Entity state is updated via the interval timer within the integration. - Any entity state change via the iaqualink library triggers an internal - state refresh which is then propagated to all the entities in the system - via the refresh_system decorator above to the _update_callback in this - class. + Entity availability and periodic refreshes are driven by the per-system + DataUpdateCoordinator. State changes initiated through the iaqualink + library are propagated back to Home Assistant through the coordinator-aware + entity update flow. """ - _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None - def __init__(self, dev: AqualinkDeviceT) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkDeviceT + ) -> None: """Initialize the entity.""" + super().__init__(coordinator) self.dev = dev self._attr_unique_id = f"{dev.system.serial}_{dev.name}" self._attr_device_info = DeviceInfo( @@ -35,18 +38,7 @@ class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity): name=dev.label, ) - async def async_added_to_hass(self) -> None: - """Set up a listener when this entity is added to HA.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) - ) - @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" return self.dev.system.online in [False, None] - - @property - def available(self) -> bool: - """Return whether the device is available or not.""" - return self.dev.system.online is True diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 55b14065cef..f01f0a41ccf 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -1,7 +1,5 @@ """Support for Aqualink pool lights.""" -from __future__ import annotations - from typing import Any from iaqualink.device import AqualinkLight @@ -17,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -30,18 +29,21 @@ async def async_setup_entry( ) -> None: """Set up discovered lights.""" async_add_entities( - (HassAqualinkLight(dev) for dev in config_entry.runtime_data.lights), - True, + HassAqualinkLight( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.lights ) class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """Representation of a light.""" - def __init__(self, dev: AqualinkLight) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkLight + ) -> None: """Initialize AquaLink light.""" - super().__init__(dev) - self._attr_name = dev.label + super().__init__(coordinator, dev) if dev.supports_effect: self._attr_effect_list = list(dev.supported_effects) self._attr_supported_features = LightEntityFeature.EFFECT @@ -65,18 +67,28 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """ # For now I'm assuming lights support either effects or brightness. if effect_name := kwargs.get(ATTR_EFFECT): - await await_or_reraise(self.dev.set_effect_by_name(effect_name)) + await await_or_reraise( + self.hass, + self.coordinator.config_entry, + self.dev.set_effect_by_name(effect_name), + ) elif brightness := kwargs.get(ATTR_BRIGHTNESS): # Aqualink supports percentages in 25% increments. - pct = int(round(brightness * 4.0 / 255)) * 25 - await await_or_reraise(self.dev.set_brightness(pct)) + pct = round(brightness * 4.0 / 255) * 25 + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.set_brightness(pct) + ) else: - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) @refresh_system async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) @property def brightness(self) -> int: @@ -84,7 +96,7 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): The scale needs converting between 0-100 and 0-255. """ - return self.dev.brightness * 255 / 100 + return round(self.dev.brightness * 255 / 100) @property def effect(self) -> str: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index fea0531264a..ec442defd12 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -1,12 +1,14 @@ { "domain": "iaqualink", - "name": "Jandy iAqualink", + "name": "Jandy iAquaLink", "codeowners": ["@flz"], "config_flow": true, + "dhcp": [{ "hostname": "iaqualink-*" }], "documentation": "https://www.home-assistant.io/integrations/iaqualink", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.6.0", "h2==4.3.0"], + "quality_scale": "silver", + "requirements": ["iaqualink==0.7.0", "h2==4.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/iaqualink/quality_scale.yaml b/homeassistant/components/iaqualink/quality_scale.yaml new file mode 100644 index 00000000000..26af8ae41ae --- /dev/null +++ b/homeassistant/components/iaqualink/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register integration actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register integration actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration uses a cloud account. + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index baeca799bc3..76fad41befa 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -1,7 +1,5 @@ """Support for Aqualink temperature sensors.""" -from __future__ import annotations - from iaqualink.device import AqualinkSensor from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -10,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -22,18 +21,21 @@ async def async_setup_entry( ) -> None: """Set up discovered sensors.""" async_add_entities( - (HassAqualinkSensor(dev) for dev in config_entry.runtime_data.sensors), - True, + HassAqualinkSensor( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.sensors ) class HassAqualinkSensor(AqualinkEntity[AqualinkSensor], SensorEntity): """Representation of a sensor.""" - def __init__(self, dev: AqualinkSensor) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkSensor + ) -> None: """Initialize AquaLink sensor.""" - super().__init__(dev) - self._attr_name = dev.label + super().__init__(coordinator, dev) if not dev.name.endswith("_temp"): return self._attr_device_class = SensorDeviceClass.TEMPERATURE diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json index 5b00a9424de..23857c34817 100644 --- a/homeassistant/components/iaqualink/strings.json +++ b/homeassistant/components/iaqualink/strings.json @@ -1,17 +1,51 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::iaqualink::config::step::user::data_description::password%]", + "username": "[%key:component::iaqualink::config::step::user::data_description::username%]" + }, + "description": "[%key:component::iaqualink::config::step::user::description%]", + "title": "Reauthenticate iAquaLink" + }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::iaqualink::config::step::user::data_description::password%]", + "username": "[%key:component::iaqualink::config::step::user::data_description::username%]" + }, + "description": "[%key:component::iaqualink::config::step::user::description%]", + "title": "Reconnect iAquaLink" + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, - "description": "Please enter the username and password for your iAqualink account.", - "title": "Connect to iAqualink" + "data_description": { + "password": "The password associated with your account.", + "username": "The email address used to sign in to your account using the iAquaLink app or website." + }, + "description": "Please enter the username and password for your iAquaLink account.", + "title": "Connect to iAquaLink" } } } diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 851554a1972..c82b15b6bba 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -1,7 +1,5 @@ """Support for Aqualink pool feature switches.""" -from __future__ import annotations - from typing import Any from iaqualink.device import AqualinkSwitch @@ -11,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -24,18 +23,22 @@ async def async_setup_entry( ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkSwitch(dev) for dev in config_entry.runtime_data.switches), - True, + HassAqualinkSwitch( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.switches ) class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): """Representation of a switch.""" - def __init__(self, dev: AqualinkSwitch) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkSwitch + ) -> None: """Initialize AquaLink switch.""" - super().__init__(dev) - name = self._attr_name = dev.label + super().__init__(coordinator, dev) + name = dev.label if name == "Cleaner": self._attr_icon = "mdi:robot-vacuum" elif name == "Waterfall" or name.endswith("Dscnt"): @@ -53,9 +56,13 @@ class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): @refresh_system async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) @refresh_system async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py index 62d2d4d2e93..c34867205d4 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -1,18 +1,44 @@ """Utility functions for Aqualink devices.""" -from __future__ import annotations - from collections.abc import Awaitable import httpx -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -async def await_or_reraise(awaitable: Awaitable) -> None: +def error_detail(err: Exception) -> str: + """Return a non-empty error detail for iaqualink exceptions.""" + if detail := str(err): + return detail + return type(err).__name__ + + +async def await_or_reraise( + hass: HomeAssistant, + config_entry: ConfigEntry | None, + awaitable: Awaitable, +) -> None: """Execute API call while catching service exceptions.""" try: await awaitable + except AqualinkServiceUnauthorizedException as auth_exception: + if config_entry is not None: + config_entry.async_start_reauth(hass) + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception + except TimeoutError as timeout_exception: + raise HomeAssistantError( + f"Aqualink error: {error_detail(timeout_exception)}" + ) from timeout_exception except (AqualinkServiceException, httpx.HTTPError) as svc_exception: - raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception + raise HomeAssistantError( + f"Aqualink error: {error_detail(svc_exception)}" + ) from svc_exception diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py index 14d5bbca17f..8440758a080 100644 --- a/homeassistant/components/ibeacon/__init__.py +++ b/homeassistant/components/ibeacon/__init__.py @@ -1,7 +1,5 @@ """The iBeacon tracker integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index 5850a623ad8..45b36a826cd 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -1,7 +1,5 @@ """Config flow for iBeacon Tracker integration.""" -from __future__ import annotations - from typing import Any from uuid import UUID diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 041448101fa..952290be846 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -20,7 +20,9 @@ ATTR_MAJOR = "major" ATTR_MINOR = "minor" ATTR_SOURCE = "source" -UNAVAILABLE_TIMEOUT = 180 # Number of seconds we wait for a beacon to be seen before marking it unavailable +# Number of seconds we wait for a beacon to be seen +# before marking it unavailable +UNAVAILABLE_TIMEOUT = 180 # How often to update RSSI if it has changed # and look for unavailable groups that use a random MAC address diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 4f232220440..6bb04a2c4b4 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -1,7 +1,5 @@ """Tracking for iBeacon devices.""" -from __future__ import annotations - from datetime import datetime import logging import time @@ -69,7 +67,11 @@ def async_name( service_info.name, service_info.name.replace("-", ":"), ): - base_name = f"{ibeacon_advertisement.uuid}_{ibeacon_advertisement.major}_{ibeacon_advertisement.minor}" + base_name = ( + f"{ibeacon_advertisement.uuid}" + f"_{ibeacon_advertisement.major}" + f"_{ibeacon_advertisement.minor}" + ) else: base_name = service_info.name if unique_address: @@ -182,7 +184,10 @@ class IBeaconCoordinator: @callback def _async_ignore_uuid(self, uuid: str) -> None: - """Ignore an UUID that does not follow the spec and any entities created by it.""" + """Ignore a UUID that doesn't follow the spec. + + Also removes any entities created by it. + """ self._ignore_uuids.add(uuid) major_minor_by_uuid = self._major_minor_by_uuid.pop(uuid) unique_ids_to_purge = set() @@ -201,7 +206,10 @@ class IBeaconCoordinator: @callback def _async_ignore_address(self, address: str) -> None: - """Ignore an address that does not follow the spec and any entities created by it.""" + """Ignore an address that doesn't follow the spec. + + Also removes any entities created by it. + """ self._ignore_addresses.add(address) self._async_cancel_unavailable_tracker(address) entry_data = self._entry.data @@ -228,7 +236,10 @@ class IBeaconCoordinator: service_info: bluetooth.BluetoothServiceInfoBleak, ibeacon_advertisement: iBeaconAdvertisement, ) -> None: - """Switch to random mac tracking method when a group is using rotating mac addresses.""" + """Switch to random mac tracking method. + + Used when a group is using rotating mac addresses. + """ self._group_ids_random_macs.add(group_id) self._async_purge_untrackable_entities(self._unique_ids_by_group_id[group_id]) self._unique_ids_by_group_id.pop(group_id) @@ -315,7 +326,8 @@ class IBeaconCoordinator: new = unique_id not in self._last_ibeacon_advertisement_by_unique_id uuid = str(ibeacon_advertisement.uuid) - # Reject creating new trackers if the name is not set (unless the uuid is allowlisted). + # Reject creating new trackers if the name is not set + # (unless the uuid is allowlisted). if ( new and uuid not in self._allow_nameless_uuids @@ -378,7 +390,10 @@ class IBeaconCoordinator: @callback def _async_check_unavailable_groups_with_random_macs(self) -> None: - """Check for random mac groups that have not been seen in a while and mark them as unavailable.""" + """Check for unseen random mac groups. + + Marks them as unavailable if not seen in a while. + """ now = MONOTONIC_TIME() gone_unavailable = [ group_id diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py index 0d2ee0137cc..648ccb1917d 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -1,12 +1,8 @@ """Support for tracking iBeacon devices.""" -from __future__ import annotations - from ibeacon_ble import iBeaconAdvertisement -from homeassistant.components.device_tracker import SourceType -from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.components.device_tracker import BaseScannerEntity, SourceType from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -48,10 +44,11 @@ async def async_setup_entry( ) -class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity): +class IBeaconTrackerEntity(IBeaconEntity, BaseScannerEntity): """An iBeacon Tracker entity.""" _attr_name = None + _attr_source_type: SourceType = SourceType.BLUETOOTH_LE _attr_translation_key = "device_tracker" def __init__( @@ -69,14 +66,9 @@ class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity): self._active = True @property - def state(self) -> str: - """Return the state of the device.""" - return STATE_HOME if self._active else STATE_NOT_HOME - - @property - def source_type(self) -> SourceType: - """Return tracker source type.""" - return SourceType.BLUETOOTH_LE + def is_connected(self) -> bool: + """Return true if the device is connected.""" + return self._active @callback def _async_seen( diff --git a/homeassistant/components/ibeacon/entity.py b/homeassistant/components/ibeacon/entity.py index d4f969ff94a..4a2b276555c 100644 --- a/homeassistant/components/ibeacon/entity.py +++ b/homeassistant/components/ibeacon/entity.py @@ -1,7 +1,5 @@ """Support for iBeacon device sensors.""" -from __future__ import annotations - from abc import abstractmethod from ibeacon_ble import iBeaconAdvertisement diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index 7e1fd371128..4df586d7477 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -1,7 +1,5 @@ """Support for iBeacon device sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 16baa9fcb7d..696f0aa20a3 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -1,7 +1,5 @@ """The iCloud component.""" -from __future__ import annotations - from typing import Any from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -61,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo await hass.async_add_executor_job(account.setup) entry.runtime_data = account + entry.async_on_unload(account.cancel_fetch) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index d6b60d6da98..25d9a7e52ab 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -1,7 +1,5 @@ """iCloud account.""" -from __future__ import annotations - from datetime import timedelta import logging import operator @@ -94,6 +92,7 @@ class IcloudAccount: self._retried_fetch = False self._config_entry = config_entry + self._unsub_fetch: CALLBACK_TYPE | None = None self.listeners: list[CALLBACK_TYPE] = [] def setup(self) -> None: @@ -295,9 +294,16 @@ class IcloudAccount: self._max_interval, ) + def cancel_fetch(self) -> None: + """Cancel the scheduled fetch timer.""" + if self._unsub_fetch is not None: + self._unsub_fetch() + self._unsub_fetch = None + def _schedule_next_fetch(self) -> None: + self.cancel_fetch() if not self._config_entry.pref_disable_polling: - track_point_in_utc_time( + self._unsub_fetch = track_point_in_utc_time( self.hass, self.keep_alive, utcnow() + timedelta(minutes=self._fetch_interval), diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index d45863547a7..36789d3cdb9 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the iCloud integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import os diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 2a4f6d81dc5..6295fa940ef 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -1,7 +1,5 @@ """Support for tracking for iCloud devices.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.device_tracker import TrackerEntity diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 11690a0da59..f3ae82f7622 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -1,7 +1,5 @@ """Support for iCloud sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py index 44a2e5d52f7..c3de04fba42 100644 --- a/homeassistant/components/icloud/services.py +++ b/homeassistant/components/icloud/services.py @@ -1,7 +1,5 @@ """The iCloud component.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 158812cf015..a5717ee6a55 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -1,7 +1,5 @@ """The IKEA Idasen Desk integration.""" -from __future__ import annotations - import logging from bleak.exc import BleakError @@ -40,7 +38,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) - service_info: bluetooth.BluetoothServiceInfoBleak, change: bluetooth.BluetoothChange, ) -> None: - """Update from a Bluetooth callback to ensure that a new BLEDevice is fetched.""" + """Update from a Bluetooth callback. + + Ensures that a new BLEDevice is fetched. + """ _LOGGER.debug("Bluetooth callback triggered") hass.async_create_task(coordinator.async_connect_if_expected()) diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index aa832fdfe48..51e9736fbeb 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Idasen Desk integration.""" -from __future__ import annotations - import logging from typing import Any @@ -11,6 +9,7 @@ from idasen_ha import Desk from idasen_ha.errors import AuthFailedError import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -87,6 +86,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( @@ -104,7 +104,9 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_ADDRESS): vol.In( { - service_info.address: f"{service_info.name} ({service_info.address})" + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) for service_info in self._discovered_devices.values() } ), diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index ee15a90c667..f7012346aa8 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the IKEA Idasen Desk integration.""" -from __future__ import annotations - import logging from idasen_ha import Desk diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index b451f4d0156..adb42258f4c 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -1,7 +1,5 @@ """Idasen Desk integration cover platform.""" -from __future__ import annotations - from typing import Any from bleak.exc import BleakError diff --git a/homeassistant/components/idasen_desk/entity.py b/homeassistant/components/idasen_desk/entity.py index 46730ee13fe..6b0d1e6bc07 100644 --- a/homeassistant/components/idasen_desk/entity.py +++ b/homeassistant/components/idasen_desk/entity.py @@ -1,7 +1,5 @@ """Base entity for Idasen Desk.""" -from __future__ import annotations - from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 1acaf083485..3376d9fabaa 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["idasen-ha==2.6.5"] + "requirements": ["idasen-ha==2.7.0"] } diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index 22680b4fa7f..14ccaf9727c 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -1,7 +1,5 @@ """Representation of Idasen Desk sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/idrive_e2/__init__.py b/homeassistant/components/idrive_e2/__init__.py index caae39e1668..0b0e01245d1 100644 --- a/homeassistant/components/idrive_e2/__init__.py +++ b/homeassistant/components/idrive_e2/__init__.py @@ -1,7 +1,5 @@ """The IDrive e2 integration.""" -from __future__ import annotations - import logging from typing import Any, cast diff --git a/homeassistant/components/idrive_e2/backup.py b/homeassistant/components/idrive_e2/backup.py index 819d10d2099..5aca3c8c477 100644 --- a/homeassistant/components/idrive_e2/backup.py +++ b/homeassistant/components/idrive_e2/backup.py @@ -237,8 +237,10 @@ class IDriveE2BackupAgent(BackupAgent): finally: view.release() - # Compact the buffer if the consumed offset has grown large enough. This - # avoids unnecessary memory copies when compacting after every part upload. + # Compact the buffer if the consumed offset has + # grown large enough. This avoids unnecessary + # memory copies when compacting after every + # part upload. if offset and offset >= MULTIPART_MIN_PART_SIZE_BYTES: buffer = bytearray(buffer[offset:]) offset = 0 diff --git a/homeassistant/components/idrive_e2/config_flow.py b/homeassistant/components/idrive_e2/config_flow.py index 9395383a702..fb99c17d63e 100644 --- a/homeassistant/components/idrive_e2/config_flow.py +++ b/homeassistant/components/idrive_e2/config_flow.py @@ -1,7 +1,5 @@ """IDrive e2 config flow.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -68,7 +66,10 @@ class IDriveE2ConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """First step: prompt for access_key and secret_access_key, then fetch region endpoint and buckets.""" + """Prompt for access_key and secret_access_key. + + Then fetch region endpoint and buckets. + """ errors: dict[str, str] = {} if user_input is not None: diff --git a/homeassistant/components/idrive_e2/quality_scale.yaml b/homeassistant/components/idrive_e2/quality_scale.yaml index 11093f4430f..9bcf1f82f1e 100644 --- a/homeassistant/components/idrive_e2/quality_scale.yaml +++ b/homeassistant/components/idrive_e2/quality_scale.yaml @@ -94,7 +94,7 @@ rules: entity-translations: status: exempt comment: This integration does not have entities. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: This integration does not use icons. diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py index 68969f1eced..ef1efc93349 100644 --- a/homeassistant/components/idteck_prox/__init__.py +++ b/homeassistant/components/idteck_prox/__init__.py @@ -1,7 +1,5 @@ """Component for interfacing RFK101 proximity card readers.""" -from __future__ import annotations - import logging from rfk101py.rfk101py import rfk101py diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index c5682e5a8d9..607ce219559 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -1,7 +1,5 @@ """Support to trigger Maker IFTTT recipes.""" -from __future__ import annotations - from http import HTTPStatus import json import logging @@ -84,6 +82,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: res = pyfttt.send_event(key, event, value1, value2, value3) if res.status_code != HTTPStatus.OK: _LOGGER.error("IFTTT reported error sending event to %s", target) + # pylint: disable-next=home-assistant-action-swallowed-exception except requests.exceptions.RequestException: _LOGGER.exception("Error communicating with IFTTT") diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index f36fe8e672b..7ff396d8178 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for alarm control panels that can be controlled through IFTTT.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index 3fb09f0eac6..26e3a460865 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -1,7 +1,5 @@ """Support for lights under the iGlo brand.""" -from __future__ import annotations - from typing import Any from iglo import Lamp diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py index a3907fcbcf3..f333d792883 100644 --- a/homeassistant/components/igloohome/__init__.py +++ b/homeassistant/components/igloohome/__init__.py @@ -1,7 +1,5 @@ """The igloohome integration.""" -from __future__ import annotations - from dataclasses import dataclass from aiohttp import ClientError diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py index 89d072a128a..73de215389f 100644 --- a/homeassistant/components/igloohome/config_flow.py +++ b/homeassistant/components/igloohome/config_flow.py @@ -1,7 +1,5 @@ """Config flow for igloohome integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/igloohome/utils.py b/homeassistant/components/igloohome/utils.py index be17912b8b8..25a8d6c9d13 100644 --- a/homeassistant/components/igloohome/utils.py +++ b/homeassistant/components/igloohome/utils.py @@ -6,7 +6,10 @@ from igloohome_api import DEVICE_TYPE_BRIDGE, GetDeviceInfoResponse def get_linked_bridge( device_id: str, devices: list[GetDeviceInfoResponse] ) -> str | None: - """Return the ID of the bridge that is linked to the device. None if no bridge is linked.""" + """Return the ID of the bridge linked to the device. + + Returns None if no bridge is linked. + """ bridges = (bridge for bridge in devices if bridge.type == DEVICE_TYPE_BRIDGE) for bridge in bridges: if device_id in ( diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index e99f2b23ca0..a3f9b4032b2 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -1,7 +1,5 @@ """Support for IGN Sismologia (Earthquakes) Feeds.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 413d89ca027..84943e4b611 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -1,7 +1,5 @@ """Support for IHC binary sensors.""" -from __future__ import annotations - from ihcsdk.ihccontroller import IHCController from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index 8a07bd4fec4..ed82ff0684b 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -15,7 +15,6 @@ CONF_INFO = "info" CONF_INVERTING = "inverting" CONF_LIGHT = "light" CONF_NODE = "node" -CONF_NOTE = "note" CONF_OFF_ID = "off_id" CONF_ON_ID = "on_id" CONF_POSITION = "position" diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index b2138eb8aab..8a6a08f37e7 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -45,7 +45,8 @@ class IHCEntity(Entity): if "id" in product: product_id = product["id"] self.device_id = f"{controller_id}_{product_id}" - # this will name the device the same way as the IHC visual application: Product name + position + # Name the device like the IHC visual app: + # Product name + position self.device_name = product["name"] if self.ihc_position: self.device_name += f" ({self.ihc_position})" diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index 47f343304dc..f42ba1c0595 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -1,7 +1,5 @@ """Support for IHC lights.""" -from __future__ import annotations - from typing import Any from ihcsdk.ihccontroller import IHCController diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 68cc1b2c754..a26cab4ee4c 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["ihcsdk"], "quality_scale": "legacy", - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.12"] } diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index f17920145e7..fd2a31f557f 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( CONF_ID, CONF_NAME, + CONF_NOTE, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, @@ -25,7 +26,6 @@ from .const import ( CONF_INFO, CONF_INVERTING, CONF_LIGHT, - CONF_NOTE, CONF_OFF_ID, CONF_ON_ID, CONF_POSITION, diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index f3b722b2cdd..f74a14216ad 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -1,7 +1,5 @@ """Support for IHC sensors.""" -from __future__ import annotations - from ihcsdk.ihccontroller import IHCController from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index b509c2dd10f..f19d6141fc3 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -1,7 +1,5 @@ """Support for IHC switches.""" -from __future__ import annotations - from typing import Any from ihcsdk.ihccontroller import IHCController diff --git a/homeassistant/components/illuminance/__init__.py b/homeassistant/components/illuminance/__init__.py index e97ecc3d260..b29a54858e9 100644 --- a/homeassistant/components/illuminance/__init__.py +++ b/homeassistant/components/illuminance/__init__.py @@ -1,7 +1,5 @@ """Integration for illuminance triggers and conditions.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/illuminance/condition.py b/homeassistant/components/illuminance/condition.py index c074c333100..025051dfc65 100644 --- a/homeassistant/components/illuminance/condition.py +++ b/homeassistant/components/illuminance/condition.py @@ -1,7 +1,5 @@ """Provides conditions for illuminance.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, diff --git a/homeassistant/components/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index b23ac8007e0..92f43eecd40 100644 --- a/homeassistant/components/illuminance/conditions.yaml +++ b/homeassistant/components/illuminance/conditions.yaml @@ -8,11 +8,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: is_detected: *detected_condition_common @@ -25,6 +27,7 @@ is_value: device_class: illuminance fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/illuminance/strings.json b/homeassistant/components/illuminance/strings.json index e1c478fff9f..59bd195116f 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is detected" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is not detected" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::illuminance::common::condition_threshold_name%]" } @@ -37,21 +48,6 @@ "name": "Illuminance" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Illuminance", "triggers": { "changed": { @@ -68,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" } }, "name": "Light cleared" @@ -78,6 +77,9 @@ "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } @@ -89,6 +91,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" } }, "name": "Light detected" diff --git a/homeassistant/components/illuminance/trigger.py b/homeassistant/components/illuminance/trigger.py index 56fe4910809..b793bd0f2ea 100644 --- a/homeassistant/components/illuminance/trigger.py +++ b/homeassistant/components/illuminance/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for illuminance.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, diff --git a/homeassistant/components/illuminance/triggers.yaml b/homeassistant/components/illuminance/triggers.yaml index c2f77fd4292..ed33d6279fa 100644 --- a/homeassistant/components/illuminance/triggers.yaml +++ b/homeassistant/components/illuminance/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .illuminance_threshold_entity: &illuminance_threshold_entity - domain: input_number @@ -55,6 +56,7 @@ crossed_threshold: target: *trigger_numerical_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 5f7c497fdb4..91ef6aab79b 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -1,23 +1,22 @@ """The image integration.""" -from __future__ import annotations - import asyncio import collections +from collections.abc import Container, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta import logging import os from random import SystemRandom -from typing import Final, final +from typing import Final, final, override -from aiohttp import hdrs, web +from aiohttp import web import httpx from propcache.api import cached_property import voluptuous as vol -from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( @@ -209,7 +208,7 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None: """Initialize an image entity.""" self._client = get_async_client(hass, verify_ssl=verify_ssl) - self.access_tokens: collections.deque = collections.deque([], 2) + self.access_tokens: collections.deque = collections.deque(maxlen=2) self.async_update_token() @cached_property @@ -316,33 +315,28 @@ class ImageView(HomeAssistantView): """View to serve an image.""" name = "api:image:image" - requires_auth = False + use_query_token_for_auth = True url = "/api/image_proxy/{entity_id}" def __init__(self, component: EntityComponent[ImageEntity]) -> None: """Initialize an image view.""" self.component = component - async def _authenticate_request( - self, request: web.Request, entity_id: str - ) -> ImageEntity: - """Authenticate request and return image entity.""" + @callback + @override + def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: + """Return valid auth tokens, which can be used for query token authentication.""" + if (image_entity := self.component.get_entity(match_info["entity_id"])) is None: + return () + + return image_entity.access_tokens + + @callback + def _get_image_entity(self, entity_id: str) -> ImageEntity: + """Get image entity from request.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound - authenticated = ( - request[KEY_AUTHENTICATED] - or request.query.get("token") in image_entity.access_tokens - ) - - if not authenticated: - # Attempt with invalid bearer token, raise unauthorized - # so ban middleware can handle it. - if hdrs.AUTHORIZATION in request.headers: - raise web.HTTPUnauthorized - # Invalid sigAuth or image entity access token - raise web.HTTPForbidden - return image_entity async def head(self, request: web.Request, entity_id: str) -> web.Response: @@ -351,7 +345,7 @@ class ImageView(HomeAssistantView): This is sent by some DLNA renderers, like Samsung ones, prior to sending the GET request. """ - image_entity = await self._authenticate_request(request, entity_id) + image_entity = self._get_image_entity(entity_id) # Don't use `handle` as we don't care about the stream case, we only want # to verify that the image exists. @@ -367,7 +361,7 @@ class ImageView(HomeAssistantView): async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: """Start a GET request.""" - image_entity = await self._authenticate_request(request, entity_id) + image_entity = self._get_image_entity(entity_id) return await self.handle(request, image_entity) async def handle( @@ -481,7 +475,9 @@ async def async_handle_snapshot_service( # check if we allow to access to that file if not hass.config.is_allowed_path(snapshot_file): raise HomeAssistantError( - f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + f"Cannot write `{snapshot_file}`, no access to path;" + " `allowlist_external_dirs` may need to be adjusted" + " in `configuration.yaml`" ) async with asyncio.timeout(IMAGE_TIMEOUT): diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py index a646b0dd3d5..853455e6d9d 100644 --- a/homeassistant/components/image/const.py +++ b/homeassistant/components/image/const.py @@ -1,7 +1,5 @@ """Constants for the image integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Final from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py index 8d06ec3807f..89f52a2f71e 100644 --- a/homeassistant/components/image/media_source.py +++ b/homeassistant/components/image/media_source.py @@ -1,7 +1,5 @@ """Expose images as media sources.""" -from __future__ import annotations - from typing import cast from homeassistant.components.media_player import BrowseError, MediaClass diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 06b6bb7a57f..2f677e045b2 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with image processing services.""" -from __future__ import annotations - import asyncio from datetime import timedelta from enum import StrEnum diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index ff86d4441e4..808433e9de8 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -1,7 +1,5 @@ """The Image Upload integration.""" -from __future__ import annotations - import asyncio import logging import pathlib diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 394e1871d29..8379e224a0a 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==12.1.1"] + "requirements": ["Pillow==12.2.0"] } diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py index d1fc978c278..102cee088bb 100644 --- a/homeassistant/components/image_upload/media_source.py +++ b/homeassistant/components/image_upload/media_source.py @@ -1,7 +1,5 @@ """Expose image_upload as media sources.""" -from __future__ import annotations - import pathlib from propcache.api import cached_property @@ -27,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource: class ImageUploadMediaSource(MediaSource): """Provide images as media sources.""" - name: str = "Image Upload" + name: str = "Image upload" def __init__(self, hass: HomeAssistant) -> None: """Initialize ImageMediaSource.""" @@ -79,7 +77,7 @@ class ImageUploadMediaSource(MediaSource): identifier=None, media_class=MediaClass.APP, media_content_type="", - title="Image Upload", + title="Image upload", can_play=False, can_expand=True, children_media_class=MediaClass.IMAGE, diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index a60bc308410..51d6d49cba1 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -1,7 +1,5 @@ """The imap integration.""" -from __future__ import annotations - import asyncio from email.message import Message import logging diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index a7e51e29dab..c18dc8f7f0c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -1,7 +1,5 @@ """Config flow for imap integration.""" -from __future__ import annotations - from collections.abc import Mapping import ssl from typing import Any @@ -78,14 +76,12 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, # The default for new entries is to not include text and headers vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR, + vol.Optional( + CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT + ): CIPHER_SELECTOR, + vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, } ) -CONFIG_SCHEMA_ADVANCED = { - vol.Optional( - CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT - ): CIPHER_SELECTOR, - vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, -} OPTIONS_SCHEMA = vol.Schema( { @@ -95,18 +91,15 @@ OPTIONS_SCHEMA = vol.Schema( vol.Optional( CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS ): EVENT_MESSAGE_DATA_SELECTOR, + vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( + cv.positive_int, + vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), + ), + vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR, } ) -OPTIONS_SCHEMA_ADVANCED = { - vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, - vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( - cv.positive_int, - vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), - ), - vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR, -} - async def validate_input( hass: HomeAssistant, user_input: dict[str, Any] @@ -126,9 +119,11 @@ async def validate_input( except InvalidFolder: errors[CONF_FOLDER] = "invalid_folder" except ssl.SSLError: - # The aioimaplib library 1.0.1 does not raise an ssl.SSLError correctly, but is logged - # See https://github.com/bamthomas/aioimaplib/issues/91 - # This handler is added to be able to supply a better error message + # The aioimaplib library 1.0.1 does not raise an + # ssl.SSLError correctly, but is logged. + # See + # https://github.com/bamthomas/aioimaplib/issues/91 + # This handler supplies a better error message. errors["base"] = "ssl_error" except TimeoutError, AioImapException, ConnectionRefusedError: errors["base"] = "cannot_connect" @@ -153,8 +148,6 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" schema = CONFIG_SCHEMA - if self.show_advanced_options: - schema = schema.extend(CONFIG_SCHEMA_ADVANCED) if user_input is None: return self.async_show_form(step_id="user", data_schema=schema) @@ -252,8 +245,6 @@ class ImapOptionsFlow(OptionsFlow): return self.async_create_entry(data={}) schema = OPTIONS_SCHEMA - if self.show_advanced_options: - schema = schema.extend(OPTIONS_SCHEMA_ADVANCED) schema = self.add_suggested_values_to_schema(schema, entry_data) return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 157db4da174..5a9327580d9 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for imap integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from datetime import datetime, timedelta @@ -306,7 +304,8 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): data | {"text": message.text}, parse_result=True ) _LOGGER.debug( - "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", + "IMAP custom template (%s) for msguid" + " %s (%s) rendered to: %s, initial: %s", self.custom_event_template, last_message_uid, message_id, @@ -338,7 +337,8 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message with id %s (%s) processed, sender: %s, subject: %s, initial: %s", + "Message with id %s (%s) processed," + " sender: %s, subject: %s, initial: %s", last_message_uid, message_id, message.sender, @@ -356,7 +356,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): ) if result != "OK": raise UpdateFailed( - f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" + "Invalid response for search" + f" '{self.config_entry.data[CONF_SEARCH]}':" + f" {result} / {lines[0]}" ) # Check we do have returned items. # @@ -494,6 +496,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): async def _async_wait_push_loop(self) -> None: """Wait for data push from server.""" + idle: asyncio.Future | None = None while True: try: self.number_of_messages = await self._async_fetch_number_of_messages() @@ -527,8 +530,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): else: self.auth_errors = 0 self.async_set_updated_data(self.number_of_messages) + try: - idle: asyncio.Future = await self.imap_client.idle_start() + idle = await self.imap_client.idle_start() await self.imap_client.wait_server_push() self.imap_client.idle_done() async with asyncio.timeout(10): @@ -543,6 +547,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await self._cleanup() await asyncio.sleep(BACKOFF_TIME) + finally: + # Ensure no pending IDLE future survives + if idle is not None and not idle.done(): + idle.cancel() + _LOGGER.debug( + "Canceling IDLE wait for %s", + self.config_entry.data[CONF_SERVER], + ) + try: + await idle + except asyncio.CancelledError: + if ( + current_task := asyncio.current_task() + ) and current_task.cancelling(): + raise + except AioImapException: + pass + async def shutdown(self, *_: Any) -> None: """Close resources.""" if self._push_wait_task: diff --git a/homeassistant/components/imap/diagnostics.py b/homeassistant/components/imap/diagnostics.py index d402053520a..034cdcb210f 100644 --- a/homeassistant/components/imap/diagnostics.py +++ b/homeassistant/components/imap/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for IMAP.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/imap/quality_scale.yaml b/homeassistant/components/imap/quality_scale.yaml index 1c75b527882..ff7dd8104a3 100644 --- a/homeassistant/components/imap/quality_scale.yaml +++ b/homeassistant/components/imap/quality_scale.yaml @@ -62,7 +62,7 @@ rules: comment: > The device class is a service. When removed, entities are removed as well. diagnostics: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 01009e3d17b..5f07567d1e3 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,7 +1,5 @@ """IMAP sensor support.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, diff --git a/homeassistant/components/imeon_inverter/__init__.py b/homeassistant/components/imeon_inverter/__init__.py index 0676731f375..8b079276c2e 100644 --- a/homeassistant/components/imeon_inverter/__init__.py +++ b/homeassistant/components/imeon_inverter/__init__.py @@ -1,7 +1,5 @@ """Initialize the Imeon component.""" -from __future__ import annotations - import logging from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py index 3cb2b53d993..48cd81b148e 100644 --- a/homeassistant/components/imeon_inverter/coordinator.py +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Imeon integration.""" -from __future__ import annotations - from asyncio import timeout from datetime import timedelta import logging diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index f2d30ce34ef..084f297447f 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -1,7 +1,5 @@ """The IMGW-PIB integration.""" -from __future__ import annotations - import logging from aiohttp import ClientError @@ -50,7 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() - # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API + # Remove binary_sensor entities for which the endpoint + # has been blocked by IMGW-PIB API entity_reg = er.async_get(hass) for key in ("flood_warning", "flood_alarm"): if entity_id := entity_reg.async_get_entity_id( diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index fc4ff0e9f54..964ad481c9a 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -1,7 +1,5 @@ """Config flow for IMGW-PIB integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py index f74878d672c..0f3dfb87cd3 100644 --- a/homeassistant/components/imgw_pib/coordinator.py +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -1,7 +1,5 @@ """Data Update Coordinator for IMGW-PIB integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/imgw_pib/diagnostics.py b/homeassistant/components/imgw_pib/diagnostics.py index ce9cb3f9e95..dbf4ef436db 100644 --- a/homeassistant/components/imgw_pib/diagnostics.py +++ b/homeassistant/components/imgw_pib/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for IMGW-PIB.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 0265c6c2ec0..df22ae85410 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,9 +1,21 @@ { "entity": { "sensor": { + "emergent_vegetation_cover": { + "default": "mdi:leaf-circle-outline" + }, + "floating_vegetation_cover": { + "default": "mdi:leaf-circle-outline" + }, "hydrological_alert": { "default": "mdi:alert-octagon-outline" }, + "ice_phenomena": { + "default": "mdi:snowflake" + }, + "submerged_vegetation_cover": { + "default": "mdi:leaf-circle-outline" + }, "water_flow": { "default": "mdi:waves-arrow-right" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index c1d9580facd..eab1dbd55a9 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==2.0.2"] + "requirements": ["imgw_pib==2.2.2"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml index bf5f7a4c092..8fcda37a34b 100644 --- a/homeassistant/components/imgw_pib/quality_scale.yaml +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -66,9 +66,7 @@ rules: comment: This integration has a fixed single service. entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: This integration does not have any entities that should disabled by default. + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 170736d8f6c..e57447103bf 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -1,7 +1,5 @@ """IMGW-PIB sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -16,7 +14,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolumeFlowRate +from homeassistant.const import ( + PERCENTAGE, + UnitOfLength, + UnitOfTemperature, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -60,6 +63,14 @@ SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( value=lambda data: data.hydrological_alert.value, attrs=gen_alert_attributes, ), + ImgwPibSensorEntityDescription( + key="ice_phenomena", + translation_key="ice_phenomena", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.ice_phenomena.value, + suggested_display_precision=0, + ), ImgwPibSensorEntityDescription( key="water_flow", translation_key="water_flow", @@ -87,6 +98,33 @@ SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( suggested_display_precision=1, value=lambda data: data.water_temperature.value, ), + ImgwPibSensorEntityDescription( + key="submerged_vegetation_cover", + translation_key="submerged_vegetation_cover", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.submerged_vegetation_cover.value, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + ImgwPibSensorEntityDescription( + key="floating_vegetation_cover", + translation_key="floating_vegetation_cover", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.floating_vegetation_cover.value, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + ImgwPibSensorEntityDescription( + key="emergent_vegetation_cover", + translation_key="emergent_vegetation_cover", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.emergent_vegetation_cover.value, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), ) @@ -107,9 +145,7 @@ async def async_setup_entry( entity_reg.async_remove(entity_id) async_add_entities( - ImgwPibSensorEntity(coordinator, description) - for description in SENSOR_TYPES - if getattr(coordinator.data, description.key).value is not None + ImgwPibSensorEntity(coordinator, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index e746d66a945..a513c9ee8ee 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,12 @@ }, "entity": { "sensor": { + "emergent_vegetation_cover": { + "name": "Emergent vegetation cover" + }, + "floating_vegetation_cover": { + "name": "Floating vegetation cover" + }, "hydrological_alert": { "name": "Hydrological alert", "state": { @@ -59,6 +65,12 @@ } } }, + "ice_phenomena": { + "name": "Ice phenomena" + }, + "submerged_vegetation_cover": { + "name": "Submerged vegetation cover" + }, "water_flow": { "name": "Water flow" }, diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index 11f8b766dd4..d7b9e7924a4 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -1,7 +1,5 @@ """The Immich integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/immich/config_flow.py b/homeassistant/components/immich/config_flow.py index 98709f25de7..697d5b5d2d9 100644 --- a/homeassistant/components/immich/config_flow.py +++ b/homeassistant/components/immich/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Immich integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/immich/coordinator.py b/homeassistant/components/immich/coordinator.py index cb012b44b51..895d962642b 100644 --- a/homeassistant/components/immich/coordinator.py +++ b/homeassistant/components/immich/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Immich integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/immich/diagnostics.py b/homeassistant/components/immich/diagnostics.py index c44e24d8202..8bdcea18ed2 100644 --- a/homeassistant/components/immich/diagnostics.py +++ b/homeassistant/components/immich/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for immich.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 2a0680e314a..7cc16c1979a 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.12.1"] + "requirements": ["aioimmich==0.14.1"] } diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index e37172cb5e1..aab6df65c5b 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -1,12 +1,10 @@ """Immich as a media source.""" -from __future__ import annotations - from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aioimmich.assets.models import ImmichAsset -from aioimmich.exceptions import ImmichError +from aioimmich.exceptions import ImmichError, ImmichForbiddenError from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import BrowseError, MediaClass @@ -81,7 +79,7 @@ class ImmichMediaSource(MediaSource): ], ) - async def _async_build_immich( + async def _async_build_immich( # noqa: C901 self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" @@ -124,11 +122,11 @@ class ImmichMediaSource(MediaSource): identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title=collection, + title=collection.split("|", maxsplit=1)[0], can_play=False, can_expand=True, ) - for collection in ("albums", "people", "tags") + for collection in ("albums", "favorites|favorites", "people", "tags") ] # -------------------------------------------------------- @@ -139,6 +137,12 @@ class ImmichMediaSource(MediaSource): LOGGER.debug("Render all albums for %s", entry.title) try: albums = await immich_api.albums.async_get_all_albums() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -160,6 +164,12 @@ class ImmichMediaSource(MediaSource): LOGGER.debug("Render all tags for %s", entry.title) try: tags = await immich_api.tags.async_get_all_tags() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -180,6 +190,12 @@ class ImmichMediaSource(MediaSource): LOGGER.debug("Render all people for %s", entry.title) try: people = await immich_api.people.async_get_all_people() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -213,6 +229,12 @@ class ImmichMediaSource(MediaSource): identifier.collection_id ) assets = album_info.assets + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -225,6 +247,12 @@ class ImmichMediaSource(MediaSource): assets = await immich_api.search.async_get_all_by_tag_ids( [identifier.collection_id] ) + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -237,6 +265,24 @@ class ImmichMediaSource(MediaSource): assets = await immich_api.search.async_get_all_by_person_ids( [identifier.collection_id] ) + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err + except ImmichError: + return [] + elif identifier.collection == "favorites": + LOGGER.debug("Render all assets for favorites collection") + try: + assets = await immich_api.search.async_get_all_favorites() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] diff --git a/homeassistant/components/immich/sensor.py b/homeassistant/components/immich/sensor.py index c083ec51261..fafc99839b5 100644 --- a/homeassistant/components/immich/sensor.py +++ b/homeassistant/components/immich/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Immich integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py index 499b3b475a4..60c98d1e76f 100644 --- a/homeassistant/components/immich/services.py +++ b/homeassistant/components/immich/services.py @@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None: await coordinator.api.albums.async_add_assets_to_album( target_album, [upload_result.asset_id] ) - except ImmichError as ex: + except (ImmichError, FileNotFoundError) as ex: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="upload_failed", diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 4c465ea3d2b..3a1e61f8975 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -102,6 +102,9 @@ "identifier_unresolvable": { "message": "Could not parse identifier: {identifier}" }, + "missing_api_permission": { + "message": "Missing API permission ({msg})." + }, "not_configured": { "message": "Immich is not configured." }, diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py index e0af5c1c67f..ae9e49692c9 100644 --- a/homeassistant/components/immich/update.py +++ b/homeassistant/components/immich/update.py @@ -1,7 +1,5 @@ """Update platform for the Immich integration.""" -from __future__ import annotations - from homeassistant.components.update import UpdateEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/imou/__init__.py b/homeassistant/components/imou/__init__.py new file mode 100644 index 00000000000..1e7e12fd6c6 --- /dev/null +++ b/homeassistant/components/imou/__init__.py @@ -0,0 +1,42 @@ +"""Support for Imou devices.""" + +from pyimouapi.device import ImouDeviceManager +from pyimouapi.ha_device import ImouHaDeviceManager +from pyimouapi.openapi import ImouOpenApiClient + +from homeassistant.core import HomeAssistant, callback + +from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS +from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool: + """Set up Imou integration from a config entry.""" + imou_client = ImouOpenApiClient( + entry.data[CONF_APP_ID], + entry.data[CONF_APP_SECRET], + API_URLS[entry.data[CONF_API_URL]], + ) + device_manager = ImouDeviceManager(imou_client) + imou_device_manager = ImouHaDeviceManager(device_manager) + imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry) + await imou_coordinator.async_config_entry_first_refresh() + entry.runtime_data = imou_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # DataUpdateCoordinator schedules periodic refreshes only when it has + # listeners. With zero entities (e.g. an empty account at setup), register a + # no-op listener so polling continues and later devices are discovered via + # new_device_callbacks. + @callback + def _async_keep_polling() -> None: + """Keep periodic polling when no entities are registered yet.""" + + entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool: + """Handle removal of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py new file mode 100644 index 00000000000..31ef0c96bea --- /dev/null +++ b/homeassistant/components/imou/button.py @@ -0,0 +1,109 @@ +"""Support for Imou button controls.""" + +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import ImouHaDevice + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier +from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator +from .entity import ImouEntity + +PARALLEL_UPDATES = 1 +# Button types +PARAM_RESTART_DEVICE = "restart_device" +PARAM_MUTE = "mute" +PARAM_PTZ_UP = "ptz_up" +PARAM_PTZ_DOWN = "ptz_down" +PARAM_PTZ_LEFT = "ptz_left" +PARAM_PTZ_RIGHT = "ptz_right" + +BUTTON_TYPES = ( + PARAM_RESTART_DEVICE, + PARAM_MUTE, + PARAM_PTZ_UP, + PARAM_PTZ_DOWN, + PARAM_PTZ_LEFT, + PARAM_PTZ_RIGHT, +) + +PTZ_BUTTON_TYPES = ( + PARAM_PTZ_UP, + PARAM_PTZ_DOWN, + PARAM_PTZ_LEFT, + PARAM_PTZ_RIGHT, +) + +BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { + PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, +} + + +def _iter_buttons( + coordinator: ImouDataUpdateCoordinator, +) -> list[tuple[str, ImouHaDevice]]: + """Return (button_type, device) pairs for supported buttons.""" + return [ + (button_type, device) + for device in coordinator.devices + for button_type in device.buttons + if button_type in BUTTON_TYPES + ] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImouConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Imou button entities.""" + coordinator = entry.runtime_data + + def _add_buttons(new_devices: list[ImouHaDevice]) -> None: + device_keys = {imou_device_identifier(device) for device in new_devices} + async_add_entities( + ImouButton(coordinator, button_type, device) + for button_type, device in _iter_buttons(coordinator) + if imou_device_identifier(device) in device_keys + ) + + coordinator.new_device_callbacks.append(_add_buttons) + + @callback + def _remove_new_device_callback() -> None: + if _add_buttons in coordinator.new_device_callbacks: + coordinator.new_device_callbacks.remove(_add_buttons) + + entry.async_on_unload(_remove_new_device_callback) + _add_buttons(coordinator.devices) + + +class ImouButton(ImouEntity, ButtonEntity): + """Imou button entity.""" + + def __init__( + self, + coordinator: ImouDataUpdateCoordinator, + entity_type: str, + device: ImouHaDevice, + ) -> None: + """Initialize the Imou button entity.""" + super().__init__(coordinator, entity_type, device) + if device_class := BUTTON_DEVICE_CLASS.get(entity_type): + self._attr_device_class = device_class + self._attr_translation_key = None + + async def async_press(self) -> None: + """Handle button press.""" + duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0 + try: + await self.coordinator.device_manager.async_press_button( + self.device, + self._entity_type, + duration, + ) + except ImouException as e: + raise HomeAssistantError(str(e)) from e diff --git a/homeassistant/components/imou/config_flow.py b/homeassistant/components/imou/config_flow.py new file mode 100644 index 00000000000..49d43a13b38 --- /dev/null +++ b/homeassistant/components/imou/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow for Imou.""" + +import logging +from typing import Any + +from pyimouapi.exceptions import ( + ConnectFailedException, + ImouException, + InvalidAppIdOrSecretException, + RequestFailedException, +) +from pyimouapi.openapi import ImouOpenApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImouConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Imou integration.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the config flow.""" + errors: dict[str, str] = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_APP_ID]) + self._abort_if_unique_id_configured() + api_client = ImouOpenApiClient( + user_input[CONF_APP_ID], + user_input[CONF_APP_SECRET], + API_URLS[user_input[CONF_API_URL]], + ) + try: + await api_client.async_get_token() + except InvalidAppIdOrSecretException: + errors["base"] = "invalid_auth" + except ConnectFailedException, RequestFailedException: + errors["base"] = "cannot_connect" + except ImouException as exception: + _LOGGER.debug("Imou error during config flow: %s", exception) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Imou", + data={ + CONF_APP_ID: user_input[CONF_APP_ID], + CONF_APP_SECRET: user_input[CONF_APP_SECRET], + CONF_API_URL: user_input[CONF_API_URL], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_APP_ID): str, + vol.Required(CONF_APP_SECRET): str, + vol.Required(CONF_API_URL, default="sg"): SelectSelector( + SelectSelectorConfig( + options=list(API_URLS), + translation_key="api_url", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/imou/const.py b/homeassistant/components/imou/const.py new file mode 100644 index 00000000000..d315aa6b1c2 --- /dev/null +++ b/homeassistant/components/imou/const.py @@ -0,0 +1,39 @@ +"""Constants.""" + +from pyimouapi.ha_device import ImouHaDevice + +from homeassistant.const import Platform + +DOMAIN = "imou" + + +def imou_device_identifier(device: ImouHaDevice) -> str: + """Return a device registry identifier (device_id + channel when present).""" + if device.channel_id is not None: + return f"{device.device_id}_{device.channel_id}" + return device.device_id + + +# API URL region mapping +API_URLS: dict[str, str] = { + "sg": "openapi-sg.easy4ip.com", + "eu": "openapi-or.easy4ip.com", + "na": "openapi-fk.easy4ip.com", + "cn": "openapi.lechange.cn", +} + +CONF_API_URL = "api_url" +CONF_APP_ID = "app_id" +CONF_APP_SECRET = "app_secret" + +PARAM_STATUS = "status" +PARAM_STATE = "state" + + +# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API). +PTZ_MOVE_DURATION_MS = 500 + +# Upper bound for a full coordinator refresh (device list + status for all devices). +UPDATE_TIMEOUT = 300 + +PLATFORMS = [Platform.BUTTON] diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py new file mode 100644 index 00000000000..d25f2d4e613 --- /dev/null +++ b/homeassistant/components/imou/coordinator.py @@ -0,0 +1,152 @@ +"""Provides the Imou DataUpdateCoordinator.""" + +import asyncio +from collections.abc import Callable +from datetime import timedelta +import logging + +from pyimouapi.exceptions import ImouException +from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_TIMEOUT, imou_device_identifier + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=120) + +type ImouConfigEntry = ConfigEntry[ImouDataUpdateCoordinator] + + +class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Data update coordinator for Imou devices.""" + + config_entry: ImouConfigEntry + + def __init__( + self, + hass: HomeAssistant, + device_manager: ImouHaDeviceManager, + config_entry: ImouConfigEntry, + ) -> None: + """Initialize the Imou data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ImouDataUpdateCoordinator", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + always_update=True, + ) + self._device_manager = device_manager + self.devices_by_key: dict[str, ImouHaDevice] = {} + self._devices_initialized = False + self.new_device_callbacks: list[Callable[[list[ImouHaDevice]], None]] = [] + + @property + def devices(self) -> list[ImouHaDevice]: + """Return the list of devices.""" + return list(self.devices_by_key.values()) + + @property + def device_manager(self) -> ImouHaDeviceManager: + """Return the device manager.""" + return self._device_manager + + def get_device(self, device_key: str) -> ImouHaDevice | None: + """Return the current device for device_key, if still on the account.""" + return self.devices_by_key.get(device_key) + + async def _async_update_data(self) -> None: + """Update coordinator data.""" + try: + async with asyncio.timeout(UPDATE_TIMEOUT): + fresh_devices = await self._device_manager.async_get_devices() + except TimeoutError as err: + raise UpdateFailed(f"Timeout while fetching data: {err}") from err + except ImouException as err: + raise UpdateFailed(f"Error fetching Imou devices: {err}") from err + + fresh_by_key = { + imou_device_identifier(device): device for device in fresh_devices + } + self._async_add_remove_devices(fresh_by_key) + devices = list(self.devices_by_key.values()) + if not devices: + return + + try: + async with asyncio.timeout(UPDATE_TIMEOUT): + results = await asyncio.gather( + *( + self._device_manager.async_update_device_status(device) + for device in devices + ), + return_exceptions=True, + ) + except TimeoutError as err: + raise UpdateFailed(f"Timeout while fetching data: {err}") from err + + failures: list[Exception] = [] + for device, result in zip(devices, results, strict=True): + if isinstance(result, BaseException) and not isinstance(result, Exception): + # Propagate CancelledError and other BaseExceptions instead of + # swallowing them as a regular device failure. + raise result + if not isinstance(result, Exception): + continue + device_key = imou_device_identifier(device) + _LOGGER.warning( + "Error updating status for Imou device %s: %s", + device_key, + result, + ) + failures.append(result) + if failures and len(failures) == len(devices): + raise UpdateFailed( + f"Error updating Imou devices: {failures[0]}" + ) from failures[0] + + def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None: + """Add new devices, remove devices no longer in the account. + + This only tracks which devices exist on the account; per-device state + is updated in place by `async_update_device_status`, so devices that + remain on the account keep their existing object and are not replaced. + """ + if not self._devices_initialized: + self.devices_by_key = fresh_by_key + self._devices_initialized = True + return + + current_keys = set(fresh_by_key) + known_keys = set(self.devices_by_key) + + if current_keys == known_keys: + return + + if removed_keys := known_keys - current_keys: + _LOGGER.debug("Removed Imou device(s): %s", ", ".join(removed_keys)) + device_registry = dr.async_get(self.hass) + for device_key in removed_keys: + del self.devices_by_key[device_key] + if device := device_registry.async_get_device( + identifiers={(DOMAIN, device_key)} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_keys := current_keys - known_keys: + _LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys)) + new_devices = [] + for device_key in new_keys: + self.devices_by_key[device_key] = fresh_by_key[device_key] + new_devices.append(fresh_by_key[device_key]) + for callback in self.new_device_callbacks: + callback(new_devices) diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py new file mode 100644 index 00000000000..f3fd4257e96 --- /dev/null +++ b/homeassistant/components/imou/entity.py @@ -0,0 +1,59 @@ +"""An abstract class common to all Imou entities.""" + +from pyimouapi.ha_device import DeviceStatus, ImouHaDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier +from .coordinator import ImouDataUpdateCoordinator + + +class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): + """Base class for all Imou entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ImouDataUpdateCoordinator, + entity_type: str, + device: ImouHaDevice, + ) -> None: + """Initialize the Imou entity.""" + super().__init__(coordinator) + self._entity_type = entity_type + self._device_key = imou_device_identifier(device) + self._attr_unique_id = f"{self._device_key}${entity_type}" + self._attr_translation_key = entity_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_key)}, + name=device.channel_name or device.device_name, + manufacturer=device.manufacturer, + model=device.model, + sw_version=device.swversion, + serial_number=device.device_id, + ) + + @property + def device(self) -> ImouHaDevice: + """Return the live device from the coordinator. + + Callers must guard with `available` first; accessing this for a device + that has left the account raises `KeyError`. + """ + return self.coordinator.devices_by_key[self._device_key] + + @property + def available(self) -> bool: + """Return if the entity is available.""" + if ( + not super().available + or self._device_key not in self.coordinator.devices_by_key + ): + return False + if PARAM_STATUS not in self.device.sensors: + return False + return ( + self.device.sensors[PARAM_STATUS][PARAM_STATE] != DeviceStatus.OFFLINE.value + ) diff --git a/homeassistant/components/imou/icons.json b/homeassistant/components/imou/icons.json new file mode 100644 index 00000000000..8b2cd6c0562 --- /dev/null +++ b/homeassistant/components/imou/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "button": { + "ptz_down": { + "default": "mdi:arrow-down-bold" + }, + "ptz_left": { + "default": "mdi:arrow-left-bold" + }, + "ptz_right": { + "default": "mdi:arrow-right-bold" + }, + "ptz_up": { + "default": "mdi:arrow-up-bold" + } + } + } +} diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json new file mode 100644 index 00000000000..8f034a66389 --- /dev/null +++ b/homeassistant/components/imou/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "imou", + "name": "Imou", + "codeowners": ["@Imou-OpenPlatform"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imou", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pyimouapi==1.2.7"] +} diff --git a/homeassistant/components/imou/quality_scale.yaml b/homeassistant/components/imou/quality_scale.yaml new file mode 100644 index 00000000000..4442f476433 --- /dev/null +++ b/homeassistant/components/imou/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Cloud service integration, does not support discovery. + discovery: + status: exempt + comment: >- + Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No + supported local discovery flow today; example cues if investigated later: + hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json new file mode 100644 index 00000000000..ea7bed1bc65 --- /dev/null +++ b/homeassistant/components/imou/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_url": "Server region", + "app_id": "App ID", + "app_secret": "App secret" + }, + "data_description": { + "api_url": "Select the server region closest to your location", + "app_id": "The app ID obtained from the Imou cloud platform", + "app_secret": "The app secret obtained from the Imou cloud platform" + }, + "title": "Log in to Imou cloud" + } + } + }, + "entity": { + "button": { + "mute": { + "name": "Mute" + }, + "ptz_down": { + "name": "PTZ down" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + }, + "ptz_up": { + "name": "PTZ up" + } + } + }, + "selector": { + "api_url": { + "options": { + "cn": "China", + "eu": "Europe", + "na": "North America", + "sg": "Singapore (Asia-Pacific)" + } + } + } +} diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py index d0526ad7150..2710599ab5a 100644 --- a/homeassistant/components/improv_ble/__init__.py +++ b/homeassistant/components/improv_ble/__init__.py @@ -1,7 +1,5 @@ """The Improv BLE integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 2946018aace..1516aaf59ed 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Improv via BLE integration.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine from contextlib import asynccontextmanager @@ -366,7 +364,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): if err.error == Error.UNABLE_TO_CONNECT: self._credentials = None errors["base"] = "unable_to_connect" - # Only for UNABLE_TO_CONNECT do we continue to show the form with an error + # Only for UNABLE_TO_CONNECT do we continue + # to show the form with an error else: self._provision_result = self.async_abort(reason="unknown") return @@ -374,9 +373,10 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug( "Provision successful, redirect URL: %s", redirect_url ) - # Clear match history so device can be rediscovered if factory reset. - # This ensures that if the device is factory reset in the future, - # it will trigger a new discovery flow. + # Clear match history so device can be + # rediscovered if factory reset. This ensures + # that if the device is factory reset in the + # future, it will trigger a new discovery flow. bluetooth.async_clear_address_from_match_history( self.hass, self._discovery_info.address ) @@ -389,7 +389,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) - # Wait for another integration to discover and register flow chaining + # Wait for another integration to discover + # and register flow chaining next_flow_id: str | None = None try: @@ -398,7 +399,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): ) except TimeoutError: _LOGGER.debug( - "Timeout waiting for next flow, proceeding with URL redirect" + "Timeout waiting for next flow," + " proceeding with URL redirect" ) if next_flow_id: diff --git a/homeassistant/components/improv_ble/const.py b/homeassistant/components/improv_ble/const.py index c06e7c667a5..88afd41638c 100644 --- a/homeassistant/components/improv_ble/const.py +++ b/homeassistant/components/improv_ble/const.py @@ -1,7 +1,5 @@ """Constants for the Improv BLE integration.""" -from __future__ import annotations - import asyncio from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 307ff09206f..7871483f73c 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -1,23 +1,12 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" -from __future__ import annotations +from incomfortclient import Gateway as InComfortGateway -from aiohttp import ClientResponseError -from incomfortclient import InvalidGateway, InvalidHeaterList +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr - -from .const import DOMAIN -from .coordinator import ( - InComfortConfigEntry, - InComfortData, - InComfortDataCoordinator, - async_connect_gateway, -) -from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator PLATFORMS = ( Platform.WATER_HEATER, @@ -29,73 +18,16 @@ PLATFORMS = ( INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" -@callback -def async_cleanup_stale_devices( - hass: HomeAssistant, - entry: InComfortConfigEntry, - data: InComfortData, - gateway_device: dr.DeviceEntry, -) -> None: - """Cleanup stale heater devices and climates.""" - heater_serial_numbers = {heater.serial_no for heater in data.heaters} - device_registry = dr.async_get(hass) - device_entries = device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id - ) - stale_heater_serial_numbers: list[str] = [ - device_entry.serial_number - for device_entry in device_entries - if device_entry.id != gateway_device.id - and device_entry.serial_number is not None - and device_entry.serial_number not in heater_serial_numbers - ] - if not stale_heater_serial_numbers: - return - cleanup_devices: list[str] = [] - # Find stale heater and climate devices - for serial_number in stale_heater_serial_numbers: - cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] - cleanup_list.append(serial_number) - cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] - cleanup_devices.extend( - device_entry.id - for device_entry in device_entries - if device_entry.identifiers in cleanup_identifiers - ) - for device_id in cleanup_devices: - device_registry.async_remove_device(device_id) - - async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" - try: - data = await async_connect_gateway(hass, dict(entry.data)) - for heater in data.heaters: - await heater.update() - except InvalidHeaterList as exc: - raise NoHeaters from exc - except InvalidGateway as exc: - raise ConfigEntryAuthFailed("Incorrect credentials") from exc - except ClientResponseError as exc: - if exc.status == 404: - raise NotFound from exc - raise InComfortUnknownError from exc - except TimeoutError as exc: - raise InComfortTimeout from exc - # Register discovered gateway device - device_registry = dr.async_get(hass) - gateway_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.entry_id)}, - connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} - if entry.unique_id is not None - else set(), - manufacturer="Intergas", - name="RFGateway", + credentials = dict(entry.data) + hostname = credentials.pop(CONF_HOST) + client = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) ) - async_cleanup_stale_devices(hass, entry, data, gateway_device) - coordinator = InComfortDataCoordinator(hass, entry, data) + + coordinator = InComfortDataCoordinator(hass, entry, client) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 356cee82e57..48a803bebf5 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -1,10 +1,8 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, override from incomfortclient import Heater as InComfortHeater @@ -99,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity): self._attr_unique_id = f"{heater.serial_no}_{description.key}" @property + @override def is_on(self) -> bool: """Return the status of the sensor.""" return bool(self._heater.status[self.entity_description.value_key]) @property + @override def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None: diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index c10cbe5be5b..85f889ac669 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -1,8 +1,6 @@ """Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" -from __future__ import annotations - -from typing import Any +from typing import Any, override from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom @@ -78,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): ) @property + @override def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return {"status": self._room.status} @property + @override def current_temperature(self) -> float | None: """Return the current temperature.""" return self._room.room_temp @property + @override def hvac_action(self) -> HVACAction | None: """Return the actual current HVAC action.""" if self._heater.is_burning and self._heater.is_pumping: @@ -95,23 +96,27 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): return HVACAction.IDLE @property + @override def target_temperature(self) -> float | None: """Return the (override)temperature we try to reach. As we set the override, we report back the override. The actual set point is is returned at a later time. - Some older thermostats do not clear the override setting in that case, in that case - we fallback to the returning actual setpoint. + Some older thermostats do not clear the override + setting in that case, so we fallback to the returning + actual setpoint. """ if self._legacy_setpoint_status: return self._room.setpoint return self._room.override or self._room.setpoint + @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature for this zone.""" temperature: float = kwargs[ATTR_TEMPERATURE] await self._room.set_override(temperature) await self.coordinator.async_refresh() + @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 027c3ad4691..0c39db2b923 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -1,12 +1,14 @@ """Config flow support for Intergas InComfort integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging -from typing import Any +from typing import Any, override -from incomfortclient import InvalidGateway, InvalidHeaterList +from incomfortclient import ( + Gateway as InComfortGateway, + InvalidGateway, + InvalidHeaterList, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -19,6 +21,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( BooleanSelector, @@ -30,7 +33,7 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN -from .coordinator import InComfortConfigEntry, async_connect_gateway +from .coordinator import InComfortConfigEntry _LOGGER = logging.getLogger(__name__) TITLE = "Intergas InComfort/Intouch Lan2RF gateway" @@ -83,7 +86,13 @@ async def async_try_connect_gateway( ) -> dict[str, str] | None: """Try to connect to the Lan2RF gateway.""" try: - await async_connect_gateway(hass, config) + client = InComfortGateway( + hostname=config[CONF_HOST], + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + session=async_get_clientsession(hass), + ) + await client.heaters() except InvalidGateway: return {"base": "auth_error"} except InvalidHeaterList: @@ -102,6 +111,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): _discovered_host: str + @override @staticmethod @callback def async_get_options_flow( @@ -110,6 +120,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return InComfortOptionsFlowHandler() + @override async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: @@ -171,6 +182,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_HOST: self._discovered_host}, ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 5c72b9daa06..12f1255bb05 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -2,23 +2,26 @@ from dataclasses import dataclass, field from datetime import timedelta +from http import HTTPStatus import logging -from typing import Any +from typing import override from aiohttp import ClientResponseError from incomfortclient import ( Gateway as InComfortGateway, Heater as InComfortHeater, + InvalidGateway, InvalidHeaterList, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] _LOGGER = logging.getLogger(__name__) @@ -34,20 +37,41 @@ class InComfortData: heaters: list[InComfortHeater] = field(default_factory=list) -async def async_connect_gateway( +@callback +def async_cleanup_stale_devices( hass: HomeAssistant, - entry_data: dict[str, Any], -) -> InComfortData: - """Validate the configuration.""" - credentials = dict(entry_data) - hostname = credentials.pop(CONF_HOST) - - client = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) + entry: InComfortConfigEntry, + data: InComfortData, + gateway_device: dr.DeviceEntry, +) -> None: + """Cleanup stale heater devices and climates.""" + heater_serial_numbers = {heater.serial_no for heater in data.heaters} + device_registry = dr.async_get(hass) + device_entries = device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id ) - heaters = await client.heaters() - - return InComfortData(client=client, heaters=heaters) + stale_heater_serial_numbers: list[str] = [ + device_entry.serial_number + for device_entry in device_entries + if device_entry.id != gateway_device.id + and device_entry.serial_number is not None + and device_entry.serial_number not in heater_serial_numbers + ] + if not stale_heater_serial_numbers: + return + cleanup_devices: list[str] = [] + # Find stale heater and climate devices + for serial_number in stale_heater_serial_numbers: + cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] + cleanup_list.append(serial_number) + cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] + cleanup_devices.extend( + device_entry.id + for device_entry in device_entries + if device_entry.identifiers in cleanup_identifiers + ) + for device_id in cleanup_devices: + device_registry.async_remove_device(device_id) class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): @@ -59,10 +83,9 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): self, hass: HomeAssistant, config_entry: InComfortConfigEntry, - incomfort_data: InComfortData, + client: InComfortGateway, ) -> None: """Initialize coordinator.""" - self.unique_id = config_entry.unique_id super().__init__( hass, _LOGGER, @@ -70,19 +93,65 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): name="InComfort datacoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - self.incomfort_data = incomfort_data + self.client = client + self.unique_id = config_entry.unique_id + @override async def _async_update_data(self) -> InComfortData: - """Fetch data from API endpoint.""" + """Fetch data from Incomfort.""" try: - for heater in self.incomfort_data.heaters: + heaters = await self.client.heaters() + for heater in heaters: await heater.update() + except InvalidGateway as exc: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from exc except TimeoutError as exc: - raise UpdateFailed("Timeout error") from exc + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from exc except ClientResponseError as exc: - if exc.status == 401: - raise ConfigEntryError("Incorrect credentials") from exc - raise UpdateFailed(exc.message) from exc + if exc.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from exc + _LOGGER.exception("Error communicating with InComfort gateway") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown", + ) from exc except InvalidHeaterList as exc: - raise UpdateFailed(exc.message) from exc - return self.incomfort_data + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_heaters", + ) from exc + + incomfort_data = InComfortData( + client=self.client, + heaters=heaters, + ) + + # Register discovered gateway device + # Respect this as it is. Maybe later... + device_registry = dr.async_get(self.hass) + gateway_device = device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, self.config_entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.config_entry.unique_id)} + if self.config_entry.unique_id is not None + else set(), + manufacturer="Intergas", + name="RFGateway", + ) + async_cleanup_stale_devices( + self.hass, + self.config_entry, + incomfort_data, + gateway_device, + ) + + return incomfort_data diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py index 4d7af14eac7..a6115b29e32 100644 --- a/homeassistant/components/incomfort/diagnostics.py +++ b/homeassistant/components/incomfort/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for InComfort integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -29,15 +27,14 @@ def _async_get_diagnostics( redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG) coordinator = entry.runtime_data - nr_heaters = len(coordinator.incomfort_data.heaters) + nr_heaters = len(coordinator.data.heaters) status: dict[str, Any] = { - f"heater_{n}": coordinator.incomfort_data.heaters[n].status - for n in range(nr_heaters) + f"heater_{n}": coordinator.data.heaters[n].status for n in range(nr_heaters) } for n in range(nr_heaters): status[f"heater_{n}"]["rooms"] = { - m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) - for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) + m: dict(coordinator.data.heaters[n].rooms[m].status) + for m in range(len(coordinator.data.heaters[n].rooms)) } return { "config": redacted_config, diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py deleted file mode 100644 index c367916d6c7..00000000000 --- a/homeassistant/components/incomfort/errors.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Exceptions raised by Intergas InComfort integration.""" - -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError - -from .const import DOMAIN - - -class NotFound(HomeAssistantError): - """Raise exception if no Lan2RF Gateway was found.""" - - translation_domain = DOMAIN - translation_key = "not_found" - - -class NoHeaters(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "no_heaters" - - -class InComfortTimeout(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "timeout_error" - - -class InComfortUnknownError(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "unknown" diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 21db7125c30..3f559d78d3b 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -1,9 +1,7 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" -from __future__ import annotations - from dataclasses import dataclass -from typing import Any +from typing import Any, override from incomfortclient import Heater as InComfortHeater @@ -106,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity): self._attr_unique_id = f"{heater.serial_no}_{description.key}" @property + @override def native_value(self) -> StateType: """Return the state of the sensor.""" return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return] @property + @override def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" if (extra_key := self.entity_description.extra_key) is None: diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 8c331741a99..99aa18e5d3a 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -131,6 +131,9 @@ } }, "exceptions": { + "invalid_auth": { + "message": "[%key:component::incomfort::config::error::auth_error%]" + }, "no_heaters": { "message": "[%key:component::incomfort::config::error::no_heaters%]" }, @@ -142,6 +145,9 @@ }, "unknown": { "message": "[%key:component::incomfort::config::error::unknown%]" + }, + "update_failed_with_error_message": { + "message": "Update failed, got {error}." } }, "options": { diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 2a2c7cc47da..fcd2362eb86 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -1,9 +1,7 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" -from __future__ import annotations - import logging -from typing import Any +from typing import Any, override from incomfortclient import Heater as InComfortHeater @@ -51,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): self._attr_unique_id = heater.serial_no @property + @override def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS} @property + @override def current_temperature(self) -> float | None: """Return the current temperature.""" if self._heater.is_tapping: @@ -69,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): return max(self._heater.heater_temp, self._heater.tap_temp) @property + @override def current_operation(self) -> str | None: """Return the current operation mode.""" return self._heater.display_text diff --git a/homeassistant/components/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py index 7a4341d602b..907819e123f 100644 --- a/homeassistant/components/indevolt/__init__.py +++ b/homeassistant/components/indevolt/__init__.py @@ -1,19 +1,48 @@ """Home Assistant integration for indevolt device.""" -from __future__ import annotations +from typing import Any from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import IndevoltConfigEntry, IndevoltCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_migrate_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # 1.1 -> 1.2: indevolt-api 1.8.3 changed IndevoltBattery.MAIN_HEATING_STATE + # from 9079 to 9080, so migrate affected unique IDs. + @callback + def migrate_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, Any] | None: + if entity_entry.unique_id.endswith("_9079"): + return { + "new_unique_id": entity_entry.unique_id.removesuffix("_9079") + + "_9080" + } + return None + + await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) + hass.config_entries.async_update_entry(entry, version=1, minor_version=2) + + return True async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: @@ -29,6 +58,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up indevolt services (actions).""" + + await async_setup_services(hass) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: - """Unload a config entry / clean up resources (when integration is removed / reloaded).""" + """Unload a config entry. + + Clean up resources when integration is removed or reloaded. + """ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/indevolt/binary_sensor.py b/homeassistant/components/indevolt/binary_sensor.py new file mode 100644 index 00000000000..2e372d35a03 --- /dev/null +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -0,0 +1,152 @@ +"""Binary sensor platform for Indevolt integration.""" + +from dataclasses import dataclass +from typing import Final + +from indevolt_api import IndevoltBattery, IndevoltGrid, IndevoltSystem + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IndevoltConfigEntry +from .coordinator import IndevoltCoordinator +from .entity import IndevoltEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription): + """Custom entity description class for Indevolt binary sensors.""" + + on_value: int = 1000 + off_value: int = 1001 + generation: tuple[int, ...] = (1, 2) + + +BINARY_SENSORS: Final = ( + # Electricity Meter Status + IndevoltBinarySensorEntityDescription( + key=IndevoltGrid.METER_CONNECTED, + translation_key="meter_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Electric Heating States + IndevoltBinarySensorEntityDescription( + key=IndevoltSystem.HEATING_STATE, + generation=(1,), + translation_key="electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.MAIN_HEATING_STATE, + generation=(2,), + translation_key="main_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.PACK_1_HEATING_STATE, + generation=(2,), + translation_key="battery_pack_1_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.PACK_2_HEATING_STATE, + generation=(2,), + translation_key="battery_pack_2_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.PACK_3_HEATING_STATE, + generation=(2,), + translation_key="battery_pack_3_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.PACK_4_HEATING_STATE, + generation=(2,), + translation_key="battery_pack_4_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltBinarySensorEntityDescription( + key=IndevoltBattery.PACK_5_HEATING_STATE, + generation=(2,), + translation_key="battery_pack_5_electric_heating_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + +# Sensor per battery pack: (serial_number_key, heating_state_key) +BATTERY_PACK_SENSOR_KEYS = [ + (IndevoltBattery.PACK_1_SERIAL_NUMBER, IndevoltBattery.PACK_1_HEATING_STATE), + (IndevoltBattery.PACK_2_SERIAL_NUMBER, IndevoltBattery.PACK_2_HEATING_STATE), + (IndevoltBattery.PACK_3_SERIAL_NUMBER, IndevoltBattery.PACK_3_HEATING_STATE), + (IndevoltBattery.PACK_4_SERIAL_NUMBER, IndevoltBattery.PACK_4_HEATING_STATE), + (IndevoltBattery.PACK_5_SERIAL_NUMBER, IndevoltBattery.PACK_5_HEATING_STATE), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + excluded_keys: set[str] = set() + for sn_key, heating_key in BATTERY_PACK_SENSOR_KEYS: + if not coordinator.data.get(sn_key): + excluded_keys.add(heating_key) + + async_add_entities( + IndevoltBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + if device_gen in description.generation and description.key not in excluded_keys + ) + + +class IndevoltBinarySensorEntity(IndevoltEntity, BinarySensorEntity): + """Represents a binary sensor entity for Indevolt devices.""" + + entity_description: IndevoltBinarySensorEntityDescription + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltBinarySensorEntityDescription, + ) -> None: + """Initialize the Indevolt binary sensor entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{self.serial_number}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return on/active state of the binary sensor.""" + raw_value = self.coordinator.data.get(self.entity_description.key) + + if raw_value == self.entity_description.on_value: + return True + + if raw_value == self.entity_description.off_value: + return False + + return None diff --git a/homeassistant/components/indevolt/button.py b/homeassistant/components/indevolt/button.py index 6abcf50048b..53461602315 100644 --- a/homeassistant/components/indevolt/button.py +++ b/homeassistant/components/indevolt/button.py @@ -1,10 +1,10 @@ """Button platform for Indevolt integration.""" -from __future__ import annotations - -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Final +from indevolt_api import IndevoltRealtimeAction + from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0 class IndevoltButtonEntityDescription(ButtonEntityDescription): """Custom entity description class for Indevolt button entities.""" - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) BUTTONS: Final = ( @@ -66,5 +66,4 @@ class IndevoltButtonEntity(IndevoltEntity, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - - await self.coordinator.async_execute_realtime_action([0, 0, 0]) + await self.coordinator.async_realtime_action(IndevoltRealtimeAction.STOP) diff --git a/homeassistant/components/indevolt/config_flow.py b/homeassistant/components/indevolt/config_flow.py index feca6c647e5..7039d426704 100644 --- a/homeassistant/components/indevolt/config_flow.py +++ b/homeassistant/components/indevolt/config_flow.py @@ -10,6 +10,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN @@ -20,6 +22,13 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN): """Configuration flow for Indevolt integration.""" VERSION = 1 + MINOR_VERSION = 2 + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self._discovered_host: str | None = None + self._discovered_device_data: dict[str, Any] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,6 +92,84 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery for registered Indevolt devices.""" + host = discovery_info.ip + + try: + device_data = await self._async_get_device_data(host) + except OSError, ClientError, KeyError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host}, reload_on_update=True + ) + + self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]} + self._discovered_host = host + self._discovered_device_data = device_data + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery — probe the device to confirm it is an Indevolt device.""" + host = str(discovery_info.ip_address) + + # The mDNS hostname encodes the SN as "{sn}.local." — if it is not in + # that form, this is not a recognisable Indevolt device; abort without probing. + if ( + sn := discovery_info.hostname.removesuffix(".local.") + ) == discovery_info.hostname: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(sn) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host}, reload_on_update=True + ) + + try: + device_data = await self._async_get_device_data(host) + except OSError, ClientError, KeyError: + return self.async_abort(reason="cannot_connect") + + self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]} + self._discovered_host = host + self._discovered_device_data = device_data + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm zeroconf discovery by user.""" + assert self._discovered_host is not None + assert self._discovered_device_data is not None + + # Attempt to setup from user input + if user_input is not None: + return self.async_create_entry( + title=f"INDEVOLT {self._discovered_device_data[CONF_MODEL]}", + data={ + CONF_HOST: self._discovered_host, + **self._discovered_device_data, + }, + ) + + # Retrieve user confirmation + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + CONF_HOST: self._discovered_host, + CONF_MODEL: self._discovered_device_data[CONF_MODEL], + }, + ) + async def _async_validate_input( self, user_input: dict[str, Any] ) -> tuple[dict[str, str], dict[str, Any] | None]: diff --git a/homeassistant/components/indevolt/const.py b/homeassistant/components/indevolt/const.py index 3b469282a64..c5d8d5b748a 100644 --- a/homeassistant/components/indevolt/const.py +++ b/homeassistant/components/indevolt/const.py @@ -2,6 +2,14 @@ from typing import Final +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltGrid, + IndevoltSolar, + IndevoltSystem, +) + DOMAIN: Final = "indevolt" # Default configurations @@ -11,108 +19,147 @@ DEFAULT_PORT: Final = 8080 CONF_SERIAL_NUMBER: Final = "serial_number" CONF_GENERATION: Final = "generation" -# API write/read keys for energy and value for outdoor/portable mode -ENERGY_MODE_READ_KEY: Final = "7101" -ENERGY_MODE_WRITE_KEY: Final = "47005" -PORTABLE_MODE: Final = 0 - -# API write key and value for real-time control mode -REALTIME_ACTION_KEY: Final = "47015" -REALTIME_ACTION_MODE: Final = 4 - # API key fields SENSOR_KEYS: Final[dict[int, list[str]]] = { 1: [ - "606", - "7101", - "2101", - "2108", - "2107", - "6000", - "6001", - "6002", - "1501", - "1502", - "1664", - "1665", - "1666", - "1667", - "6105", - "21028", - "1505", + IndevoltSystem.OPERATING_MODE, + IndevoltConfig.READ_ENERGY_MODE, + IndevoltSystem.INPUT_POWER, + IndevoltSystem.OUTPUT_POWER, + IndevoltSystem.TOTAL_INPUT_ENERGY, + IndevoltSystem.TOTAL_OUTPUT_ENERGY, + IndevoltBattery.POWER, + IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, + IndevoltSystem.BYPASS_POWER, + IndevoltSystem.BYPASS_INPUT_ENERGY, + IndevoltBattery.RATED_CAPACITY, + IndevoltBattery.DAILY_CHARGING_ENERGY, + IndevoltBattery.DAILY_DISCHARGING_ENERGY, + IndevoltBattery.TOTAL_CHARGING_ENERGY, + IndevoltBattery.TOTAL_DISCHARGING_ENERGY, + IndevoltBattery.CHARGE_DISCHARGE_STATE, + IndevoltBattery.SOC, + IndevoltSolar.DC_OUTPUT_POWER, + IndevoltSolar.DAILY_PRODUCTION, + IndevoltSolar.DC_INPUT_POWER_1, + IndevoltSolar.DC_INPUT_VOLTAGE_1, + IndevoltSolar.DC_INPUT_CURRENT_1, + IndevoltSolar.DC_INPUT_POWER_2, + IndevoltSolar.DC_INPUT_VOLTAGE_2, + IndevoltSolar.DC_INPUT_CURRENT_2, + IndevoltSolar.DC_INPUT_POWER_3, + IndevoltSolar.DC_INPUT_POWER_4, + IndevoltConfig.READ_DISCHARGE_LIMIT, + IndevoltConfig.READ_REALTIME_COMMAND, + IndevoltConfig.READ_REALTIME_TARGET_SOC, + IndevoltConfig.READ_REALTIME_POWER_LIMIT, + IndevoltGrid.METER_POWER_GEN1, + IndevoltGrid.METER_CONNECTED, + IndevoltSolar.CUMULATIVE_PRODUCTION, + IndevoltSystem.HEATING_STATE, + IndevoltBattery.GEN_1_INVERTER_TEMPERATURE, + IndevoltBattery.GEN_1_PACK_1_TEMPERATURE, + IndevoltBattery.GEN_1_PACK_2_TEMPERATURE, + IndevoltBattery.GEN_1_PACK_3_TEMPERATURE, + IndevoltBattery.GEN_1_MOS_TEMPERATURE_CHARGE, + IndevoltBattery.GEN_1_MOS_TEMPERATURE_DISCHARGE, ], 2: [ - "606", - "7101", - "2101", - "2108", - "2107", - "6000", - "6001", - "6002", - "1501", - "1502", - "1664", - "1665", - "1666", - "1667", - "142", - "667", - "2104", - "2105", - "11034", - "6004", - "6005", - "6006", - "6007", - "11016", - "2600", - "2612", - "1632", - "1600", - "1633", - "1601", - "1634", - "1602", - "1635", - "1603", - "9008", - "9032", - "9051", - "9070", - "9165", - "9218", - "9000", - "9016", - "9035", - "9054", - "9149", - "9202", - "9012", - "9030", - "9049", - "9068", - "9163", - "9216", - "9004", - "9020", - "9039", - "9058", - "9153", - "9206", - "9013", - "19173", - "19174", - "19175", - "19176", - "19177", - "680", - "2618", - "7171", - "11011", - "11009", - "11010", - "6105", - "1505", + IndevoltSystem.OPERATING_MODE, + IndevoltConfig.READ_ENERGY_MODE, + IndevoltSystem.INPUT_POWER, + IndevoltSystem.OUTPUT_POWER, + IndevoltSystem.TOTAL_INPUT_ENERGY, + IndevoltBattery.POWER, + IndevoltBattery.CHARGE_DISCHARGE_STATE, + IndevoltBattery.SOC, + IndevoltSolar.DC_OUTPUT_POWER, + IndevoltSolar.DAILY_PRODUCTION, + IndevoltSolar.DC_INPUT_POWER_1, + IndevoltSolar.DC_INPUT_POWER_2, + IndevoltSolar.DC_INPUT_POWER_3, + IndevoltSolar.DC_INPUT_POWER_4, + IndevoltBattery.RATED_CAPACITY, + IndevoltSystem.BYPASS_POWER, + IndevoltSystem.TOTAL_OUTPUT_ENERGY, + IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, + IndevoltSystem.BYPASS_INPUT_ENERGY, + IndevoltBattery.DAILY_CHARGING_ENERGY, + IndevoltBattery.DAILY_DISCHARGING_ENERGY, + IndevoltBattery.TOTAL_CHARGING_ENERGY, + IndevoltBattery.TOTAL_DISCHARGING_ENERGY, + IndevoltGrid.METER_POWER_GEN2, + IndevoltGrid.VOLTAGE, + IndevoltGrid.FREQUENCY, + IndevoltSolar.DC_INPUT_CURRENT_1, + IndevoltSolar.DC_INPUT_VOLTAGE_1, + IndevoltSolar.DC_INPUT_CURRENT_2, + IndevoltSolar.DC_INPUT_VOLTAGE_2, + IndevoltSolar.DC_INPUT_CURRENT_3, + IndevoltSolar.DC_INPUT_VOLTAGE_3, + IndevoltSolar.DC_INPUT_CURRENT_4, + IndevoltSolar.DC_INPUT_VOLTAGE_4, + IndevoltBattery.MAIN_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.MAIN_SOC, + IndevoltBattery.PACK_1_SOC, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.MAIN_TEMPERATURE, + IndevoltBattery.PACK_1_TEMPERATURE, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.MAIN_MOS_TEMPERATURE, + IndevoltBattery.PACK_1_MOS_TEMPERATURE, + IndevoltBattery.PACK_2_MOS_TEMPERATURE, + IndevoltBattery.PACK_3_MOS_TEMPERATURE, + IndevoltBattery.PACK_4_MOS_TEMPERATURE, + IndevoltBattery.PACK_5_MOS_TEMPERATURE, + IndevoltBattery.MAIN_VOLTAGE, + IndevoltBattery.PACK_1_VOLTAGE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.MAIN_CURRENT, + IndevoltBattery.PACK_1_CURRENT, + IndevoltBattery.PACK_2_CURRENT, + IndevoltBattery.PACK_3_CURRENT, + IndevoltBattery.PACK_4_CURRENT, + IndevoltBattery.PACK_5_CURRENT, + IndevoltBattery.MAIN_CYCLES, + IndevoltBattery.PACK_1_CYCLES, + IndevoltBattery.PACK_2_CYCLES, + IndevoltBattery.PACK_3_CYCLES, + IndevoltBattery.PACK_4_CYCLES, + IndevoltBattery.PACK_5_CYCLES, + IndevoltConfig.READ_BYPASS, + IndevoltConfig.READ_GRID_CHARGING, + IndevoltConfig.READ_LIGHT, + IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, + IndevoltConfig.READ_INVERTER_INPUT_LIMIT, + IndevoltConfig.READ_FEEDIN_POWER_LIMIT, + IndevoltConfig.READ_DISCHARGE_LIMIT, + IndevoltConfig.READ_REALTIME_COMMAND, + IndevoltConfig.READ_REALTIME_TARGET_SOC, + IndevoltConfig.READ_REALTIME_POWER_LIMIT, + IndevoltBattery.MAIN_HEATING_STATE, + IndevoltBattery.PACK_1_HEATING_STATE, + IndevoltBattery.PACK_2_HEATING_STATE, + IndevoltBattery.PACK_3_HEATING_STATE, + IndevoltBattery.PACK_4_HEATING_STATE, + IndevoltBattery.PACK_5_HEATING_STATE, + IndevoltGrid.METER_CONNECTED, + IndevoltSolar.CUMULATIVE_PRODUCTION, + IndevoltBattery.GEN_2_CYCLE_COUNT, + IndevoltBattery.GEN_2_TRANSFORMER_TEMPERATURE, ], } diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py index 19320eec544..9c0bdeb70dc 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -1,19 +1,24 @@ """Home Assistant integration for Indevolt device.""" -from __future__ import annotations - from datetime import timedelta +import itertools import logging from typing import Any, Final from aiohttp import ClientError -from indevolt_api import IndevoltAPI, TimeOutException +from indevolt_api import ( + IndevoltAPI, + IndevoltConfig, + IndevoltEnergyMode, + IndevoltRealtimeAction, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -21,34 +26,23 @@ from .const import ( CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN, - ENERGY_MODE_READ_KEY, - ENERGY_MODE_WRITE_KEY, - PORTABLE_MODE, - REALTIME_ACTION_KEY, - REALTIME_ACTION_MODE, SENSOR_KEYS, ) _LOGGER = logging.getLogger(__name__) +SCAN_BATCH_SIZE: Final = 50 SCAN_INTERVAL: Final = 30 type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator] -class DeviceTimeoutError(HomeAssistantError): - """Raised when device push times out.""" - - -class DeviceConnectionError(HomeAssistantError): - """Raised when device push fails due to connection issues.""" - - class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for fetching and pushing data to indevolt devices.""" friendly_name: str config_entry: IndevoltConfigEntry firmware_version: str | None + mac_address: str | None serial_number: str device_model: str generation: int @@ -70,48 +64,56 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): session=async_get_clientsession(hass), ) - self.friendly_name = entry.title - self.serial_number = entry.data[CONF_SERIAL_NUMBER] - self.device_model = entry.data[CONF_MODEL] - self.generation = entry.data[CONF_GENERATION] + self.friendly_name: str = entry.title + self.serial_number: str = entry.data[CONF_SERIAL_NUMBER] + self.device_model: str = entry.data[CONF_MODEL] + self.generation: int = entry.data[CONF_GENERATION] async def _async_setup(self) -> None: """Fetch device info once on boot.""" try: config_data = await self.api.get_config() - except TimeOutException as err: + except (ClientError, OSError) as err: raise ConfigEntryNotReady( - f"Device config retrieval timed out: {err}" + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + translation_placeholders={"error": str(err)}, ) from err # Cache device information device_data = config_data.get("device", {}) - self.firmware_version = device_data.get("fw") + raw_mac = device_data.get("mac") + self.mac_address = format_mac(raw_mac) if raw_mac else None async def _async_update_data(self) -> dict[str, Any]: """Fetch raw JSON data from the device.""" + data: dict[str, Any] = {} sensor_keys = SENSOR_KEYS[self.generation] try: - return await self.api.fetch_data(sensor_keys) - except TimeOutException as err: - raise UpdateFailed(f"Device update timed out: {err}") from err + for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False): + data.update(await self.api.fetch_data(list(chunk))) + + except (ClientError, OSError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + + else: + return data async def async_push_data(self, sensor_key: str, value: Any) -> bool: """Push/write data values to given key on the device.""" - try: - return await self.api.set_data(sensor_key, value) - except TimeOutException as err: - raise DeviceTimeoutError(f"Device push timed out: {err}") from err - except (ClientError, ConnectionError, OSError) as err: - raise DeviceConnectionError(f"Device push failed: {err}") from err + return await self.api.set_data(sensor_key, value) async def async_switch_energy_mode( - self, target_mode: int, refresh: bool = True + self, target_mode: IndevoltEnergyMode, refresh: bool = True ) -> None: """Attempt to switch device to given energy mode.""" - current_mode = self.data.get(ENERGY_MODE_READ_KEY) + current_mode = self.data.get(IndevoltConfig.READ_ENERGY_MODE) # Ensure current energy mode is known if current_mode is None: @@ -121,7 +123,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # Ensure device is not in "Outdoor/Portable mode" - if current_mode == PORTABLE_MODE: + if current_mode == IndevoltEnergyMode.OUTDOOR_PORTABLE: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="energy_mode_change_unavailable_outdoor_portable", @@ -129,13 +131,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Switch energy mode if required if current_mode != target_mode: - try: - success = await self.async_push_data(ENERGY_MODE_WRITE_KEY, target_mode) - except (DeviceTimeoutError, DeviceConnectionError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="failed_to_switch_energy_mode", - ) from err + success = await self.async_push_data( + IndevoltConfig.WRITE_ENERGY_MODE, target_mode + ) if not success: raise HomeAssistantError( @@ -146,19 +144,27 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): if refresh: await self.async_request_refresh() - async def async_execute_realtime_action(self, action: list[int]) -> None: + async def async_realtime_action( + self, + action: IndevoltRealtimeAction, + power: int = 0, + target_soc: int = 0, + ) -> None: """Switch mode, execute action, and refresh for real-time control.""" - await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False) + await self.async_switch_energy_mode( + IndevoltEnergyMode.REAL_TIME_CONTROL, refresh=False + ) - try: - success = await self.async_push_data(REALTIME_ACTION_KEY, action) + success = False - except (DeviceTimeoutError, DeviceConnectionError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="failed_to_execute_realtime_action", - ) from err + match action: + case IndevoltRealtimeAction.CHARGE: + success = await self.api.charge(power, target_soc) + case IndevoltRealtimeAction.DISCHARGE: + success = await self.api.discharge(power, target_soc) + case IndevoltRealtimeAction.STOP: + success = await self.api.stop() if not success: raise HomeAssistantError( @@ -167,3 +173,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + + def get_emergency_soc(self) -> int: + """Get the emergency SOC value.""" + return int(self.data[IndevoltConfig.READ_DISCHARGE_LIMIT]) diff --git a/homeassistant/components/indevolt/diagnostics.py b/homeassistant/components/indevolt/diagnostics.py index fadc6e63403..a05f79baa8c 100644 --- a/homeassistant/components/indevolt/diagnostics.py +++ b/homeassistant/components/indevolt/diagnostics.py @@ -1,9 +1,9 @@ """Diagnostics support for Indevolt integration.""" -from __future__ import annotations - from typing import Any +from indevolt_api import IndevoltBattery, IndevoltSystem + from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -15,16 +15,25 @@ from .coordinator import IndevoltConfigEntry TO_REDACT = { CONF_HOST, CONF_SERIAL_NUMBER, - "0", - "9008", - "9032", - "9051", - "9070", - "9218", - "9165", + IndevoltSystem.SERIAL_NUMBER, + IndevoltBattery.MAIN_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SERIAL_NUMBER, } +def _redact_mac(mac_address: str) -> str: + """Redact the device-specific part of a MAC address. + + Keeps the OUI, which is used for discovery. + """ + parts = mac_address.split(":") + return ":".join([*parts[:3], "XX", "XX", "XX"]) + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: IndevoltConfigEntry ) -> dict[str, Any]: @@ -36,6 +45,9 @@ async def async_get_config_entry_diagnostics( "generation": coordinator.generation, "serial_number": coordinator.serial_number, "firmware_version": coordinator.firmware_version, + "mac_address": _redact_mac(coordinator.mac_address) + if coordinator.mac_address + else None, } return { diff --git a/homeassistant/components/indevolt/entity.py b/homeassistant/components/indevolt/entity.py index da87036a33a..de4b5a209c8 100644 --- a/homeassistant/components/indevolt/entity.py +++ b/homeassistant/components/indevolt/entity.py @@ -1,6 +1,6 @@ """Base entity for Indevolt integration.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -21,8 +21,12 @@ class IndevoltEntity(CoordinatorEntity[IndevoltCoordinator]): def device_info(self) -> DeviceInfo: """Return device information for registry.""" coordinator = self.coordinator + connections: set[tuple[str, str]] = set() + if coordinator.mac_address: + connections.add((CONNECTION_NETWORK_MAC, coordinator.mac_address)) return DeviceInfo( identifiers={(DOMAIN, coordinator.serial_number)}, + connections=connections, manufacturer="INDEVOLT", serial_number=coordinator.serial_number, model=coordinator.device_model, diff --git a/homeassistant/components/indevolt/icons.json b/homeassistant/components/indevolt/icons.json new file mode 100644 index 00000000000..291997db54e --- /dev/null +++ b/homeassistant/components/indevolt/icons.json @@ -0,0 +1,22 @@ +{ + "entity": { + "button": { + "stop": { + "default": "mdi:stop" + } + }, + "select": { + "energy_mode": { + "default": "mdi:home-lightning-bolt" + } + } + }, + "services": { + "charge": { + "service": "mdi:battery-arrow-up" + }, + "discharge": { + "service": "mdi:battery-arrow-down" + } + } +} diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 2e67b487bd6..f53cef6d414 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -3,9 +3,11 @@ "name": "Indevolt", "codeowners": ["@xirt"], "config_flow": true, + "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/indevolt", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", - "requirements": ["indevolt-api==1.2.3"] + "quality_scale": "platinum", + "requirements": ["indevolt-api==1.8.5"], + "zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }] } diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py index 0831e9b9657..bd4ef3ba12f 100644 --- a/homeassistant/components/indevolt/number.py +++ b/homeassistant/components/indevolt/number.py @@ -1,10 +1,10 @@ """Number platform for Indevolt integration.""" -from __future__ import annotations - -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Final +from indevolt_api import IndevoltConfig + from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IndevoltConfigEntry +from .const import DOMAIN from .coordinator import IndevoltCoordinator from .entity import IndevoltEntity @@ -27,30 +28,29 @@ PARALLEL_UPDATES = 0 class IndevoltNumberEntityDescription(NumberEntityDescription): """Custom entity description class for Indevolt number entities.""" - generation: list[int] = field(default_factory=lambda: [1, 2]) read_key: str write_key: str + generation: tuple[int, ...] = (1, 2) NUMBERS: Final = ( IndevoltNumberEntityDescription( key="discharge_limit", - generation=[2], + generation=(2,), translation_key="discharge_limit", - read_key="6105", - write_key="1142", + read_key=IndevoltConfig.READ_DISCHARGE_LIMIT, + write_key=IndevoltConfig.WRITE_DISCHARGE_LIMIT, native_min_value=0, native_max_value=100, native_step=1, native_unit_of_measurement=PERCENTAGE, - device_class=NumberDeviceClass.BATTERY, ), IndevoltNumberEntityDescription( key="max_ac_output_power", - generation=[2], + generation=(2,), translation_key="max_ac_output_power", - read_key="11011", - write_key="1147", + read_key=IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, + write_key=IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER, native_min_value=0, native_max_value=2400, native_step=100, @@ -59,10 +59,10 @@ NUMBERS: Final = ( ), IndevoltNumberEntityDescription( key="inverter_input_limit", - generation=[2], + generation=(2,), translation_key="inverter_input_limit", - read_key="11009", - write_key="1138", + read_key=IndevoltConfig.READ_INVERTER_INPUT_LIMIT, + write_key=IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT, native_min_value=100, native_max_value=2400, native_step=100, @@ -71,10 +71,10 @@ NUMBERS: Final = ( ), IndevoltNumberEntityDescription( key="feedin_power_limit", - generation=[2], + generation=(2,), translation_key="feedin_power_limit", - read_key="11010", - write_key="1146", + read_key=IndevoltConfig.READ_FEEDIN_POWER_LIMIT, + write_key=IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT, native_min_value=0, native_max_value=2400, native_step=100, @@ -139,4 +139,8 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity): await self.coordinator.async_request_refresh() else: - raise HomeAssistantError(f"Failed to set value {int_value} for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_error", + translation_placeholders={"name": str(self.name)}, + ) diff --git a/homeassistant/components/indevolt/quality_scale.yaml b/homeassistant/components/indevolt/quality_scale.yaml index a532a7868ac..69f9518a9b9 100644 --- a/homeassistant/components/indevolt/quality_scale.yaml +++ b/homeassistant/components/indevolt/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze (mandatory for core integrations) - action-setup: - status: exempt - comment: Integration does not register custom actions + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register custom actions + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,9 +22,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not register custom actions + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -46,19 +40,15 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: exempt - comment: Integration does not support network discovery - discovery: - status: exempt - comment: Integration does not support network discovery - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: Integration represents a single device, not a hub with multiple devices @@ -66,8 +56,8 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo - icon-translations: todo + exception-translations: done + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt @@ -79,4 +69,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/indevolt/select.py b/homeassistant/components/indevolt/select.py index 2850ae2da52..2d4b0fab634 100644 --- a/homeassistant/components/indevolt/select.py +++ b/homeassistant/components/indevolt/select.py @@ -1,16 +1,17 @@ """Select platform for Indevolt integration.""" -from __future__ import annotations - from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltConfig, IndevoltEnergyMode + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IndevoltConfigEntry +from .const import DOMAIN from .coordinator import IndevoltCoordinator from .entity import IndevoltEntity @@ -23,23 +24,23 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): read_key: str write_key: str - value_to_option: dict[int, str] - unavailable_values: list[int] = field(default_factory=list) - generation: list[int] = field(default_factory=lambda: [1, 2]) + value_to_option: dict[IndevoltEnergyMode, str] + unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list) + generation: tuple[int, ...] = (1, 2) SELECTS: Final = ( IndevoltSelectEntityDescription( key="energy_mode", translation_key="energy_mode", - read_key="7101", - write_key="47005", + read_key=IndevoltConfig.READ_ENERGY_MODE, + write_key=IndevoltConfig.WRITE_ENERGY_MODE, value_to_option={ - 1: "self_consumed_prioritized", - 4: "real_time_control", - 5: "charge_discharge_schedule", + IndevoltEnergyMode.SELF_CONSUMED_PRIORITIZED: "self_consumed_prioritized", + IndevoltEnergyMode.REAL_TIME_CONTROL: "real_time_control", + IndevoltEnergyMode.CHARGE_DISCHARGE_SCHEDULE: "charge_discharge_schedule", }, - unavailable_values=[0], + unavailable_values=[IndevoltEnergyMode.OUTDOOR_PORTABLE], ), ) @@ -108,4 +109,8 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity): await self.coordinator.async_request_refresh() else: - raise HomeAssistantError(f"Failed to set option {option} for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_error", + translation_placeholders={"name": str(self.name)}, + ) diff --git a/homeassistant/components/indevolt/sensor.py b/homeassistant/components/indevolt/sensor.py index 75040bf8e7e..1331d7b0356 100644 --- a/homeassistant/components/indevolt/sensor.py +++ b/homeassistant/components/indevolt/sensor.py @@ -1,7 +1,16 @@ """Sensor platform for Indevolt integration.""" from dataclasses import dataclass, field -from typing import Final +from typing import Final, cast + +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltEnergyMode, + IndevoltGrid, + IndevoltSolar, + IndevoltSystem, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,21 +43,26 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): """Custom entity description class for Indevolt sensors.""" state_mapping: dict[str | int, str] = field(default_factory=dict) - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) + energy_mode: IndevoltEnergyMode | None = None SENSORS: Final = ( # System Operating Information IndevoltSensorEntityDescription( - key="606", + key=IndevoltSystem.OPERATING_MODE, translation_key="mode", - state_mapping={"1000": "main", "1001": "sub", "1002": "standalone"}, + state_mapping={ + "1000": "main", + "1001": "sub", + "1002": "standalone", + }, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="7101", + key=IndevoltConfig.READ_ENERGY_MODE, translation_key="energy_mode", state_mapping={ 0: "outdoor_portable", @@ -59,102 +73,120 @@ SENSORS: Final = ( device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="142", - generation=[2], + key=IndevoltBattery.RATED_CAPACITY, translation_key="rated_capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6105", - generation=[1], - translation_key="rated_capacity", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + key=IndevoltConfig.READ_DISCHARGE_LIMIT, + generation=(1,), + translation_key="discharge_limit", + native_unit_of_measurement=PERCENTAGE, + ), + # Real-time control state + IndevoltSensorEntityDescription( + key=IndevoltConfig.READ_REALTIME_COMMAND, + translation_key="realtime_command", + state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"}, + device_class=SensorDeviceClass.ENUM, + energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL, ), IndevoltSensorEntityDescription( - key="2101", + key=IndevoltConfig.READ_REALTIME_TARGET_SOC, + translation_key="realtime_target_soc", + native_unit_of_measurement=PERCENTAGE, + energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL, + ), + IndevoltSensorEntityDescription( + key=IndevoltConfig.READ_REALTIME_POWER_LIMIT, + translation_key="realtime_power_limit", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + energy_mode=IndevoltEnergyMode.REAL_TIME_CONTROL, + ), + IndevoltSensorEntityDescription( + key=IndevoltSystem.INPUT_POWER, translation_key="ac_input_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="2108", + key=IndevoltSystem.OUTPUT_POWER, translation_key="ac_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="667", - generation=[2], + key=IndevoltSystem.BYPASS_POWER, translation_key="bypass_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.GEN_2_CYCLE_COUNT, + generation=(2,), + translation_key="equivalent_full_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), # Electrical Energy Information IndevoltSensorEntityDescription( - key="2107", + key=IndevoltSystem.TOTAL_INPUT_ENERGY, translation_key="total_ac_input_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="2104", - generation=[2], + key=IndevoltSystem.TOTAL_OUTPUT_ENERGY, translation_key="total_ac_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="2105", - generation=[2], + key=IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, translation_key="off_grid_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="11034", - generation=[2], + key=IndevoltSystem.BYPASS_INPUT_ENERGY, translation_key="bypass_input_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6004", - generation=[2], + key=IndevoltBattery.DAILY_CHARGING_ENERGY, translation_key="battery_daily_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6005", - generation=[2], + key=IndevoltBattery.DAILY_DISCHARGING_ENERGY, translation_key="battery_daily_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6006", - generation=[2], + key=IndevoltBattery.TOTAL_CHARGING_ENERGY, translation_key="battery_total_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6007", - generation=[2], + key=IndevoltBattery.TOTAL_DISCHARGING_ENERGY, translation_key="battery_total_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -162,16 +194,16 @@ SENSORS: Final = ( ), # Electricity Meter Status IndevoltSensorEntityDescription( - key="11016", - generation=[2], + key=IndevoltGrid.METER_POWER_GEN2, + generation=(2,), translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="21028", - generation=[1], + key=IndevoltGrid.METER_POWER_GEN1, + generation=(1,), translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -179,8 +211,8 @@ SENSORS: Final = ( ), # Grid information IndevoltSensorEntityDescription( - key="2600", - generation=[2], + key=IndevoltGrid.VOLTAGE, + generation=(2,), translation_key="grid_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -188,8 +220,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="2612", - generation=[2], + key=IndevoltGrid.FREQUENCY, + generation=(2,), translation_key="grid_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -198,42 +230,52 @@ SENSORS: Final = ( ), # Battery Pack Operating Parameters IndevoltSensorEntityDescription( - key="6000", + key=IndevoltBattery.POWER, translation_key="battery_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="6001", + key=IndevoltBattery.CHARGE_DISCHARGE_STATE, translation_key="battery_charge_discharge_state", state_mapping={1000: "static", 1001: "charging", 1002: "discharging"}, device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="6002", + key=IndevoltBattery.SOC, translation_key="battery_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.GEN_2_TRANSFORMER_TEMPERATURE, + generation=(2,), + translation_key="transformer_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), # PV Operating Parameters IndevoltSensorEntityDescription( - key="1501", + key=IndevoltSolar.DC_OUTPUT_POWER, translation_key="dc_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="1502", + key=IndevoltSolar.DAILY_PRODUCTION, translation_key="daily_production", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1505", + key=IndevoltSolar.CUMULATIVE_PRODUCTION, translation_key="cumulative_production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -241,8 +283,7 @@ SENSORS: Final = ( state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1632", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_1, translation_key="dc_input_current_1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -250,8 +291,7 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1600", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_1, translation_key="dc_input_voltage_1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -259,7 +299,7 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1664", + key=IndevoltSolar.DC_INPUT_POWER_1, translation_key="dc_input_power_1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -267,8 +307,7 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1633", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_2, translation_key="dc_input_current_2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -276,8 +315,7 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1601", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_2, translation_key="dc_input_voltage_2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -285,7 +323,7 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1665", + key=IndevoltSolar.DC_INPUT_POWER_2, translation_key="dc_input_power_2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -293,8 +331,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1634", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_3, + generation=(2,), translation_key="dc_input_current_3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -302,8 +340,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1602", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_3, + generation=(2,), translation_key="dc_input_voltage_3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -311,8 +349,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1666", - generation=[2], + key=IndevoltSolar.DC_INPUT_POWER_3, + generation=(2,), translation_key="dc_input_power_3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -320,8 +358,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1635", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_4, + generation=(2,), translation_key="dc_input_current_4", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -329,8 +367,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1603", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_4, + generation=(2,), translation_key="dc_input_voltage_4", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -338,8 +376,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1667", - generation=[2], + key=IndevoltSolar.DC_INPUT_POWER_4, + generation=(2,), translation_key="dc_input_power_4", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -348,51 +386,51 @@ SENSORS: Final = ( ), # Battery Pack Serial Numbers IndevoltSensorEntityDescription( - key="9008", - generation=[2], + key=IndevoltBattery.MAIN_SERIAL_NUMBER, + generation=(2,), translation_key="main_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9032", - generation=[2], + key=IndevoltBattery.PACK_1_SERIAL_NUMBER, + generation=(2,), translation_key="battery_pack_1_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9051", - generation=[2], + key=IndevoltBattery.PACK_2_SERIAL_NUMBER, + generation=(2,), translation_key="battery_pack_2_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9070", - generation=[2], + key=IndevoltBattery.PACK_3_SERIAL_NUMBER, + generation=(2,), translation_key="battery_pack_3_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9165", - generation=[2], + key=IndevoltBattery.PACK_4_SERIAL_NUMBER, + generation=(2,), translation_key="battery_pack_4_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9218", - generation=[2], + key=IndevoltBattery.PACK_5_SERIAL_NUMBER, + generation=(2,), translation_key="battery_pack_5_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), # Battery Pack SOC IndevoltSensorEntityDescription( - key="9000", - generation=[2], + key=IndevoltBattery.MAIN_SOC, + generation=(2,), translation_key="main_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -401,8 +439,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9016", - generation=[2], + key=IndevoltBattery.PACK_1_SOC, + generation=(2,), translation_key="battery_pack_1_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -411,8 +449,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9035", - generation=[2], + key=IndevoltBattery.PACK_2_SOC, + generation=(2,), translation_key="battery_pack_2_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -421,8 +459,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9054", - generation=[2], + key=IndevoltBattery.PACK_3_SOC, + generation=(2,), translation_key="battery_pack_3_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -431,8 +469,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9149", - generation=[2], + key=IndevoltBattery.PACK_4_SOC, + generation=(2,), translation_key="battery_pack_4_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -441,8 +479,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9202", - generation=[2], + key=IndevoltBattery.PACK_5_SOC, + generation=(2,), translation_key="battery_pack_5_soc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -452,9 +490,9 @@ SENSORS: Final = ( ), # Battery Pack Temperature IndevoltSensorEntityDescription( - key="9012", - generation=[2], - translation_key="main_temperature", + key=IndevoltBattery.GEN_1_INVERTER_TEMPERATURE, + generation=(1,), + translation_key="inverter_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -462,8 +500,28 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9030", - generation=[2], + key=IndevoltBattery.GEN_1_MOS_TEMPERATURE_CHARGE, + generation=(1,), + translation_key="mos_temperature_charge", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.GEN_1_MOS_TEMPERATURE_DISCHARGE, + generation=(1,), + translation_key="mos_temperature_discharge", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.GEN_1_PACK_1_TEMPERATURE, + generation=(1,), translation_key="battery_pack_1_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -472,8 +530,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9049", - generation=[2], + key=IndevoltBattery.GEN_1_PACK_2_TEMPERATURE, + generation=(1,), translation_key="battery_pack_2_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -482,8 +540,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9068", - generation=[2], + key=IndevoltBattery.GEN_1_PACK_3_TEMPERATURE, + generation=(1,), translation_key="battery_pack_3_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -492,8 +550,48 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9163", - generation=[2], + key=IndevoltBattery.MAIN_TEMPERATURE, + generation=(2,), + translation_key="main_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_1_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_1_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_2_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_2_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_3_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_3_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_4_TEMPERATURE, + generation=(2,), translation_key="battery_pack_4_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -502,8 +600,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9216", - generation=[2], + key=IndevoltBattery.PACK_5_TEMPERATURE, + generation=(2,), translation_key="battery_pack_5_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -511,10 +609,71 @@ SENSORS: Final = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + # Battery Pack MOS Temperature + IndevoltSensorEntityDescription( + key=IndevoltBattery.MAIN_MOS_TEMPERATURE, + generation=(2,), + translation_key="main_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_1_MOS_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_1_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_2_MOS_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_2_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_3_MOS_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_3_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_4_MOS_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_4_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_5_MOS_TEMPERATURE, + generation=(2,), + translation_key="battery_pack_5_mos_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), # Battery Pack Voltage IndevoltSensorEntityDescription( - key="9004", - generation=[2], + key=IndevoltBattery.MAIN_VOLTAGE, + generation=(2,), translation_key="main_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -523,8 +682,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9020", - generation=[2], + key=IndevoltBattery.PACK_1_VOLTAGE, + generation=(2,), translation_key="battery_pack_1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -533,8 +692,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9039", - generation=[2], + key=IndevoltBattery.PACK_2_VOLTAGE, + generation=(2,), translation_key="battery_pack_2_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -543,8 +702,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9058", - generation=[2], + key=IndevoltBattery.PACK_3_VOLTAGE, + generation=(2,), translation_key="battery_pack_3_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -553,8 +712,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9153", - generation=[2], + key=IndevoltBattery.PACK_4_VOLTAGE, + generation=(2,), translation_key="battery_pack_4_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -563,8 +722,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9206", - generation=[2], + key=IndevoltBattery.PACK_5_VOLTAGE, + generation=(2,), translation_key="battery_pack_5_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -574,8 +733,8 @@ SENSORS: Final = ( ), # Battery Pack Current IndevoltSensorEntityDescription( - key="9013", - generation=[2], + key=IndevoltBattery.MAIN_CURRENT, + generation=(2,), translation_key="main_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -584,8 +743,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19173", - generation=[2], + key=IndevoltBattery.PACK_1_CURRENT, + generation=(2,), translation_key="battery_pack_1_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -594,8 +753,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19174", - generation=[2], + key=IndevoltBattery.PACK_2_CURRENT, + generation=(2,), translation_key="battery_pack_2_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -604,8 +763,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19175", - generation=[2], + key=IndevoltBattery.PACK_3_CURRENT, + generation=(2,), translation_key="battery_pack_3_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -614,8 +773,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19176", - generation=[2], + key=IndevoltBattery.PACK_4_CURRENT, + generation=(2,), translation_key="battery_pack_4_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -624,8 +783,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19177", - generation=[2], + key=IndevoltBattery.PACK_5_CURRENT, + generation=(2,), translation_key="battery_pack_5_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -633,15 +792,104 @@ SENSORS: Final = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + # Battery Pack Cycles + IndevoltSensorEntityDescription( + key=IndevoltBattery.MAIN_CYCLES, + generation=(2,), + translation_key="main_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_1_CYCLES, + generation=(2,), + translation_key="battery_pack_1_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_2_CYCLES, + generation=(2,), + translation_key="battery_pack_2_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_3_CYCLES, + generation=(2,), + translation_key="battery_pack_3_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_4_CYCLES, + generation=(2,), + translation_key="battery_pack_4_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key=IndevoltBattery.PACK_5_CYCLES, + generation=(2,), + translation_key="battery_pack_5_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) -# Sensors per battery pack (SN, SOC, Temperature, Voltage, Current) +# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current, Cycles) BATTERY_PACK_SENSOR_KEYS = [ - ("9032", "9016", "9030", "9020", "19173"), # Battery Pack 1 - ("9051", "9035", "9049", "9039", "19174"), # Battery Pack 2 - ("9070", "9054", "9068", "9058", "19175"), # Battery Pack 3 - ("9165", "9149", "9163", "9153", "19176"), # Battery Pack 4 - ("9218", "9202", "9216", "9206", "19177"), # Battery Pack 5 + ( + IndevoltBattery.PACK_1_SERIAL_NUMBER, + IndevoltBattery.PACK_1_SOC, + IndevoltBattery.PACK_1_TEMPERATURE, + IndevoltBattery.PACK_1_MOS_TEMPERATURE, + IndevoltBattery.PACK_1_VOLTAGE, + IndevoltBattery.PACK_1_CURRENT, + IndevoltBattery.PACK_1_CYCLES, + ), + ( + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_2_MOS_TEMPERATURE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_2_CURRENT, + IndevoltBattery.PACK_2_CYCLES, + ), + ( + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_3_MOS_TEMPERATURE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_3_CURRENT, + IndevoltBattery.PACK_3_CYCLES, + ), + ( + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_4_MOS_TEMPERATURE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_4_CURRENT, + IndevoltBattery.PACK_4_CYCLES, + ), + ( + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.PACK_5_MOS_TEMPERATURE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.PACK_5_CURRENT, + IndevoltBattery.PACK_5_CYCLES, + ), ] @@ -689,6 +937,28 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity): if description.device_class == SensorDeviceClass.ENUM: self._attr_options = sorted(set(description.state_mapping.values())) + @property + def available(self) -> bool: + """Return False for sensors in a non-applicable state.""" + + # Check whether device is not in the required energy mode + if self.entity_description.energy_mode is not None: + energy_mode = self.coordinator.data.get(IndevoltConfig.READ_ENERGY_MODE) + if energy_mode != self.entity_description.energy_mode: + return False + + # Check whether inverter is reporting 0 degrees with heater not active (thus reporting to indicate "idle") + # Pending fix by Indevolt: https://discord.com/channels/1417471269942591571/1510277757689659522 + if self.entity_description.key == IndevoltBattery.GEN_1_INVERTER_TEMPERATURE: + inverter_temp = self.coordinator.data.get( + IndevoltBattery.GEN_1_INVERTER_TEMPERATURE + ) + heating_state = self.coordinator.data.get(IndevoltSystem.HEATING_STATE) + if inverter_temp == 0 and heating_state != 1000: + return False + + return super().available + @property def native_value(self) -> str | int | float | None: """Return the current value of the sensor in its native unit.""" @@ -700,4 +970,4 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity): if self.entity_description.device_class == SensorDeviceClass.ENUM: return self.entity_description.state_mapping.get(raw_value) - return raw_value + return cast(str | int | float, raw_value) diff --git a/homeassistant/components/indevolt/services.py b/homeassistant/components/indevolt/services.py new file mode 100644 index 00000000000..fbcf37d15b8 --- /dev/null +++ b/homeassistant/components/indevolt/services.py @@ -0,0 +1,219 @@ +"""Services for Indevolt integration.""" + +import asyncio +from typing import Final, Never + +from indevolt_api import ( + IndevoltRealtimeAction, + PowerExceedsMaxError, + SocBelowMinimumError, +) +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import IndevoltCoordinator + +RT_ACTION_SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required("device_id"): vol.All( + cv.ensure_list, + [cv.string], + ), + vol.Required("target_soc"): vol.All( + vol.Coerce(int), + vol.Range(min=0, max=100), + ), + vol.Required("power"): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=2400), + ), + } +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Indevolt integration.""" + + async def charge(call: ServiceCall) -> None: + """Handle the service call to start charging.""" + await _async_handle_realtime_action(hass, call, IndevoltRealtimeAction.CHARGE) + + async def discharge(call: ServiceCall) -> None: + """Handle the service call to start discharging.""" + await _async_handle_realtime_action( + hass, call, IndevoltRealtimeAction.DISCHARGE + ) + + hass.services.async_register( + DOMAIN, "charge", charge, schema=RT_ACTION_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, "discharge", discharge, schema=RT_ACTION_SERVICE_SCHEMA + ) + + +async def _async_handle_realtime_action( + hass: HomeAssistant, + call: ServiceCall, + action: IndevoltRealtimeAction, +) -> None: + """Validate and execute a realtime action for one or more coordinators.""" + coordinators = await _async_get_coordinators_from_call(hass, call) + + power: int = call.data["power"] + target_soc: int = call.data["target_soc"] + + _validate_realtime_action(coordinators, action, power, target_soc) + await _execute_realtime_action(coordinators, action, power, target_soc) + + +async def _async_get_coordinators_from_call( + hass: HomeAssistant, + call: ServiceCall, +) -> list[IndevoltCoordinator]: + """Resolve coordinator(s) targeted by a service call.""" + entry_ids = await async_extract_config_entry_ids(call) + + coordinators: list[IndevoltCoordinator] = [ + entry.runtime_data + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in entry_ids + ] + + if not coordinators: + _raise_no_target_entries() + + return coordinators + + +def _validate_realtime_action( + coordinators: list[IndevoltCoordinator], + action: IndevoltRealtimeAction, + power: int, + target_soc: int, +) -> None: + """Validate parameters prior to calling `_execute_realtime_action`.""" + + errors: list[str] = [] + + for coordinator in coordinators: + try: + try: + match action: + case IndevoltRealtimeAction.CHARGE: + coordinator.api.check_charge_limits( + power, target_soc, coordinator.generation + ) + case IndevoltRealtimeAction.DISCHARGE: + coordinator.api.check_discharge_limits( + power, target_soc, coordinator.generation + ) + + except PowerExceedsMaxError as err: + _raise_power_exceeds_max(err.power, err.max_power, err.generation) + + except SocBelowMinimumError as err: + _raise_soc_below_minimum(err.target_soc, err.minimum_soc) + + # Validate target SOC against known emergency SOC (soft limit) + emergency_soc = coordinator.get_emergency_soc() + if target_soc < emergency_soc: + _raise_soc_below_emergency(target_soc, emergency_soc) + + except ServiceValidationError as err: + if len(coordinators) == 1: + raise + + errors.append(f"{coordinator.friendly_name}: {err}") + + if errors: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="multi_device_errors", + translation_placeholders={"errors": "; ".join(errors)}, + ) + + +async def _execute_realtime_action( + coordinators: list[IndevoltCoordinator], + action: IndevoltRealtimeAction, + power: int, + target_soc: int, +) -> None: + """Execute async_execute_realtime_action on all coordinators concurrently.""" + results: list[None | BaseException] = await asyncio.gather( + *( + coordinator.async_realtime_action(action, power, target_soc) + for coordinator in coordinators + ), + return_exceptions=True, + ) + + errors: list[str] = [] + + for coordinator, result in zip(coordinators, results, strict=True): + if isinstance(result, BaseException): + if len(coordinators) == 1 or not isinstance(result, Exception): + raise result + + errors.append(f"{coordinator.friendly_name}: {result}") + + if errors: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="multi_device_errors", + translation_placeholders={"errors": "; ".join(errors)}, + ) + + +def _raise_power_exceeds_max(power: int, max_power: int, generation: int) -> Never: + """Raise a translated validation error for out-of-range power.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="power_exceeds_max", + translation_placeholders={ + "power": str(power), + "max_power": str(max_power), + "generation": str(generation), + }, + ) + + +def _raise_soc_below_minimum(target_soc: int, minimum_soc: int) -> Never: + """Raise a translated validation error. + + Called when SOC is below the device's hard minimum. + """ + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="soc_below_minimum", + translation_placeholders={ + "target": str(target_soc), + "minimum_soc": str(minimum_soc), + }, + ) + + +def _raise_soc_below_emergency(target: int, emergency_soc: int) -> Never: + """Raise a translated validation error for out-of-range SOC.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="soc_below_emergency", + translation_placeholders={ + "target": str(target), + "emergency_soc": str(emergency_soc), + }, + ) + + +def _raise_no_target_entries() -> Never: + """Raise a translated validation error for missing/invalid service targets.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_matching_target_entries", + ) diff --git a/homeassistant/components/indevolt/services.yaml b/homeassistant/components/indevolt/services.yaml new file mode 100644 index 00000000000..786cdbfbc2e --- /dev/null +++ b/homeassistant/components/indevolt/services.yaml @@ -0,0 +1,49 @@ +charge: + fields: + device_id: + required: true + selector: + device: + multiple: true + integration: indevolt + target_soc: + required: true + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + power: + required: true + selector: + number: + min: 1 + max: 2400 + step: 1 + unit_of_measurement: "W" + +discharge: + fields: + device_id: + required: true + selector: + device: + multiple: true + integration: indevolt + target_soc: + required: true + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + power: + required: true + selector: + number: + min: 1 + max: 2400 + step: 1 + unit_of_measurement: "W" diff --git a/homeassistant/components/indevolt/strings.json b/homeassistant/components/indevolt/strings.json index 8b127e3cce6..fcf0fb6e31c 100644 --- a/homeassistant/components/indevolt/strings.json +++ b/homeassistant/components/indevolt/strings.json @@ -31,10 +31,40 @@ }, "description": "Enter the connection details for your Indevolt device.", "title": "Connect to Indevolt device" + }, + "zeroconf_confirm": { + "description": "Do you want to add {model} ({host}) to Home Assistant?", + "title": "Discovered Indevolt {model}" } } }, "entity": { + "binary_sensor": { + "battery_pack_1_electric_heating_state": { + "name": "Battery pack 1 electric heating" + }, + "battery_pack_2_electric_heating_state": { + "name": "Battery pack 2 electric heating" + }, + "battery_pack_3_electric_heating_state": { + "name": "Battery pack 3 electric heating" + }, + "battery_pack_4_electric_heating_state": { + "name": "Battery pack 4 electric heating" + }, + "battery_pack_5_electric_heating_state": { + "name": "Battery pack 5 electric heating" + }, + "electric_heating_state": { + "name": "Electric heating" + }, + "main_electric_heating_state": { + "name": "Main electric heating" + }, + "meter_connected": { + "name": "Meter connected" + } + }, "button": { "stop": { "name": "Enable standby mode" @@ -88,6 +118,12 @@ "battery_pack_1_current": { "name": "Battery pack 1 current" }, + "battery_pack_1_cycles": { + "name": "Battery pack 1 cycle count" + }, + "battery_pack_1_mos_temperature": { + "name": "Battery pack 1 MOS temperature" + }, "battery_pack_1_serial_number": { "name": "Battery pack 1 SN" }, @@ -103,6 +139,12 @@ "battery_pack_2_current": { "name": "Battery pack 2 current" }, + "battery_pack_2_cycles": { + "name": "Battery pack 2 cycle count" + }, + "battery_pack_2_mos_temperature": { + "name": "Battery pack 2 MOS temperature" + }, "battery_pack_2_serial_number": { "name": "Battery pack 2 SN" }, @@ -118,6 +160,12 @@ "battery_pack_3_current": { "name": "Battery pack 3 current" }, + "battery_pack_3_cycles": { + "name": "Battery pack 3 cycle count" + }, + "battery_pack_3_mos_temperature": { + "name": "Battery pack 3 MOS temperature" + }, "battery_pack_3_serial_number": { "name": "Battery pack 3 SN" }, @@ -133,6 +181,12 @@ "battery_pack_4_current": { "name": "Battery pack 4 current" }, + "battery_pack_4_cycles": { + "name": "Battery pack 4 cycle count" + }, + "battery_pack_4_mos_temperature": { + "name": "Battery pack 4 MOS temperature" + }, "battery_pack_4_serial_number": { "name": "Battery pack 4 SN" }, @@ -148,6 +202,12 @@ "battery_pack_5_current": { "name": "Battery pack 5 current" }, + "battery_pack_5_cycles": { + "name": "Battery pack 5 cycle count" + }, + "battery_pack_5_mos_temperature": { + "name": "Battery pack 5 MOS temperature" + }, "battery_pack_5_serial_number": { "name": "Battery pack 5 SN" }, @@ -223,6 +283,9 @@ "dc_output_power": { "name": "DC output power" }, + "discharge_limit": { + "name": "[%key:component::indevolt::entity::number::discharge_limit::name%]" + }, "energy_mode": { "name": "Energy mode", "state": { @@ -232,15 +295,27 @@ "self_consumed_prioritized": "Self-consumed prioritized" } }, + "equivalent_full_cycles": { + "name": "Equivalent full cycles" + }, "grid_frequency": { "name": "Grid frequency" }, "grid_voltage": { "name": "Grid voltage" }, + "inverter_temperature": { + "name": "Inverter temperature" + }, "main_current": { "name": "Main current" }, + "main_cycles": { + "name": "Main cycle count" + }, + "main_mos_temperature": { + "name": "Main MOS temperature" + }, "main_serial_number": { "name": "Main serial number" }, @@ -264,12 +339,32 @@ "sub": "Cluster (sub)" } }, + "mos_temperature_charge": { + "name": "MOS temperature charge" + }, + "mos_temperature_discharge": { + "name": "MOS temperature discharge" + }, "off_grid_output_energy": { "name": "Off-grid output energy" }, "rated_capacity": { "name": "Rated capacity" }, + "realtime_command": { + "name": "Real-time mode", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "standby": "[%key:common::state::standby%]" + } + }, + "realtime_power_limit": { + "name": "Real-time power limit" + }, + "realtime_target_soc": { + "name": "Real-time target SOC" + }, "serial_number": { "name": "Serial number" }, @@ -281,6 +376,9 @@ }, "total_ac_output_energy": { "name": "Total AC output energy" + }, + "transformer_temperature": { + "name": "Transformer temperature" } }, "switch": { @@ -296,6 +394,9 @@ } }, "exceptions": { + "config_entry_not_ready": { + "message": "Device config retrieval failed: {error}" + }, "energy_mode_change_unavailable_outdoor_portable": { "message": "Energy mode cannot be changed when the device is in outdoor/portable mode" }, @@ -307,6 +408,65 @@ }, "failed_to_switch_energy_mode": { "message": "Failed to switch to requested energy mode" + }, + "multi_device_errors": { + "message": "One or more devices reported errors: {errors}" + }, + "no_matching_target_entries": { + "message": "No matching Indevolt devices found in the selected targets" + }, + "power_exceeds_max": { + "message": "Power ({power}W) exceeds maximum ({max_power}W) for generation ({generation}) devices" + }, + "soc_below_emergency": { + "message": "Target SOC ({target}%) is below emergency SOC ({emergency_soc}%)" + }, + "soc_below_minimum": { + "message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)" + }, + "update_failed": { + "message": "Device update failed: {error}" + }, + "write_error": { + "message": "Cannot update value for {name}" + } + }, + "services": { + "charge": { + "description": "Real-time control: Starts charging with configured power until the target SOC is reached.", + "fields": { + "device_id": { + "description": "The Indevolt device(s) to start charging.", + "name": "Device(s)" + }, + "power": { + "description": "Maximum charging power in watts.", + "name": "Max. power" + }, + "target_soc": { + "description": "Target state of charge percentage.", + "name": "Target SOC" + } + }, + "name": "Charge" + }, + "discharge": { + "description": "Real-time control: Starts discharging with configured power until the target SOC is reached.", + "fields": { + "device_id": { + "description": "The Indevolt device(s) to start discharging.", + "name": "[%key:component::indevolt::services::charge::fields::device_id::name%]" + }, + "power": { + "description": "Maximum discharging power in watts.", + "name": "Max. power" + }, + "target_soc": { + "description": "[%key:component::indevolt::services::charge::fields::target_soc::description%]", + "name": "[%key:component::indevolt::services::charge::fields::target_soc::name%]" + } + }, + "name": "Discharge" } } } diff --git a/homeassistant/components/indevolt/switch.py b/homeassistant/components/indevolt/switch.py index c5bab6053ad..e08f22f3609 100644 --- a/homeassistant/components/indevolt/switch.py +++ b/homeassistant/components/indevolt/switch.py @@ -1,10 +1,10 @@ """Switch platform for Indevolt integration.""" -from __future__ import annotations - -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Final +from indevolt_api import IndevoltConfig + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -15,6 +15,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IndevoltConfigEntry +from .const import DOMAIN from .coordinator import IndevoltCoordinator from .entity import IndevoltEntity @@ -29,16 +30,16 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): write_key: str read_on_value: int = 1 read_off_value: int = 0 - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) SWITCHES: Final = ( IndevoltSwitchEntityDescription( key="grid_charging", translation_key="grid_charging", - generation=[2], - read_key="2618", - write_key="1143", + generation=(2,), + read_key=IndevoltConfig.READ_GRID_CHARGING, + write_key=IndevoltConfig.WRITE_GRID_CHARGING, read_on_value=1001, read_off_value=1000, device_class=SwitchDeviceClass.SWITCH, @@ -46,17 +47,17 @@ SWITCHES: Final = ( IndevoltSwitchEntityDescription( key="light", translation_key="light", - generation=[2], - read_key="7171", - write_key="7265", + generation=(2,), + read_key=IndevoltConfig.READ_LIGHT, + write_key=IndevoltConfig.WRITE_LIGHT, device_class=SwitchDeviceClass.SWITCH, ), IndevoltSwitchEntityDescription( key="bypass", translation_key="bypass", - generation=[2], - read_key="680", - write_key="7266", + generation=(2,), + read_key=IndevoltConfig.READ_BYPASS, + write_key=IndevoltConfig.WRITE_BYPASS, device_class=SwitchDeviceClass.SWITCH, ), ) @@ -128,4 +129,8 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity): await self.coordinator.async_request_refresh() else: - raise HomeAssistantError(f"Failed to set value {value} for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_error", + translation_placeholders={"name": str(self.name)}, + ) diff --git a/homeassistant/components/inels/__init__.py b/homeassistant/components/inels/__init__.py index cdfa4e3ed20..54e0b013f7d 100644 --- a/homeassistant/components/inels/__init__.py +++ b/homeassistant/components/inels/__init__.py @@ -1,7 +1,5 @@ """The iNELS integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/inels/config_flow.py b/homeassistant/components/inels/config_flow.py index 73c953ff239..373b291fd1a 100644 --- a/homeassistant/components/inels/config_flow.py +++ b/homeassistant/components/inels/config_flow.py @@ -1,7 +1,5 @@ """Config flow for iNELS.""" -from __future__ import annotations - from typing import Any from homeassistant.components import mqtt diff --git a/homeassistant/components/inels/entity.py b/homeassistant/components/inels/entity.py index 592782ca5b7..cab1ae4d0f6 100644 --- a/homeassistant/components/inels/entity.py +++ b/homeassistant/components/inels/entity.py @@ -1,7 +1,5 @@ """Base class for iNELS components.""" -from __future__ import annotations - from inelsmqtt.devices import Device from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/inels/switch.py b/homeassistant/components/inels/switch.py index 22932e2c629..76a12f92df9 100644 --- a/homeassistant/components/inels/switch.py +++ b/homeassistant/components/inels/switch.py @@ -1,7 +1,5 @@ """iNELS switch entity.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -109,7 +107,10 @@ class InelsSwitch(InelsBaseEntity, SwitchEntity): ) def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None: - """Check if there are active alerts and raise ServiceValidationError if found.""" + """Check for active alerts. + + Raises ServiceValidationError if any are found. + """ if self.entity_description.alerts and any( getattr(current_state, alert_key, None) for alert_key in self.entity_description.alerts diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index a064d5f580e..ab9b9560079 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -1,7 +1,5 @@ """Support for sending data to an Influx database.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass diff --git a/homeassistant/components/influxdb/config_flow.py b/homeassistant/components/influxdb/config_flow.py index 679566e8a8f..6884e31332c 100644 --- a/homeassistant/components/influxdb/config_flow.py +++ b/homeassistant/components/influxdb/config_flow.py @@ -290,7 +290,9 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN): scheme="https" if entry.data.get(CONF_SSL) else "http", host=entry.data.get(CONF_HOST, ""), port=entry.data.get(CONF_PORT), - path=entry.data.get(CONF_PATH, ""), + path="" + if entry.data.get(CONF_PATH) is None + else entry.data[CONF_PATH], ) ) diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index 30319416a61..5a638a3eb6a 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -1,7 +1,5 @@ """InfluxDB component which allows you to get data from an Influx database.""" -from __future__ import annotations - import datetime import logging from typing import Final diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 44adbe154cc..d4fefddedf3 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -1,39 +1,54 @@ """Provides functionality to interact with infrared devices.""" -from __future__ import annotations - -from abc import abstractmethod from datetime import timedelta import logging -from typing import final - -from infrared_protocols import Command as InfraredCommand from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity import EntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util -from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DATA_COMPONENT, DOMAIN +from .entity import ( # noqa: F401 + InfraredCommand, + InfraredDeviceClass, + InfraredEmitterEntity, + InfraredEmitterEntityDescription, + InfraredEntity, + InfraredEntityDescription, + InfraredReceivedSignal, + InfraredReceiverEntity, + InfraredReceiverEntityDescription, +) +from .helpers import ( + InfraredEmitterConsumerEntity, + InfraredReceiverConsumerEntity, + async_send_command, + async_subscribe_receiver, +) __all__ = [ "DOMAIN", + "InfraredCommand", + "InfraredEmitterConsumerEntity", + "InfraredEmitterEntity", + "InfraredEmitterEntityDescription", "InfraredEntity", "InfraredEntityDescription", + "InfraredReceivedSignal", + "InfraredReceiverConsumerEntity", + "InfraredReceiverEntity", + "InfraredReceiverEntityDescription", "async_get_emitters", + "async_get_receivers", "async_send_command", + "async_subscribe_receiver", ] + _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -42,9 +57,9 @@ SCAN_INTERVAL = timedelta(seconds=30) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the infrared domain.""" - component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity]( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL - ) + component = hass.data[DATA_COMPONENT] = EntityComponent[ + InfraredEmitterEntity | InfraredReceiverEntity + ](_LOGGER, DOMAIN, hass, SCAN_INTERVAL) await component.async_setup(config) return True @@ -67,87 +82,22 @@ def async_get_emitters(hass: HomeAssistant) -> list[str]: if component is None: return [] - return [entity.entity_id for entity in component.entities] + return [ + entity.entity_id + for entity in component.entities + if isinstance(entity, InfraredEmitterEntity) + ] -async def async_send_command( - hass: HomeAssistant, - entity_id_or_uuid: str, - command: InfraredCommand, - context: Context | None = None, -) -> None: - """Send an IR command to the specified infrared entity. - - Raises: - HomeAssistantError: If the infrared entity is not found. - """ +@callback +def async_get_receivers(hass: HomeAssistant) -> list[str]: + """Get all infrared receiver entity IDs.""" component = hass.data.get(DATA_COMPONENT) if component is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="component_not_loaded", - ) + return [] - ent_reg = er.async_get(hass) - entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) - entity = component.get_entity(entity_id) - if entity is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="entity_not_found", - translation_placeholders={"entity_id": entity_id}, - ) - - if context is not None: - entity.async_set_context(context) - - await entity.async_send_command_internal(command) - - -class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True): - """Describes infrared entities.""" - - -class InfraredEntity(RestoreEntity): - """Base class for infrared transmitter entities.""" - - entity_description: InfraredEntityDescription - _attr_should_poll = False - _attr_state: None = None - - __last_command_sent: str | None = None - - @property - @final - def state(self) -> str | None: - """Return the entity state.""" - return self.__last_command_sent - - @final - async def async_send_command_internal(self, command: InfraredCommand) -> None: - """Send an IR command and update state. - - Should not be overridden, handles setting last sent timestamp. - """ - await self.async_send_command(command) - self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds") - self.async_write_ha_state() - - @final - async def async_internal_added_to_hass(self) -> None: - """Call when the infrared entity is added to hass.""" - await super().async_internal_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state not in (STATE_UNAVAILABLE, None): - self.__last_command_sent = state.state - - @abstractmethod - async def async_send_command(self, command: InfraredCommand) -> None: - """Send an IR command. - - Args: - command: The IR command to send. - - Raises: - HomeAssistantError: If transmission fails. - """ + return [ + entity.entity_id + for entity in component.entities + if isinstance(entity, InfraredReceiverEntity) + ] diff --git a/homeassistant/components/infrared/const.py b/homeassistant/components/infrared/const.py index 2240607f52a..5dc232bc49b 100644 --- a/homeassistant/components/infrared/const.py +++ b/homeassistant/components/infrared/const.py @@ -2,4 +2,12 @@ from typing import Final +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util.hass_dict import HassKey + +from .entity import InfraredEmitterEntity, InfraredReceiverEntity + DOMAIN: Final = "infrared" +DATA_COMPONENT: HassKey[ + EntityComponent[InfraredEmitterEntity | InfraredReceiverEntity] +] = HassKey(DOMAIN) diff --git a/homeassistant/components/infrared/entity.py b/homeassistant/components/infrared/entity.py new file mode 100644 index 00000000000..42a04606ba4 --- /dev/null +++ b/homeassistant/components/infrared/entity.py @@ -0,0 +1,174 @@ +"""Entity classes for the infrared integration.""" + +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +import logging +from typing import final + +from infrared_protocols.commands import Command as InfraredCommand +from propcache.api import cached_property + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + + +class InfraredDeviceClass(StrEnum): + """Device class for infrared entities.""" + + EMITTER = "emitter" + RECEIVER = "receiver" + + +@dataclass(frozen=True, slots=True) +class InfraredReceivedSignal: + """Represents a received IR signal.""" + + timings: list[int] + modulation: int | None = None + + +class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared emitter entities.""" + + +class InfraredEmitterEntity(RestoreEntity): + """Base class for infrared emitter entities.""" + + entity_description: InfraredEmitterEntityDescription + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER + _attr_should_poll = False + _attr_state: None = None + + __last_command_sent: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_command_sent + + @final + async def async_send_command_internal(self, command: InfraredCommand) -> None: + """Send an IR command and update state. + + Should not be overridden, handles setting last sent timestamp. + """ + await self.async_send_command(command) + self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds") + self.async_write_ha_state() + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the infrared entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__last_command_sent = state.state + + @abstractmethod + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command. + + Args: + command: The IR command to send. + + Raises: + HomeAssistantError: If transmission fails. + """ + + +class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared receiver entities.""" + + +class InfraredReceiverEntity(RestoreEntity): + """Base class for infrared receiver entities.""" + + entity_description: InfraredReceiverEntityDescription + _attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER + _attr_should_poll = False + _attr_state: None = None + + __last_signal_received: str | None = None + + @cached_property + def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: + """Subscriber callback set, lazily initialized on first access.""" + return set() + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_signal_received + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the infrared entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + None, + ): + self.__last_signal_received = state.state + + @final + def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal. + + Should not be overridden. To be called by platform implementations when a + signal is received. + """ + self.__last_signal_received = dt_util.utcnow().isoformat( + timespec="milliseconds" + ) + self.async_write_ha_state() + for signal_callback in tuple(self.__signal_callbacks): + try: + signal_callback(signal) + except Exception: + _LOGGER.exception("Error in signal callback for %s", self.entity_id) + + @callback + def async_subscribe_received_signal( + self, + signal_callback: Callable[[InfraredReceivedSignal], None], + ) -> CALLBACK_TYPE: + """Subscribe to received IR signals. + + Returns a callable to unsubscribe. + """ + callbacks = self.__signal_callbacks + callbacks.add(signal_callback) + + @callback + def remove_callback() -> None: + callbacks.discard(signal_callback) + + return remove_callback + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntityDescription", + breaks_in_ha_version="2027.6", +) +class InfraredEntityDescription(InfraredEmitterEntityDescription): + """Deprecated alias for InfraredEmitterEntityDescription.""" + + +@deprecated_class( + "homeassistant.components.infrared.InfraredEmitterEntity", + breaks_in_ha_version="2027.6", +) +class InfraredEntity(InfraredEmitterEntity): + """Deprecated alias for InfraredEmitterEntity.""" diff --git a/homeassistant/components/infrared/helpers.py b/homeassistant/components/infrared/helpers.py new file mode 100644 index 00000000000..7d658d184cd --- /dev/null +++ b/homeassistant/components/infrared/helpers.py @@ -0,0 +1,227 @@ +"""Helper base entities for integrations that consume infrared emitters/receivers.""" + +from abc import abstractmethod +from collections.abc import Callable +import logging +from typing import override + +from infrared_protocols.commands import Command as InfraredCommand +import voluptuous as vol + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import DATA_COMPONENT, DOMAIN +from .entity import ( + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: InfraredCommand, + context: Context | None = None, +) -> None: + """Send an IR command to the specified infrared entity. + + Raises: + HomeAssistantError: If the infrared entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None or not isinstance(entity, InfraredEmitterEntity): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +@callback +def async_subscribe_receiver( + hass: HomeAssistant, + entity_id_or_uuid: str, + signal_callback: Callable[[InfraredReceivedSignal], None], +) -> CALLBACK_TYPE: + """Subscribe to IR signals from a specific receiver entity. + + Raises: + HomeAssistantError: If the receiver entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + try: + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + except vol.Invalid as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="receiver_not_found", + translation_placeholders={"entity_id": entity_id_or_uuid}, + ) from err + + entity = component.get_entity(entity_id) + if entity is None or not isinstance(entity, InfraredReceiverEntity): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="receiver_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + return entity.async_subscribe_received_signal(signal_callback) + + +class InfraredConsumerEntity(Entity): + """Base class for entities that track the availability of an infrared entity.""" + + @callback + def _async_track_availability(self, infrared_entity_id: str) -> CALLBACK_TYPE: + """Track the availability of an infrared entity. + + Sets initial availability and subscribes to state changes. + Returns an unsubscribe callback. + """ + + @callback + def state_changed(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"] + ir_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if ir_available != self.available: + _LOGGER.info( + "Infrared entity %s used by %s is %s", + infrared_entity_id, + self.entity_id, + "available" if ir_available else "unavailable", + ) + self._async_infrared_availability_changed(ir_available) + + ir_state = self.hass.states.get(infrared_entity_id) + self._attr_available = ( + ir_state is not None and ir_state.state != STATE_UNAVAILABLE + ) + + return async_track_state_change_event( + self.hass, [infrared_entity_id], state_changed + ) + + @callback + def _async_infrared_availability_changed(self, available: bool) -> None: + """Update availability. Override to react to changes.""" + self._attr_available = available + self.async_write_ha_state() + + +class InfraredEmitterConsumerEntity(InfraredConsumerEntity): + """Base entity for integrations that send commands via an infrared emitter. + + Tracks the availability of the underlying infrared emitter entity. + """ + + _attr_should_poll = False + _infrared_emitter_entity_id: str + + async def async_added_to_hass(self) -> None: + """Subscribe to infrared entity state changes.""" + await super().async_added_to_hass() + self.async_on_remove( + self._async_track_availability(self._infrared_emitter_entity_id) + ) + + async def _send_command(self, command: InfraredCommand) -> None: + """Send an IR command through the infrared emitter entity.""" + await async_send_command( + self.hass, self._infrared_emitter_entity_id, command, context=self._context + ) + + +class InfraredReceiverConsumerEntity(InfraredConsumerEntity): + """Base entity for integrations that consume signals from an infrared receiver. + + Tracks the availability of the underlying infrared receiver entity and + manages the subscription to received IR signals. + """ + + _attr_should_poll = False + _infrared_receiver_entity_id: str + _remove_signal_subscription: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Subscribe to infrared entity state changes and receiver signals.""" + await super().async_added_to_hass() + self.async_on_remove( + self._async_track_availability(self._infrared_receiver_entity_id) + ) + + self._async_update_receiver_subscription() + self.async_on_remove(self._async_unsubscribe_receiver) + + @override + @callback + def _async_infrared_availability_changed(self, available: bool) -> None: + """Update availability and manage receiver subscription.""" + super()._async_infrared_availability_changed(available) + self._async_update_receiver_subscription() + + @callback + @abstractmethod + def _handle_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal.""" + + @callback + def _async_unsubscribe_receiver(self) -> None: + """Unsubscribe from the current IR receiver.""" + if self._remove_signal_subscription is None: + return + self._remove_signal_subscription() + self._remove_signal_subscription = None + + @callback + def _async_update_receiver_subscription(self) -> None: + """Update the IR receiver subscription when availability changes.""" + if not self.available: + self._async_unsubscribe_receiver() + elif self._remove_signal_subscription is None: + _LOGGER.debug( + "Subscribing to infrared receiver entity %s for %s", + self._infrared_receiver_entity_id, + self.entity_id, + ) + self._remove_signal_subscription = async_subscribe_receiver( + self.hass, self._infrared_receiver_entity_id, self._handle_signal + ) diff --git a/homeassistant/components/infrared/icons.json b/homeassistant/components/infrared/icons.json index 3a12eb7d0b5..7d3693b3573 100644 --- a/homeassistant/components/infrared/icons.json +++ b/homeassistant/components/infrared/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:led-on" + }, + "receiver": { + "default": "mdi:led-off" } } } diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index d81f5ecffa7..e58ea9bada1 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==1.1.0"] + "requirements": ["infrared-protocols==6.0.0"] } diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index c4cf75cf1f3..09d705d53cc 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -1,10 +1,21 @@ { + "entity_component": { + "_": { + "name": "Infrared emitter" + }, + "receiver": { + "name": "Infrared receiver" + } + }, "exceptions": { "component_not_loaded": { "message": "Infrared component not loaded" }, "entity_not_found": { "message": "Infrared entity `{entity_id}` not found" + }, + "receiver_not_found": { + "message": "Infrared receiver entity `{entity_id}` not found" } } } diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 3df99e55aec..fb42ee051ea 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -1,7 +1,5 @@ """The INKBIRD Bluetooth integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 9ce20baaeda..57a7604d91e 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -1,12 +1,11 @@ """Config flow for inkbird ble integration.""" -from __future__ import annotations - from typing import Any from inkbird_ble import INKBIRDBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -76,6 +75,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): title=title, data={CONF_DEVICE_TYPE: device_type} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index fbacedf7e0f..5640af75a7e 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -1,7 +1,5 @@ """The INKBIRD Bluetooth integration.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import Any @@ -9,9 +7,11 @@ from typing import Any from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, + async_address_reachability_diagnostics, async_ble_device_from_address, async_last_service_info, ) @@ -29,6 +29,11 @@ _LOGGER = logging.getLogger(__name__) FALLBACK_POLL_INTERVAL = timedelta(seconds=180) +# IBS-TH2 broadcasts every ~20-30s and only carries sensor data in the scan +# response, so the default 10s active window misses the device most cycles. +# 25s covers one full broadcast interval with margin to absorb jitter. +ACTIVE_SCAN_DURATION = 25.0 + class INKBIRDActiveBluetoothProcessorCoordinator( ActiveBluetoothProcessorCoordinator[SensorUpdate] @@ -59,6 +64,7 @@ class INKBIRDActiveBluetoothProcessorCoordinator( needs_poll_method=self._async_needs_poll, poll_method=self._async_poll_data, connectable=False, # Polling only happens if active scanning is disabled + scan_duration=ACTIVE_SCAN_DURATION, ) async def async_init(self) -> None: @@ -80,7 +86,14 @@ class INKBIRDActiveBluetoothProcessorCoordinator( raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="no_advertisement", - translation_placeholders={"address": self.address}, + translation_placeholders={ + "address": self.address, + "reason": async_address_reachability_diagnostics( + self.hass, + self.address.upper(), + BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT, + ), + }, ) await self._data.async_start(service_info, service_info.device) self._entry.async_on_unload(self._data.async_stop) @@ -130,7 +143,18 @@ class INKBIRDActiveBluetoothProcessorCoordinator( @callback def _async_schedule_poll(self, _: datetime) -> None: """Schedule a poll of the device.""" - if self._last_service_info and self._async_needs_poll( - self._last_service_info, self._last_poll - ): + # ``self._last_service_info`` only tracks dispatched events, so when + # the device keeps broadcasting the same payload (HA dedupes the + # repeats before dispatch) its timestamp stops advancing. Pull the + # latest service info from the bluetooth manager instead so the + # recency check in ``poll_needed`` sees every observed advertisement. + service_info = ( + async_last_service_info(self.hass, self.address, connectable=False) + or self._last_service_info + ) + if service_info and self.needs_poll(service_info): + # Seed ``_last_service_info`` so the debounced poll has a service + # info to hand to ``_async_poll_data``; the base ``_async_poll`` + # asserts on it. + self._last_service_info = service_info self._debounced_poll.async_schedule_call() diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1d2ce58ec47..890be3e0329 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -63,5 +63,5 @@ "documentation": "https://www.home-assistant.io/integrations/inkbird", "integration_type": "device", "iot_class": "local_push", - "requirements": ["inkbird-ble==1.1.1"] + "requirements": ["inkbird-ble==1.4.4"] } diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index c7d80e9bc9f..064fb1bfda3 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -1,7 +1,5 @@ """Support for inkbird ble sensors.""" -from __future__ import annotations - from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units from homeassistant.components.bluetooth.passive_update_processor import ( @@ -119,7 +117,9 @@ async def async_setup_entry( INKBIRDBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(entry.runtime_data.async_register_processor(processor)) + entry.async_on_unload( + entry.runtime_data.async_register_processor(processor, SensorEntityDescription) + ) class INKBIRDBluetoothSensorEntity( diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 46cc7ae374c..e2f1afb0be8 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -20,7 +20,7 @@ }, "exceptions": { "no_advertisement": { - "message": "The device with address {address} is not advertising; Make sure it is in range and powered on." + "message": "The device with address {address} is not advertising: {reason}" } } } diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 5fd50084895..873c8139cf1 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -1,7 +1,5 @@ """Support to keep track of user controlled booleans for within automation.""" -from __future__ import annotations - import logging from typing import Any, Self @@ -26,7 +24,6 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.loader import bind_hass DOMAIN = "input_boolean" @@ -81,7 +78,6 @@ class InputBooleanStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Test if input_boolean is True.""" return hass.states.is_state(entity_id, STATE_ON) diff --git a/homeassistant/components/input_boolean/reproduce_state.py b/homeassistant/components/input_boolean/reproduce_state.py index 7af28f8a92a..7dfd70f4246 100644 --- a/homeassistant/components/input_boolean/reproduce_state.py +++ b/homeassistant/components/input_boolean/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an input boolean state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 6bf7dc9d6bf..9802e396d77 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -1,7 +1,5 @@ """Support to keep track of user controlled buttons which can be used in automations.""" -from __future__ import annotations - import logging from typing import Self, cast @@ -125,7 +123,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class InputButton(collection.CollectionEntity, ButtonEntity, RestoreEntity): """Representation of a button.""" diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index fb739490233..60f0f0777d3 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -1,7 +1,5 @@ """Support to select a date and/or a time.""" -from __future__ import annotations - import datetime as py_datetime import logging from typing import Any, Self @@ -100,7 +98,7 @@ def parse_initial_datetime(conf: dict[str, Any]) -> py_datetime.datetime: raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a date") if (time := dt_util.parse_time(initial)) is not None: - return py_datetime.datetime.combine(py_datetime.date.today(), time) + return py_datetime.datetime.combine(dt_util.now().date(), time) raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a time") @@ -263,7 +261,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): if self.state is not None: return - default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00") + default_value = dt_util.now().strftime(f"{FMT_DATE} 00:00:00") # Priority 2: Old state if (old_state := await self.async_get_last_state()) is None: @@ -286,9 +284,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): elif (time := dt_util.parse_time(old_state.state)) is None: current_datetime = dt_util.parse_datetime(default_value) else: - current_datetime = py_datetime.datetime.combine( - py_datetime.date.today(), time - ) + current_datetime = py_datetime.datetime.combine(dt_util.now().date(), time) self._current_datetime = current_datetime.replace( tzinfo=dt_util.get_default_time_zone() diff --git a/homeassistant/components/input_datetime/reproduce_state.py b/homeassistant/components/input_datetime/reproduce_state.py index ccadbccd8d4..ea087f7d836 100644 --- a/homeassistant/components/input_datetime/reproduce_state.py +++ b/homeassistant/components/input_datetime/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Input datetime state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 81d1479be03..9cc1743d1cc 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -1,7 +1,5 @@ """Support to set a numeric value from a slider or text box.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any, Self diff --git a/homeassistant/components/input_number/reproduce_state.py b/homeassistant/components/input_number/reproduce_state.py index c2f9cfc4702..f82490d2555 100644 --- a/homeassistant/components/input_number/reproduce_state.py +++ b/homeassistant/components/input_number/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Input number state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index b05509ea09e..74bf041b95e 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -1,7 +1,5 @@ """Support to select an option from a list.""" -from __future__ import annotations - import logging from typing import Any, Self, cast @@ -39,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "input_select" CONF_INITIAL = "initial" +# pylint: disable-next=home-assistant-duplicate-const CONF_OPTIONS = "options" SERVICE_SET_OPTIONS = "set_options" @@ -243,7 +242,7 @@ class InputSelectStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" @@ -299,7 +298,8 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Select new option.""" if option not in self.options: raise HomeAssistantError( - f"Invalid option: {option} (possible options: {', '.join(self.options)})" + f"Invalid option: {option} (possible options:" + f" {', '.join(self.options)})" ) self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/input_select/reproduce_state.py b/homeassistant/components/input_select/reproduce_state.py index b451f8c3f09..f3781d70cd4 100644 --- a/homeassistant/components/input_select/reproduce_state.py +++ b/homeassistant/components/input_select/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Input select state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable, Mapping import logging @@ -49,7 +47,8 @@ async def _async_reproduce_state( DOMAIN, service, service_data, context=context, blocking=True ) - # Remove ATTR_OPTIONS from service_data so we can reuse service_data in next call + # Remove ATTR_OPTIONS from service_data so we can + # reuse service_data in next call del service_data[ATTR_OPTIONS] # Call SERVICE_SELECT_OPTION diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 9945f1dcc3a..86d447f97c6 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -1,7 +1,5 @@ """Support to enter a value into a text box.""" -from __future__ import annotations - import logging from typing import Any, Self diff --git a/homeassistant/components/input_text/reproduce_state.py b/homeassistant/components/input_text/reproduce_state.py index 78e81dba95a..774009a9f1d 100644 --- a/homeassistant/components/input_text/reproduce_state.py +++ b/homeassistant/components/input_text/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Input text state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 1a1306c2a2f..18f4c808345 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -6,7 +6,7 @@ import logging from pyinsteon import async_close, async_connect, devices from pyinsteon.constants import ReadWriteMode -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -33,7 +33,6 @@ from .utils import ( ) _LOGGER = logging.getLogger(__name__) -OPTIONS = "options" async def async_get_device_config(hass, config_entry): @@ -77,12 +76,10 @@ async def close_insteon_connection(*args): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Insteon entry.""" - if dev_path := entry.options.get(CONF_DEV_PATH): - hass.data[DOMAIN] = {} - hass.data[DOMAIN][CONF_DEV_PATH] = dev_path - api.async_load_api(hass) - await api.async_register_insteon_frontend(hass) + await api.async_register_insteon_frontend( + hass, entry.options.get(CONF_DEV_PATH) or None + ) if not devices.modem: try: @@ -99,19 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: workdir=hass.config.config_dir, id_devices=0, load_modem_aldb=0 ) - # If options existed in YAML and have not already been saved to the config entry - # add them now - if ( - not entry.options - and entry.source == SOURCE_IMPORT - and hass.data.get(DOMAIN) - and hass.data[DOMAIN].get(OPTIONS) - ): - hass.config_entries.async_update_entry( - entry=entry, - options=hass.data[DOMAIN][OPTIONS], - ) - for device_override in entry.options.get(CONF_OVERRIDE, []): # Override the device default capabilities for a specific address address = device_override.get("address") @@ -134,6 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) device = devices.add_x10_device(housecode, unitcode, x10_type, steps) + create_insteon_device(hass, devices.modem, entry.entry_id) + await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS) for address in devices: @@ -147,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: register_new_device_callback(hass) async_setup_services(hass) - create_insteon_device(hass, devices.modem, entry.entry_id) - entry.async_create_background_task( hass, async_get_device_config(hass, entry), "insteon-get-device-config" ) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index 9e5287b041d..aabeb2a3d5f 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -3,10 +3,11 @@ from insteon_frontend import get_build_id, locate_dir from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.frontend import async_panel_exists from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback -from ..const import CONF_DEV_PATH, DOMAIN +from ..const import DOMAIN from .aldb import ( websocket_add_default_links, websocket_change_aldb_record, @@ -90,11 +91,12 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_get_unknown_devices) -async def async_register_insteon_frontend(hass: HomeAssistant): +async def async_register_insteon_frontend( + hass: HomeAssistant, dev_path: str | None = None +) -> None: """Register the Insteon frontend configuration panel.""" # Add to sidepanel if needed - if DOMAIN not in hass.data.get("frontend_panels", {}): - dev_path = hass.data.get(DOMAIN, {}).get(CONF_DEV_PATH) + if not async_panel_exists(hass, DOMAIN): is_dev = dev_path is not None path = dev_path or locate_dir() build_id = get_build_id(is_dev) diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py index 47eb5137ce5..70a827d64cf 100644 --- a/homeassistant/components/insteon/api/config.py +++ b/homeassistant/components/insteon/api/config.py @@ -1,7 +1,5 @@ """API calls to manage Insteon configuration changes.""" -from __future__ import annotations - from typing import Any, TypedDict from pyinsteon import async_close, async_connect, devices diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index e26d30d5cdd..e17f13effa3 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -1,7 +1,5 @@ """Support for Insteon thermostat.""" -from __future__ import annotations - from typing import Any from pyinsteon.config import CELSIUS diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 54756397211..c4dd65a5f38 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -1,7 +1,5 @@ """Test config flow for Insteon.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 679ee67a1de..869baa27038 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -59,7 +59,7 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): pos = self._insteon_device_group.value else: pos = 0 - return int(math.ceil(pos * 100 / 255)) + return math.ceil(pos * 100 / 255) @property def is_closed(self) -> bool: diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index f4e0abf3d54..bce87aa4f87 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -1,7 +1,5 @@ """Support for INSTEON fans via PowerLinc Modem.""" -from __future__ import annotations - import math from typing import Any diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index b1398326de4..1face9fdfbb 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -1,10 +1,10 @@ { "domain": "insteon", "name": "Insteon", - "after_dependencies": ["panel_custom", "usb"], - "codeowners": ["@teharris1"], + "after_dependencies": ["panel_custom"], + "codeowners": ["@teharris1", "@ssyrell"], "config_flow": true, - "dependencies": ["http", "websocket_api"], + "dependencies": ["http", "usb", "websocket_api"], "dhcp": [ { "macaddress": "000EF3*" @@ -19,7 +19,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.6.4", - "insteon-frontend-home-assistant==0.6.1" + "insteon-frontend-home-assistant==0.6.2" ], "single_config_entry": true, "usb": [ diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 70458dc5d6f..13cb9039cd6 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -1,7 +1,5 @@ """Schemas used by insteon component.""" -from __future__ import annotations - from pyinsteon.constants import HC_LOOKUP import voluptuous as vol diff --git a/homeassistant/components/insteon/services.py b/homeassistant/components/insteon/services.py index eb671a720ad..448a268e607 100644 --- a/homeassistant/components/insteon/services.py +++ b/homeassistant/components/insteon/services.py @@ -1,7 +1,5 @@ """Utilities used by insteon component.""" -from __future__ import annotations - import asyncio import logging @@ -35,6 +33,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, dispatcher_send, ) +from homeassistant.helpers.service import async_register_admin_service from .const import ( CONF_CAT, @@ -231,11 +230,19 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 ) await async_srv_save_devices() - hass.services.async_register( - DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SRV_ADD_ALL_LINK, + async_srv_add_all_link, + schema=ADD_ALL_LINK_SCHEMA, ) - hass.services.async_register( - DOMAIN, SRV_DEL_ALL_LINK, async_srv_del_all_link, schema=DEL_ALL_LINK_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SRV_DEL_ALL_LINK, + async_srv_del_all_link, + schema=DEL_ALL_LINK_SCHEMA, ) hass.services.async_register( DOMAIN, SRV_LOAD_ALDB, async_srv_load_aldb, schema=LOAD_ALDB_SCHEMA @@ -269,7 +276,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 DOMAIN, SRV_SCENE_OFF, async_srv_scene_off, schema=TRIGGER_SCENE_SCHEMA ) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SRV_ADD_DEFAULT_LINKS, async_add_default_links, diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 5f48306754e..ffda09b4ce9 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -1,7 +1,5 @@ """Utilities used by insteon component.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -11,7 +9,6 @@ from pyinsteon.address import Address from pyinsteon.constants import ALDBStatus, DeviceAction from pyinsteon.device_types.device_base import Device from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event -from serial.tools import list_ports from homeassistant.components import usb from homeassistant.const import CONF_ADDRESS, Platform @@ -172,35 +169,22 @@ def async_add_insteon_devices( ) -def get_usb_ports() -> dict[str, str]: +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = list_ports.comports() port_descriptions = {} - for port in ports: - vid: str | None = None - pid: str | None = None - if port.vid is not None and port.pid is not None: - usb_device = usb.usb_device_from_port(port) - vid = usb_device.vid - pid = usb_device.pid - dev_path = usb.get_serial_by_id(port.device) + for port in await usb.async_scan_serial_ports(hass): human_name = usb.human_readable_device_name( - dev_path, + port.device, port.serial_number, port.manufacturer, port.description, - vid, - pid, + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name return port_descriptions -async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: - """Return a dict of USB ports and their friendly names.""" - return await hass.async_add_executor_job(get_usb_ports) - - def compute_device_name(ha_device) -> str: """Return the HA device name.""" return ha_device.name_by_user or ha_device.name diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index d45ac8f3708..1a4fab2a6fa 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -1,7 +1,5 @@ """The Integration integration.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 370de8b8011..7632d6f5202 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Integration - Riemann sum integral integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 4011caaa649..8f7f79fb257 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -1,7 +1,5 @@ """Numeric integration of data coming from a source sensor over time.""" -from __future__ import annotations - from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -246,7 +244,8 @@ async def async_setup_entry( ) if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": - # Before we had support for optional selectors, "none" was used for selecting nothing + # Before we had support for optional selectors, + # "none" was used for selecting nothing unit_prefix = None if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): @@ -340,6 +339,7 @@ class IntegrationSensor(RestoreSensor): else max_sub_interval ) self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None + # pylint: disable-next=home-assistant-enforce-utcnow self._last_integration_time: datetime = datetime.now(tz=UTC) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._attr_suggested_display_precision = round_digits or 2 @@ -392,7 +392,9 @@ class IntegrationSensor(RestoreSensor): source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement ) if self._attr_device_class: - self._attr_icon = None # Remove this sensors icon default and allow to fallback to the device class default + # Remove this sensors icon default and allow + # to fallback to the device class default + self._attr_icon = None else: self._attr_icon = "mdi:chart-histogram" @@ -497,6 +499,7 @@ class IntegrationSensor(RestoreSensor): old_timestamp, new_timestamp, old_state, new_state ) self._last_integration_trigger = _IntegrationTrigger.StateEvent + # pylint: disable-next=home-assistant-enforce-utcnow self._last_integration_time = datetime.now(tz=UTC) finally: # When max_sub_interval exceeds without state change the source is assumed @@ -566,7 +569,7 @@ class IntegrationSensor(RestoreSensor): assert old_timestamp is not None elapsed_seconds = Decimal( (new_timestamp - old_timestamp).total_seconds() - if self._last_integration_trigger == _IntegrationTrigger.StateEvent + if self._last_integration_trigger is _IntegrationTrigger.StateEvent else (new_timestamp - self._last_integration_time).total_seconds() ) @@ -605,6 +608,7 @@ class IntegrationSensor(RestoreSensor): self._update_integral(area) self.async_write_ha_state() + # pylint: disable-next=home-assistant-enforce-utcnow self._last_integration_time = datetime.now(tz=UTC) self._last_integration_trigger = _IntegrationTrigger.TimeElapsed diff --git a/homeassistant/components/intelliclima/fan.py b/homeassistant/components/intelliclima/fan.py index 28b64e1d768..7281eab7b18 100644 --- a/homeassistant/components/intelliclima/fan.py +++ b/homeassistant/components/intelliclima/fan.py @@ -107,8 +107,8 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity): ) -> None: """Turn on the fan. - Defaults back to 25% if percentage argument is 0 to prevent loop of turning off/on - infinitely. + Defaults back to 25% if percentage argument is 0 + to prevent loop of turning off/on infinitely. """ percentage = 25 if percentage == 0 else percentage await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage) diff --git a/homeassistant/components/intelliclima/quality_scale.yaml b/homeassistant/components/intelliclima/quality_scale.yaml index e66578de063..a9ce60b33df 100644 --- a/homeassistant/components/intelliclima/quality_scale.yaml +++ b/homeassistant/components/intelliclima/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: done diff --git a/homeassistant/components/intelliclima/select.py b/homeassistant/components/intelliclima/select.py index 02e865088fa..bd758243c8b 100644 --- a/homeassistant/components/intelliclima/select.py +++ b/homeassistant/components/intelliclima/select.py @@ -66,7 +66,8 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity): if device_data.mode_set == FanMode.off: return None - # If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode) + # If in auto mode (sensor mode with auto speed), + # return None (handled by fan entity preset mode) if ( device_data.speed_set == FanSpeed.auto_get and device_data.mode_set == FanMode.sensor diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 8a325152120..8bc8f9921ae 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -1,9 +1,8 @@ """The IntelliFire integration.""" -from __future__ import annotations - import asyncio +import aiohttp from intellifire4py import UnifiedFireplace from intellifire4py.cloud_interface import IntelliFireCloudInterface from intellifire4py.const import IntelliFireApiMode @@ -143,7 +142,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) try: fireplace: UnifiedFireplace = ( await UnifiedFireplace.build_fireplace_from_common( - _construct_common_data(entry) + _construct_common_data(entry), + polling_enabled=False, ) ) LOGGER.debug("Waiting for Fireplace to Initialize") @@ -154,6 +154,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) raise ConfigEntryNotReady( "Initialization of fireplace timed out after 10 minutes" ) from err + except (aiohttp.ClientConnectionError, ConnectionError) as err: + raise ConfigEntryNotReady( + "Error communicating with fireplace during initialization" + ) from err # Construct coordinator data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace) @@ -188,11 +192,11 @@ async def async_update_options( current_control_mode = fireplace.control_mode # Only update modes that actually changed - if new_read_mode != current_read_mode: + if new_read_mode is not current_read_mode: LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode) await fireplace.set_read_mode(new_read_mode) - if new_control_mode != current_control_mode: + if new_control_mode is not current_control_mode: LOGGER.debug( "Updating control mode: %s -> %s", current_control_mode, new_control_mode ) diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index 7cc22290e3c..2d9424ccebf 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -1,7 +1,5 @@ """Support for IntelliFire Binary Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 0af438a7374..8cfef426c5e 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -1,7 +1,5 @@ """Intellifire Climate Entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( @@ -63,7 +61,10 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): coordinator: IntellifireDataUpdateCoordinator, description: ClimateEntityDescription, ) -> None: - """Configure climate entry - and override last_temp if the thermostat is currently on.""" + """Configure climate entry. + + Override last_temp if the thermostat is currently on. + """ super().__init__(coordinator, description) if coordinator.data.thermostat_on: diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index e58a5e46559..6fe413ff609 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -1,7 +1,5 @@ """Config flow for IntelliFire integration.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -15,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -71,7 +70,8 @@ async def _async_poll_local_fireplace_for_serial( LOGGER.debug("Found a fireplace: %s", serial) - # Return the serial number which will be used to calculate a unique ID for the device/sensors + # Return the serial number which will be used to + # calculate a unique ID for the device/sensors return serial @@ -114,7 +114,11 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Authenticate against IFTAPI Cloud in order to see configured devices. - Local control of IntelliFire devices requires that the user download the correct API KEY which is only available on the cloud. Cloud control of the devices requires the user has at least once authenticated against the cloud and a set of cookie variables have been stored locally. + Local control of IntelliFire devices requires that the + user download the correct API KEY which is only available + on the cloud. Cloud control of the devices requires the + user has at least once authenticated against the cloud + and a set of cookie variables have been stored locally. """ errors: dict[str, str] = {} @@ -149,7 +153,8 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Step to select a device from the cloud. - We can only get here if we have logged in. If there is only one device available it will be auto-configured, + We can only get here if we have logged in. If there is + only one device available it will be auto-configured, else the user will be given a choice to pick a device. """ errors: dict[str, str] = {} @@ -209,7 +214,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_create_config_entry_from_common_data( self, fireplace: IntelliFireCommonFireplaceData ) -> ConfigFlowResult: - """Construct a config entry based on an object of IntelliFireCommonFireplaceData.""" + """Construct a config entry from IntelliFireCommonFireplaceData.""" data = { CONF_IP_ADDRESS: fireplace.ip_address, @@ -289,10 +294,8 @@ class IntelliFireOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} if user_input is not None: - # Validate connectivity for requested modes if runtime data is available - coordinator = self.config_entry.runtime_data - if coordinator is not None: - fireplace = coordinator.fireplace + if self.config_entry.state is ConfigEntryState.LOADED: + fireplace = self.config_entry.runtime_data.fireplace # Refresh connectivity status before validating await fireplace.async_validate_connectivity() diff --git a/homeassistant/components/intellifire/const.py b/homeassistant/components/intellifire/const.py index 051bb01f9d4..404c5d2c0a6 100644 --- a/homeassistant/components/intellifire/const.py +++ b/homeassistant/components/intellifire/const.py @@ -1,7 +1,5 @@ """Constants for the IntelliFire integration.""" -from __future__ import annotations - import logging DOMAIN = "intellifire" diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index dc9aa45d58b..1473d28ae8f 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -1,9 +1,8 @@ """The IntelliFire integration.""" -from __future__ import annotations - from datetime import timedelta +import aiohttp from intellifire4py import UnifiedFireplace from intellifire4py.control import IntelliFireController from intellifire4py.model import IntelliFirePollData @@ -11,8 +10,9 @@ from intellifire4py.read import IntelliFireDataProvider from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -52,6 +52,14 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData return self.fireplace.control_api async def _async_update_data(self) -> IntelliFirePollData: + try: + await self.fireplace.perform_poll() + except aiohttp.ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed("Authentication failed") from err + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err + except (aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err return self.fireplace.data @property diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 571c4717ac2..4dd1009db2e 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -1,7 +1,5 @@ """Platform for shared base classes for sensors.""" -from __future__ import annotations - from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 3075a5fb2a8..f002a510177 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -1,7 +1,5 @@ """Fan definition for Intellifire.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import math diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index a40441d640d..f3a6dfbef96 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -1,7 +1,5 @@ """The IntelliFire Light.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 68097d30b44..a4a219618e8 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -1,7 +1,5 @@ """Flame height number sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.number import ( diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 6b96f138eef..ba9398473ad 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index a6ab89d6bd7..11bd478cb2d 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -1,7 +1,5 @@ """Define switch func.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 690fccbf29f..84667e8f757 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,7 +1,5 @@ """The Intent integration.""" -from __future__ import annotations - from collections.abc import Collection import logging from typing import Any, Protocol @@ -112,7 +110,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_ON, HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, - description="Turns on/opens/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.", + description=( + "Turns on/opens/presses a device or entity." + " For locks, this performs a 'lock' action." + " Use for requests like 'turn on'," + " 'activate', 'enable', or 'lock'." + ), device_classes=ONOFF_DEVICE_CLASSES, ), ) @@ -122,7 +125,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_OFF, HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, - description="Turns off/closes a device or entity. For locks, this performs an 'unlock' action. Use for requests like 'turn off', 'deactivate', 'disable', or 'unlock'.", + description=( + "Turns off/closes a device or entity." + " For locks, this performs an 'unlock' action." + " Use for requests like 'turn off'," + " 'deactivate', 'disable', or 'unlock'." + ), device_classes=ONOFF_DEVICE_CLASSES, ), ) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 37188cb5a2e..d9b24a0b8c7 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -1,7 +1,5 @@ """Timer implementation for intents.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -205,7 +203,10 @@ class NoTimerCommandError(intent.IntentHandleError): class TimersNotSupportedError(intent.IntentHandleError): - """Error when a timer intent is used from a device that isn't registered to handle timer events.""" + """Error when a timer intent is used from an unregistered device. + + The device isn't registered to handle timer events. + """ def __init__(self, device_id: str | None = None) -> None: """Initialize error.""" @@ -310,7 +311,8 @@ class TimerManager: if (not timer.conversation_command) and (timer.device_id in self.handlers): self.handlers[timer.device_id](TimerEventType.STARTED, timer) _LOGGER.debug( - "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + "Timer started: id=%s, name=%s, hours=%s," + " minutes=%s, seconds=%s, device_id=%s", timer_id, name, hours, @@ -643,7 +645,8 @@ def _find_timer( raise MultipleTimersMatchedError _LOGGER.warning( - "Timer not found: name=%s, area=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + "Timer not found: name=%s, area=%s, hours=%s," + " minutes=%s, seconds=%s, device_id=%s", name, area_name, start_hours, diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 8d58a0dd45b..14781b39653 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -1,14 +1,12 @@ """Handle intents with scripts.""" -from __future__ import annotations - import logging from typing import Any, TypedDict import voluptuous as vol from homeassistant.components.script import CONF_MODE -from homeassistant.const import CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD +from homeassistant.const import CONF_ACTION, CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -31,7 +29,6 @@ CONF_INTENTS = "intents" CONF_SPEECH = "speech" CONF_REPROMPT = "reprompt" -CONF_ACTION = "action" CONF_CARD = "card" CONF_TITLE = "title" CONF_CONTENT = "content" @@ -78,8 +75,10 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] - for intent_type in existing_intents: + for intent_type, conf in existing_intents.items(): intent.async_remove(hass, intent_type) + if isinstance(conf.get(CONF_ACTION), script.Script): + await conf[CONF_ACTION].async_unload() if not new_config or DOMAIN not in new_config: hass.data[DOMAIN] = {} @@ -254,7 +253,8 @@ class ScriptIntentHandler(intent.IntentHandler): else: action_res = await action.async_run(slots, intent_obj.context) - # if the action returns a response, make it available to the speech/reprompt templates below + # if the action returns a response, make it + # available to the speech/reprompt templates below if action_res and action_res.service_response is not None: slots["action_response"] = action_res.service_response diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index c0ad603ba17..d2ac69d822e 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -1,7 +1,5 @@ """Support for IntesisHome and airconwithme Smart AC Controllers.""" -from __future__ import annotations - import logging from random import randrange from typing import Any, NamedTuple @@ -249,7 +247,8 @@ class IntesisAC(ClimateEntity): await self._controller.set_temperature(self._device_id, temperature) self._attr_target_temperature = temperature - # Write updated temperature to HA state to avoid flapping (API confirmation is slow) + # Write updated temperature to HA state to avoid + # flapping (API confirmation is slow) self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index ab306fb4773..314dc927f24 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyintesishome"], "quality_scale": "legacy", - "requirements": ["pyintesishome==1.8.0"] + "requirements": ["pyintesishome==1.8.8"] } diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index feb7ce9b8cf..98e3d84def4 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -1,7 +1,5 @@ """The IOmeter integration.""" -from __future__ import annotations - from iometer import IOmeterClient, IOmeterConnectionError from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json index 2d05508795f..e9e4fdf66b1 100644 --- a/homeassistant/components/iometer/manifest.json +++ b/homeassistant/components/iometer/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["iometer==0.4.0"], + "requirements": ["iometer==1.0.1"], "zeroconf": ["_iometer._tcp.local."] } diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py index b83b4a23dd6..4d510578ea8 100644 --- a/homeassistant/components/iometer/sensor.py +++ b/homeassistant/components/iometer/sensor.py @@ -74,7 +74,7 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [ device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: ( - int(round(data.status.device.core.battery_level)) + round(data.status.device.core.battery_level) if data.status.device.core.battery_level is not None else None ), diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index ef141a28475..444393b617d 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,4 +1,5 @@ """Native Home Assistant iOS app component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime from http import HTTPStatus @@ -9,7 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import Platform +from homeassistant.const import CONF_ACTIONS, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery @@ -40,7 +41,6 @@ from .const import ( CONF_ACTION_SHOW_IN_CARPLAY, CONF_ACTION_SHOW_IN_WATCH, CONF_ACTION_USE_CUSTOM_COLORS, - CONF_ACTIONS, DOMAIN, ) diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py index c9782aab1c7..1687a1faa22 100644 --- a/homeassistant/components/ios/const.py +++ b/homeassistant/components/ios/const.py @@ -32,7 +32,6 @@ CONF_ACTION_LABEL_TEXT = "text" CONF_ACTION_ICON = "icon" CONF_ACTION_ICON_COLOR = "color" CONF_ACTION_ICON_ICON = "icon" -CONF_ACTIONS = "actions" CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay" CONF_ACTION_SHOW_IN_WATCH = "show_in_watch" CONF_ACTION_USE_CUSTOM_COLORS = "use_custom_colors" diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index cf70a97f52a..1585e8d3a7f 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -1,7 +1,5 @@ """Support for iOS push notifications.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any @@ -32,8 +30,8 @@ def log_rate_limits( ) -> None: """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] - resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) - resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---" + resets_at = dt_util.parse_datetime(rate_limits["resetsAt"]) + resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---" rate_limit_msg = ( "iOS push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " @@ -46,7 +44,7 @@ def log_rate_limits( rate_limits["successful"], rate_limits["maximum"], rate_limits["errors"], - str(resetsAtTime).split(".", maxsplit=1)[0], + str(resets_at_time).split(".", maxsplit=1)[0], ) diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index a3c9876a884..a348d8d018b 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,7 +1,5 @@ """Support for Home Assistant iOS app sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py index 668844a1c5c..9bbef4db188 100644 --- a/homeassistant/components/iotawatt/config_flow.py +++ b/homeassistant/components/iotawatt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for iotawatt integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/iotawatt/const.py b/homeassistant/components/iotawatt/const.py index de008388f62..01034ae2a70 100644 --- a/homeassistant/components/iotawatt/const.py +++ b/homeassistant/components/iotawatt/const.py @@ -1,7 +1,5 @@ """Constants for the IoTaWatt integration.""" -from __future__ import annotations - import json import httpx diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 48d55dad818..a10cc6ae144 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -1,7 +1,5 @@ """IoTaWatt DataUpdateCoordinator.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 591397ad6e7..6ac1dfd9c2e 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -1,7 +1,5 @@ """Support for IoTaWatt Energy monitor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/iotty/__init__.py b/homeassistant/components/iotty/__init__.py index 02e69126492..cc7a0867264 100644 --- a/homeassistant/components/iotty/__init__.py +++ b/homeassistant/components/iotty/__init__.py @@ -1,7 +1,5 @@ """The iotty integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/iotty/api.py b/homeassistant/components/iotty/api.py index d87fda57731..3273a442b41 100644 --- a/homeassistant/components/iotty/api.py +++ b/homeassistant/components/iotty/api.py @@ -1,7 +1,5 @@ """API for iotty bound to Home Assistant OAuth.""" -from __future__ import annotations - from typing import Any from aiohttp import ClientSession diff --git a/homeassistant/components/iotty/application_credentials.py b/homeassistant/components/iotty/application_credentials.py index 83498b9edfe..31ac17ff3a6 100644 --- a/homeassistant/components/iotty/application_credentials.py +++ b/homeassistant/components/iotty/application_credentials.py @@ -1,7 +1,5 @@ """Application credentials platform for iotty.""" -from __future__ import annotations - from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/iotty/config_flow.py b/homeassistant/components/iotty/config_flow.py index 7aafde33f3d..fe1fbdbe949 100644 --- a/homeassistant/components/iotty/config_flow.py +++ b/homeassistant/components/iotty/config_flow.py @@ -1,7 +1,5 @@ """Config flow for iotty.""" -from __future__ import annotations - import logging from homeassistant.helpers import config_entry_oauth2_flow diff --git a/homeassistant/components/iotty/const.py b/homeassistant/components/iotty/const.py index e9e28f7d3e3..1696e958ce5 100644 --- a/homeassistant/components/iotty/const.py +++ b/homeassistant/components/iotty/const.py @@ -1,5 +1,3 @@ """Constants for the iotty integration.""" -from __future__ import annotations - DOMAIN = "iotty" diff --git a/homeassistant/components/iotty/coordinator.py b/homeassistant/components/iotty/coordinator.py index af870c347bd..5075b7c25f6 100644 --- a/homeassistant/components/iotty/coordinator.py +++ b/homeassistant/components/iotty/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for iotty.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/iotty/cover.py b/homeassistant/components/iotty/cover.py index d8b11131f4f..3d54a206e38 100644 --- a/homeassistant/components/iotty/cover.py +++ b/homeassistant/components/iotty/cover.py @@ -1,7 +1,5 @@ """Implement a iotty Shutter Device.""" -from __future__ import annotations - import logging from typing import Any @@ -128,19 +126,19 @@ class IottyShutter(IottyEntity, CoverEntity): self._iotty_device.percentage, ) return ( - self._iotty_device.status == ShutterState.STATIONARY + self._iotty_device.status is ShutterState.STATIONARY and self._iotty_device.percentage == 0 ) @property def is_opening(self) -> bool: """Return true if the Shutter is opening.""" - return self._iotty_device.status == ShutterState.OPENING + return self._iotty_device.status is ShutterState.OPENING @property def is_closing(self) -> bool: """Return true if the Shutter is closing.""" - return self._iotty_device.status == ShutterState.CLOSING + return self._iotty_device.status is ShutterState.CLOSING @property def supported_features(self) -> CoverEntityFeature: diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py index 113a4439e85..a4d8b8a783e 100644 --- a/homeassistant/components/iotty/switch.py +++ b/homeassistant/components/iotty/switch.py @@ -1,7 +1,5 @@ """Implement a iotty Light Switch Device.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index 3fbe447f9fb..40fd45f2322 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -1,7 +1,5 @@ """Support for Iperf3 network measurement tool.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -18,6 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_HOSTS, CONF_MONITORED_CONDITIONS, + CONF_PARALLEL, CONF_PORT, CONF_PROTOCOL, CONF_SCAN_INTERVAL, @@ -36,7 +35,6 @@ DATA_UPDATED = f"{DOMAIN}_data_updated" _LOGGER = logging.getLogger(__name__) CONF_DURATION = "duration" -CONF_PARALLEL = "parallel" CONF_MANUAL = "manual" DEFAULT_DURATION = 10 diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index b30e019798c..de41c69d101 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -1,7 +1,5 @@ """Support for Iperf3 sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -38,7 +36,7 @@ async def async_setup_platform( async_add_entities(entities, True) -# pylint: disable-next=hass-invalid-inheritance # needs fixing +# pylint: disable-next=home-assistant-invalid-inheritance # needs fixing class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index 1cb1af17d95..af0b6aeda3e 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -1,7 +1,5 @@ """Constants for IPMA component.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.components.weather import ( diff --git a/homeassistant/components/ipma/diagnostics.py b/homeassistant/components/ipma/diagnostics.py index bf868324593..1ce33a089b4 100644 --- a/homeassistant/components/ipma/diagnostics.py +++ b/homeassistant/components/ipma/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for IPMA.""" -from __future__ import annotations - from typing import Any from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE diff --git a/homeassistant/components/ipma/entity.py b/homeassistant/components/ipma/entity.py index ef9401fcb07..d1344c5ea84 100644 --- a/homeassistant/components/ipma/entity.py +++ b/homeassistant/components/ipma/entity.py @@ -1,7 +1,5 @@ """Base Entity for IPMA.""" -from __future__ import annotations - from pyipma.api import IPMA_API from pyipma.location import Location diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 7e71457513b..89ffb93d2e8 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -1,7 +1,5 @@ """Support for IPMA sensors.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from dataclasses import asdict, dataclass @@ -114,7 +112,11 @@ class IPMASensor(SensorEntity, IPMADevice): """Initialize the IPMA Sensor.""" IPMADevice.__init__(self, api, location) self.entity_description = description - self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self.entity_description.key}" + self._attr_unique_id = ( + f"{self._location.station_latitude}," + f" {self._location.station_longitude}," + f" {self.entity_description.key}" + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: diff --git a/homeassistant/components/ipma/system_health.py b/homeassistant/components/ipma/system_health.py index 7b6a5c517c7..44c0346e898 100644 --- a/homeassistant/components/ipma/system_health.py +++ b/homeassistant/components/ipma/system_health.py @@ -1,5 +1,7 @@ """Provide info to system health.""" +from typing import Any + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback @@ -14,7 +16,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "api_endpoint_reachable": system_health.async_check_can_reach_url( diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 02689a4b791..9b249edcac0 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -1,7 +1,5 @@ """Support for IPMA weather service.""" -from __future__ import annotations - import asyncio import contextlib import logging @@ -75,7 +73,11 @@ class IPMAWeather(WeatherEntity, IPMADevice): self._daily_forecast: list[IPMAForecast] | None = None self._hourly_forecast: list[IPMAForecast] | None = None if self._mode is not None: - self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" + self._attr_unique_id = ( + f"{self._location.station_latitude}," + f" {self._location.station_longitude}," + f" {self._mode}" + ) else: self._attr_unique_id = ( f"{self._location.station_latitude}, {self._location.station_longitude}" @@ -130,7 +132,10 @@ class IPMAWeather(WeatherEntity, IPMADevice): @property def condition(self) -> str | None: - """Return the current condition which is only available on the hourly forecast data.""" + """Return the current condition. + + Only available on the hourly forecast data. + """ forecast = self._hourly_forecast if not forecast: @@ -191,7 +196,9 @@ class IPMAWeather(WeatherEntity, IPMADevice): ), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.min_temperature, ATTR_FORECAST_NATIVE_TEMP: data_in.max_temperature, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.precipitation_probability, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + data_in.precipitation_probability + ), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 99332dca0e2..52f009652f4 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -1,7 +1,5 @@ """The Internet Printing Protocol (IPP) integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 4c3423df378..7246b24a94c 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the IPP integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py index 1c3dc4d0a03..45d2f3093de 100644 --- a/homeassistant/components/ipp/coordinator.py +++ b/homeassistant/components/ipp/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for The Internet Printing Protocol (IPP) integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/ipp/diagnostics.py b/homeassistant/components/ipp/diagnostics.py index cd136e78373..2d2c587e510 100644 --- a/homeassistant/components/ipp/diagnostics.py +++ b/homeassistant/components/ipp/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Internet Printing Protocol (IPP).""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index ce146db8c3b..ca961be07df 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -1,7 +1,5 @@ """Entities for The Internet Printing Protocol (IPP) integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index e16819a54ff..17e4d93f525 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -1,7 +1,5 @@ """Support for IPP sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index ad8b78bf9e3..e070b7cdce5 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -1,7 +1,5 @@ """Support for IQVIA.""" -from __future__ import annotations - import asyncio from pyiqvia import Client diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 444d86a7fb8..f138217d431 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the IQVIA component.""" -from __future__ import annotations - from typing import Any from pyiqvia import Client diff --git a/homeassistant/components/iqvia/coordinator.py b/homeassistant/components/iqvia/coordinator.py index ef926d1112d..28a95b3d6d8 100644 --- a/homeassistant/components/iqvia/coordinator.py +++ b/homeassistant/components/iqvia/coordinator.py @@ -1,7 +1,5 @@ """Support for IQVIA.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from datetime import timedelta from typing import Any diff --git a/homeassistant/components/iqvia/diagnostics.py b/homeassistant/components/iqvia/diagnostics.py index 953d42eafc2..9b2436786c6 100644 --- a/homeassistant/components/iqvia/diagnostics.py +++ b/homeassistant/components/iqvia/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for IQVIA.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/iqvia/entity.py b/homeassistant/components/iqvia/entity.py index 04e92ef9c4d..53750815afe 100644 --- a/homeassistant/components/iqvia/entity.py +++ b/homeassistant/components/iqvia/entity.py @@ -1,7 +1,5 @@ """Support for IQVIA.""" -from __future__ import annotations - from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 8b838d35ea1..6f8ea35a40f 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -1,7 +1,5 @@ """Support for IQVIA sensors.""" -from __future__ import annotations - from statistics import mean from typing import Any, NamedTuple, cast diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 13f827b2265..87a59c629a5 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -1,7 +1,5 @@ """Support for Irish Rail RTPI information.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/irm_kmi/config_flow.py b/homeassistant/components/irm_kmi/config_flow.py index ad426b36ba5..b7d8768faaf 100644 --- a/homeassistant/components/irm_kmi/config_flow.py +++ b/homeassistant/components/irm_kmi/config_flow.py @@ -103,7 +103,10 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN): class IrmKmiOptionFlow(OptionsFlowWithReload): - """Option flow for the IRM KMI integration, help change the options once the integration was configured.""" + """Option flow for the IRM KMI integration. + + Helps change options once the integration was configured. + """ async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py index 9ff6d735cdd..c64644bbf84 100644 --- a/homeassistant/components/irm_kmi/coordinator.py +++ b/homeassistant/components/irm_kmi/coordinator.py @@ -46,8 +46,8 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData] async def _async_update_data(self) -> ProcessedCoordinatorData: """Fetch data from API endpoint. - This is the place to pre-process the data to lookup tables so entities can quickly look up their data. - :return: ProcessedCoordinatorData + Pre-process the data to lookup tables so entities + can quickly look up their data. """ self._api.expire_cache() diff --git a/homeassistant/components/irm_kmi/data.py b/homeassistant/components/irm_kmi/data.py index 5a70b97f36f..cdde61d8408 100644 --- a/homeassistant/components/irm_kmi/data.py +++ b/homeassistant/components/irm_kmi/data.py @@ -9,7 +9,7 @@ from homeassistant.components.weather import Forecast @dataclass class ProcessedCoordinatorData: - """Dataclass that will be exposed to the entities consuming data from an IrmKmiCoordinator.""" + """Data exposed to entities consuming IrmKmiCoordinator data.""" current_weather: CurrentWeatherData country: str diff --git a/homeassistant/components/irm_kmi/utils.py b/homeassistant/components/irm_kmi/utils.py index b5f36297696..662e6554aff 100644 --- a/homeassistant/components/irm_kmi/utils.py +++ b/homeassistant/components/irm_kmi/utils.py @@ -7,7 +7,10 @@ from .const import CONF_LANGUAGE_OVERRIDE, LANGS def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry | None) -> str: - """Get the preferred language for the integration if it was overridden by the configuration.""" + """Get the preferred language for the integration. + + Returns the overridden language if set in configuration. + """ if ( config_entry is None diff --git a/homeassistant/components/irm_kmi/weather.py b/homeassistant/components/irm_kmi/weather.py index a0b4286a50c..5467301820f 100644 --- a/homeassistant/components/irm_kmi/weather.py +++ b/homeassistant/components/irm_kmi/weather.py @@ -119,7 +119,8 @@ class IrmKmiWeather( data: list[Forecast] = self.coordinator.data.daily_forecast # The data in daily_forecast might contain nighttime forecast. - # The following handle the lowest temperature attribute to be displayed correctly. + # The following handles the lowest temperature + # attribute to be displayed correctly. if ( len(data) > 1 and not data[0].get("is_daytime") diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 01ce0918459..6e334741409 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -1,7 +1,5 @@ """The IronOS integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py index 66e642c7aaa..a7e94f8ec2b 100644 --- a/homeassistant/components/iron_os/binary_sensor.py +++ b/homeassistant/components/iron_os/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for IronOS integration.""" -from __future__ import annotations - from enum import StrEnum from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/iron_os/button.py b/homeassistant/components/iron_os/button.py index e069ddb1d9f..ce7ac0f7946 100644 --- a/homeassistant/components/iron_os/button.py +++ b/homeassistant/components/iron_os/button.py @@ -1,7 +1,5 @@ """Button platform for IronOS integration.""" -from __future__ import annotations - from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py index d8df13d4a55..c2aa61726ed 100644 --- a/homeassistant/components/iron_os/config_flow.py +++ b/homeassistant/components/iron_os/config_flow.py @@ -1,7 +1,5 @@ """Config flow for IronOS integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 2a94794f2b8..8f08e5a6ce6 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -1,7 +1,5 @@ """Update coordinator for IronOS Integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from enum import Enum @@ -148,7 +146,9 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): device_registry.async_update_device( device_id=device.id, sw_version=self.device_info.build, - serial_number=f"{self.device_info.device_sn} (ID:{self.device_info.device_id})", + serial_number=( + f"{self.device_info.device_sn} (ID:{self.device_info.device_id})" + ), ) diff --git a/homeassistant/components/iron_os/diagnostics.py b/homeassistant/components/iron_os/diagnostics.py index e9545c24dec..a6d60b7b222 100644 --- a/homeassistant/components/iron_os/diagnostics.py +++ b/homeassistant/components/iron_os/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for IronOS integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import CONF_ADDRESS diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py index d07ad5a3aa1..02e46bfc232 100644 --- a/homeassistant/components/iron_os/entity.py +++ b/homeassistant/components/iron_os/entity.py @@ -1,7 +1,5 @@ """Base entity for IronOS integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo @@ -42,7 +40,10 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]): self._attr_device_info.update( DeviceInfo( sw_version=coordinator.device_info.build, - serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})", + serial_number=( + f"{coordinator.device_info.device_sn}" + f" (ID:{coordinator.device_info.device_id})" + ), ) ) diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index e9056bc9abc..270f2eb41fb 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -1,7 +1,5 @@ """Number platform for IronOS integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum @@ -286,11 +284,16 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( ) """ -The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities. -These entities represent user-defined input values, not measured temperatures, and their -interpretation depends on the device's current unit configuration. Applying a device_class -results in automatic unit conversions, which introduce rounding errors due to the use of integers. -This can prevent the correct value from being set, as the input is modified during synchronization with the device. +The `device_class` attribute was removed from the +`setpoint_temperature`, `sleep_temperature`, and +`boost_temp` entities. These entities represent user-defined +input values, not measured temperatures, and their +interpretation depends on the device's current unit +configuration. Applying a device_class results in automatic +unit conversions, which introduce rounding errors due to the +use of integers. This can prevent the correct value from +being set, as the input is modified during synchronization +with the device. """ PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( IronOSNumberEntityDescription( diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index 32652829531..7d283743405 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -1,7 +1,5 @@ """Select platform for IronOS integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import Enum, StrEnum diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py index da70b998e34..d74085ff5ea 100644 --- a/homeassistant/components/iron_os/sensor.py +++ b/homeassistant/components/iron_os/sensor.py @@ -1,9 +1,8 @@ """Sensor platform for IronOS integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from enum import StrEnum from pynecil import LiveDataResponse, OperatingMode, PowerSource @@ -25,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from . import IronOSConfigEntry from .const import OHM @@ -58,7 +58,7 @@ class PinecilSensor(StrEnum): class IronOSSensorEntityDescription(SensorEntityDescription): """IronOS sensor entity descriptions.""" - value_fn: Callable[[LiveDataResponse, bool], StateType] + value_fn: Callable[[LiveDataResponse, bool], StateType | datetime] PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( @@ -118,10 +118,14 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( IronOSSensorEntityDescription( key=PinecilSensor.UPTIME, translation_key=PinecilSensor.UPTIME, - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data, _: data.uptime, + device_class=SensorDeviceClass.UPTIME, + value_fn=( + lambda data, _: ( + (dt_util.utcnow() - timedelta(seconds=data.uptime)) + if data.uptime is not None + else None + ) + ), entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -202,7 +206,7 @@ class IronOSSensorEntity(IronOSBaseEntity, SensorEntity): coordinator: IronOSLiveDataCoordinator @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return sensor state.""" return self.entity_description.value_fn( self.coordinator.data, self.coordinator.has_tip diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index f1f189d83b3..838e2d55eab 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -1,7 +1,5 @@ """Switch platform for IronOS integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index ca7e7581067..5e788d717c1 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -1,7 +1,5 @@ """Update platform for IronOS integration.""" -from __future__ import annotations - from homeassistant.components.update import ( ATTR_INSTALLED_VERSION, UpdateDeviceClass, diff --git a/homeassistant/components/isal/__init__.py b/homeassistant/components/isal/__init__.py index 3df59b7ea9f..10271bc81e9 100644 --- a/homeassistant/components/isal/__init__.py +++ b/homeassistant/components/isal/__init__.py @@ -1,7 +1,5 @@ """The isal integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/iskra/__init__.py b/homeassistant/components/iskra/__init__.py index 21c60db20fe..f87cc4b9a5d 100644 --- a/homeassistant/components/iskra/__init__.py +++ b/homeassistant/components/iskra/__init__.py @@ -1,7 +1,5 @@ """The iskra integration.""" -from __future__ import annotations - from pyiskra.adapters import Modbus, RestAPI from pyiskra.devices import Device from pyiskra.exceptions import DeviceConnectionError, DeviceNotSupported, NotAuthorised @@ -61,7 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IskraConfigEntry) -> boo # Initialize the device await base_device.init() - # if the device is a gateway, add all child devices, otherwise add the device itself. + # If the device is a gateway, add all child devices, + # otherwise add the device itself. if base_device.is_gateway: # Add the gateway device to the device registry device_registry = dr.async_get(hass) diff --git a/homeassistant/components/iskra/config_flow.py b/homeassistant/components/iskra/config_flow.py index b67b9ba3839..ef3c80bdcbe 100644 --- a/homeassistant/components/iskra/config_flow.py +++ b/homeassistant/components/iskra/config_flow.py @@ -1,7 +1,5 @@ """Config flow for iskra integration.""" -from __future__ import annotations - import logging from typing import Any @@ -59,7 +57,8 @@ STEP_AUTHENTICATION_DATA_SCHEMA = vol.Schema( } ) -# CONF_ADDRESS validation is done later in code, as if ranges are set in voluptuous it turns into a slider +# CONF_ADDRESS validation is done later in code, as if +# ranges are set in voluptuous it turns into a slider STEP_MODBUS_TCP_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_PORT, default=10001): vol.All( @@ -184,7 +183,8 @@ class IskraConfigFlowFlow(ConfigFlow, domain=DOMAIN): user_input=user_input, ) - # If there's no user_input or there was an error, show the authentication form again. + # If there's no user_input or there was an error, + # show the authentication form again. return self.async_show_form( step_id="authentication", data_schema=STEP_AUTHENTICATION_DATA_SCHEMA, diff --git a/homeassistant/components/iskra/coordinator.py b/homeassistant/components/iskra/coordinator.py index d476556e96d..14ef5c28827 100644 --- a/homeassistant/components/iskra/coordinator.py +++ b/homeassistant/components/iskra/coordinator.py @@ -47,11 +47,13 @@ class IskraDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.device.update_status() except DeviceTimeoutError as e: raise UpdateFailed( - f"Timeout error occurred while updating data for device {self.device.serial}" + "Timeout error occurred while updating" + f" data for device {self.device.serial}" ) from e except DeviceConnectionError as e: raise UpdateFailed( - f"Connection error occurred while updating data for device {self.device.serial}" + "Connection error occurred while updating" + f" data for device {self.device.serial}" ) from e except NotAuthorised as e: raise UpdateFailed( diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py index 10aa5555249..75b8e1c488a 100644 --- a/homeassistant/components/iskra/sensor.py +++ b/homeassistant/components/iskra/sensor.py @@ -1,7 +1,5 @@ """Support for Iskra.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, replace @@ -171,7 +169,10 @@ def get_counter_entity_description( index: int, entity_name: str, ) -> IskraSensorEntityDescription: - """Dynamically create IskraSensor object as energy meter's counters are customizable.""" + """Dynamically create IskraSensor object. + + Energy meter's counters are customizable. + """ key = entity_name.format(index + 1) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 731d1324c71..2450d7e0b16 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,7 +1,5 @@ """The islamic_prayer_times component.""" -from __future__ import annotations - import logging from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index ce911ccc49d..50d4f2fd800 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Islamic Prayer Times integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -81,6 +79,8 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=NAME): TextSelector(), vol.Required( CONF_LOCATION, default=home_location diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 8bd7e5904b0..514f76ae8c3 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Islamic prayer times integration.""" -from __future__ import annotations - from datetime import date, datetime, timedelta import logging from typing import Any, cast @@ -95,10 +93,13 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim The least surprising behaviour is to load the next day's prayer times only after the current day's prayers are complete. We will take the fiqhi opinion - that Isha should be prayed before Islamic midnight (which may be before or after 12:00 midnight), - and thus we will switch to the next day's timings at Islamic midnight. + that Isha should be prayed before Islamic midnight + (which may be before or after 12:00 midnight), and thus + we will switch to the next day's timings at Islamic + midnight. - The +1s is to ensure that any automations predicated on the arrival of Islamic midnight will run. + The +1s is to ensure that any automations predicated + on the arrival of Islamic midnight will run. """ _LOGGER.debug("Scheduling next update for Islamic prayer times") @@ -114,12 +115,18 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim async def _async_update_data(self) -> dict[str, datetime]: """Update sensors with new prayer times. - Prayer time calculations "roll over" at 12:00 midnight - but this does not mean that all prayers - occur within that Gregorian calendar day. For instance Jasper, Alta. sees Isha occur after 00:00 in the summer. - It is similarly possible (albeit less likely) that Fajr occurs before 00:00. + Prayer time calculations "roll over" at 12:00 midnight + but this does not mean that all prayers occur within + that Gregorian calendar day. For instance Jasper, Alta. + sees Isha occur after 00:00 in the summer. It is + similarly possible (albeit less likely) that Fajr + occurs before 00:00. - As such, to ensure that no prayer times are "unreachable" (e.g. we always see the Isha timestamp pass before loading the next day's times), - we calculate 3 days' worth of times (-1, 0, +1 days) and select the appropriate set based on Islamic midnight. + As such, to ensure that no prayer times are + "unreachable" (e.g. we always see the Isha timestamp + pass before loading the next day's times), we calculate + 3 days' worth of times (-1, 0, +1 days) and select the + appropriate set based on Islamic midnight. The calculation is inexpensive, so there is no need to cache it. """ diff --git a/homeassistant/components/israel_rail/__init__.py b/homeassistant/components/israel_rail/__init__.py index ed800f559d4..ebe69625970 100644 --- a/homeassistant/components/israel_rail/__init__.py +++ b/homeassistant/components/israel_rail/__init__.py @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) - try: await hass.async_add_executor_job(train_schedule.query, start, destination) except Exception as e: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_timeout", diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index 190ed938790..ae54834affd 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the israel rail integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime import logging @@ -25,6 +23,7 @@ class DataConnection: """A connection data class.""" departure: datetime | None + departure_delay: int | None platform: str start: str destination: str @@ -80,15 +79,27 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection] "Unable to connect and retrieve data from israelrail api", ) from e + offset = 0 + now = dt_util.now() + while offset < len(train_routes): + route = train_routes[offset] + if route is None: + break + route_departure = departure_time(route) + if route_departure is None or route_departure >= now: + break + offset += 1 + return [ DataConnection( departure=departure_time(train_routes[i]), + departure_delay=train_routes[i].trains[0].departure_delay, train_number=train_routes[i].trains[0].data["trainNumber"], platform=train_routes[i].trains[0].platform, trains=len(train_routes[i].trains), start=station_name_to_id(train_routes[i].trains[0].src), destination=station_name_to_id(train_routes[i].trains[-1].dst), ) - for i in range(DEPARTURES_COUNT) + for i in range(offset, offset + DEPARTURES_COUNT) if len(train_routes) > i and train_routes[i] is not None ] diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index 0362f7d2224..ad9f3c1a17f 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.4"] + "requirements": ["israel-rail-api==0.1.5"] } diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py index 6e3324de7ae..3582695a928 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -1,7 +1,5 @@ """Support for israel rail.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -12,7 +10,9 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,21 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( ) SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( - IsraelRailSensorEntityDescription( - key="platform", - translation_key="platform", - value_fn=lambda data_connection: data_connection.platform, - ), - IsraelRailSensorEntityDescription( - key="trains", - translation_key="trains", - value_fn=lambda data_connection: data_connection.trains, - ), - IsraelRailSensorEntityDescription( - key="train_number", - translation_key="train_number", - value_fn=lambda data_connection: data_connection.train_number, - ), + *[ + IsraelRailSensorEntityDescription( + key=f"platform{i or ''}", + translation_key=f"platform{i or ''}", + value_fn=lambda data_connection: data_connection.platform, + index=i, + ) + for i in range(DEPARTURES_COUNT) + ], + *[ + IsraelRailSensorEntityDescription( + key=f"trains{i or ''}", + translation_key=f"trains{i or ''}", + value_fn=lambda data_connection: data_connection.trains, + index=i, + ) + for i in range(DEPARTURES_COUNT) + ], + *[ + IsraelRailSensorEntityDescription( + key=f"train_number{i or ''}", + translation_key=f"train_number{i or ''}", + value_fn=lambda data_connection: data_connection.train_number, + index=i, + ) + for i in range(DEPARTURES_COUNT) + ], + *[ + IsraelRailSensorEntityDescription( + key=f"departure_delay{i or ''}", + translation_key=f"departure_delay{i or ''}", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data_connection: data_connection.departure_delay, + index=i, + ) + for i in range(DEPARTURES_COUNT) + ], ) diff --git a/homeassistant/components/israel_rail/strings.json b/homeassistant/components/israel_rail/strings.json index 3b16015fe34..d4848be67b8 100644 --- a/homeassistant/components/israel_rail/strings.json +++ b/homeassistant/components/israel_rail/strings.json @@ -28,14 +28,41 @@ "departure2": { "name": "Departure +2" }, + "departure_delay": { + "name": "Departure delay" + }, + "departure_delay1": { + "name": "Departure delay +1" + }, + "departure_delay2": { + "name": "Departure delay +2" + }, "platform": { "name": "Platform" }, + "platform1": { + "name": "Platform +1" + }, + "platform2": { + "name": "Platform +2" + }, "train_number": { "name": "Train number" }, + "train_number1": { + "name": "Train number +1" + }, + "train_number2": { + "name": "Train number +2" + }, "trains": { "name": "Trains" + }, + "trains1": { + "name": "Trains +1" + }, + "trains2": { + "name": "Trains +2" } } } diff --git a/homeassistant/components/iss/__init__.py b/homeassistant/components/iss/__init__.py index d8ffa9c215d..1aebf8f44b6 100644 --- a/homeassistant/components/iss/__init__.py +++ b/homeassistant/components/iss/__init__.py @@ -1,7 +1,5 @@ """The iss component.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index 5aa49c3d45a..87bcf371882 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure iss component.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow diff --git a/homeassistant/components/iss/coordinator.py b/homeassistant/components/iss/coordinator.py index 88a9c8ebbdb..c2d347f7d8e 100644 --- a/homeassistant/components/iss/coordinator.py +++ b/homeassistant/components/iss/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the ISS integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -63,7 +61,9 @@ class IssDataUpdateCoordinator(DataUpdateCoordinator[IssData]): raise UpdateFailed("Unable to retrieve data") from err if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: raise UpdateFailed( - f"Unable to retrieve data after {self._consecutive_failures} consecutive update failures" + "Unable to retrieve data after" + f" {self._consecutive_failures}" + " consecutive update failures" ) from err _LOGGER.debug( "Transient API error (%s/%s), using cached data: %s", diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index b7fa190c3bd..57488b0c575 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -1,7 +1,5 @@ """Support for iss sensor.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index 747e33835b1..7133b21b8d9 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -1,7 +1,5 @@ """The ista Ecotrend integration.""" -from __future__ import annotations - import logging from pyecotrend_ista import PyEcotrendIsta diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index e24441c9f4e..1d62373a5c8 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ista EcoTrend integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 75591b09728..ad7e1a451c9 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Ista EcoTrend integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/ista_ecotrend/diagnostics.py b/homeassistant/components/ista_ecotrend/diagnostics.py index 4c61c197b5e..e56579b9131 100644 --- a/homeassistant/components/ista_ecotrend/diagnostics.py +++ b/homeassistant/components/ista_ecotrend/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for ista EcoTrend integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index 95096375530..8a974d15803 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Ista EcoTrend integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import datetime @@ -212,8 +210,10 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): async def async_added_to_hass(self) -> None: """When added to hass.""" - # perform initial statistics import when sensor is added, otherwise it would take - # 1 day when _handle_coordinator_update is triggered for the first time. + # Perform initial statistics import when sensor is + # added, otherwise it would take 1 day when + # _handle_coordinator_update is triggered for the + # first time. await self.update_statistics() await super().async_added_to_hass() @@ -283,7 +283,9 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): "source": DOMAIN, "statistic_id": statistic_id, "unit_class": self.entity_description.unit_class, - "unit_of_measurement": self.entity_description.native_unit_of_measurement, + "unit_of_measurement": ( + self.entity_description.native_unit_of_measurement + ), } if statistics: _LOGGER.debug("Insert statistics: %s %s", metadata, statistics) diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index 5d790a3cf1c..0a7f0341e93 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -1,7 +1,5 @@ """Utility functions for Ista EcoTrend integration.""" -from __future__ import annotations - import datetime from enum import StrEnum from typing import Any diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 68ca63b6bb5..926f805a256 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -1,7 +1,5 @@ """Support the Universal Devices ISY/IoX controllers.""" -from __future__ import annotations - import asyncio from urllib.parse import urlparse @@ -15,6 +13,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_VARIABLES, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -33,9 +32,9 @@ from .const import ( CONF_IGNORE_STRING, CONF_NETWORK, CONF_SENSOR_STRING, - CONF_TLS_VER, DEFAULT_IGNORE_STRING, DEFAULT_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -64,6 +63,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version == 1 and entry.minor_version == 1: + new_data = {key: value for key, value in entry.data.items() if key != "tls"} + new_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Set up the ISY 994 integration.""" isy_config = entry.data @@ -73,9 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: user = isy_config[CONF_USERNAME] password = isy_config[CONF_PASSWORD] host = urlparse(isy_config[CONF_HOST]) - - # Optional - tls_version = isy_config.get(CONF_TLS_VER) + verify_ssl = isy_config[CONF_VERIFY_SSL] ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) @@ -88,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: elif host.scheme == SCHEME_HTTPS: https = True port = host.port or 443 - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") return False @@ -100,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: username=user, password=password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, use_websocket=True, diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index d452b5bacef..dff4ce9d52f 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -1,7 +1,5 @@ """Support for ISY binary sensors.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index cfb077c7dc0..ba81d40afa5 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -1,7 +1,5 @@ """Representation of ISY/IoX buttons.""" -from __future__ import annotations - from pyisy import ISY from pyisy.constants import ( ATTR_ACTION, diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index ce39cae5428..52436f5d455 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -1,7 +1,5 @@ """Support for Insteon Thermostats via ISY Platform.""" -from __future__ import annotations - from typing import Any from pyisy.constants import ( diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 77ca0c851ec..976c4c76ef6 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,13 +1,12 @@ """Config flow for Universal Devices ISY/IoX integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse, urlunparse +import aiohttp from aiohttp import CookieJar from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.configuration import Configuration @@ -20,7 +19,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError @@ -36,13 +41,12 @@ from .const import ( CONF_IGNORE_STRING, CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, - CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, DEFAULT_RESTORE_LIGHT_STATE, DEFAULT_SENSOR_STRING, - DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, HTTP_PORT, HTTPS_PORT, @@ -65,7 +69,7 @@ def _data_schema(schema_input: dict[str, str]) -> vol.Schema: vol.Required(CONF_HOST, default=schema_input.get(CONF_HOST, "")): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, }, extra=vol.ALLOW_EXTRA, ) @@ -79,7 +83,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user = data[CONF_USERNAME] password = data[CONF_PASSWORD] host = urlparse(data[CONF_HOST]) - tls_version = data.get(CONF_TLS_VER) + verify_ssl = data[CONF_VERIFY_SSL] if host.scheme == SCHEME_HTTP: https = False @@ -90,7 +94,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, elif host.scheme == SCHEME_HTTPS: https = True port = host.port or HTTPS_PORT - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") raise InvalidHost @@ -102,7 +106,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user, password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, ) @@ -113,6 +117,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except ISYInvalidAuthError as error: raise InvalidAuth from error except ISYConnectionError as error: + # pyisy chains the underlying aiohttp error via __cause__; ClientSSLError + # covers both protocol mismatch and certificate verification failures. + if isinstance(error.__cause__, aiohttp.ClientSSLError): + raise SslError from error raise CannotConnect from error try: @@ -133,6 +141,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Universal Devices ISY/IoX.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" @@ -158,6 +167,8 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidHost: errors["base"] = "invalid_host" except InvalidAuth: @@ -293,6 +304,8 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): await validate_input(self.hass, new_data) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" else: @@ -370,5 +383,9 @@ class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" +class SslError(HomeAssistantError): + """Error to indicate a TLS/SSL handshake failure.""" + + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b43385a0e5d..69ca68aac71 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.lock import LockState +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -68,13 +69,12 @@ CONF_NETWORK = "network" CONF_IGNORE_STRING = "ignore_string" CONF_SENSOR_STRING = "sensor_string" CONF_VAR_SENSOR_STRING = "variable_sensor_string" -CONF_TLS_VER = "tls" CONF_RESTORE_LIGHT_STATE = "restore_light_state" DEFAULT_IGNORE_STRING = "{IGNORE ME}" DEFAULT_SENSOR_STRING = "sensor" DEFAULT_RESTORE_LIGHT_STATE = False -DEFAULT_TLS_VERSION = 1.1 +DEFAULT_VERIFY_SSL = False DEFAULT_PROGRAM_STRING = "HA." DEFAULT_VAR_SENSOR_STRING = "HA." @@ -431,7 +431,7 @@ UOM_FRIENDLY_NAME = { "127": UnitOfPressure.MMHG, "128": "J", "129": "BMI", # Body Mass Index - "130": f"{UnitOfVolume.LITERS}/{UnitOfTime.HOURS}", + "130": UnitOfVolumeFlowRate.LITERS_PER_HOUR, "131": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, "132": "bpm", # Breaths per minute "133": UnitOfFrequency.KILOHERTZ, @@ -444,8 +444,8 @@ UOM_FRIENDLY_NAME = { "140": f"{UnitOfMass.MILLIGRAMS}/{UnitOfVolume.LITERS}", "141": "N", # Netwon "142": f"{UnitOfVolume.GALLONS}/{UnitOfTime.SECONDS}", - "143": "gpm", # Gallon per Minute - "144": "gph", # Gallon per Hour + "143": UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + "144": UnitOfVolumeFlowRate.GALLONS_PER_HOUR, } UOM_TO_STATES = { @@ -653,6 +653,13 @@ HA_HVAC_TO_ISY = { HA_FAN_TO_ISY = {FAN_ON: "on", FAN_AUTO: "auto"} +TOTAL_INCREASING_DEVICE_CLASSES = { + SensorDeviceClass.ENERGY, + SensorDeviceClass.WATER, + SensorDeviceClass.GAS, + SensorDeviceClass.PRECIPITATION, +} + BINARY_SENSOR_DEVICE_TYPES_ISY = { BinarySensorDeviceClass.MOISTURE: ["16.8.", "16.13.", "16.14."], BinarySensorDeviceClass.OPENING: [ diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index f940fe55332..c37d328606b 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -1,7 +1,5 @@ """Support for ISY covers.""" -from __future__ import annotations - from typing import Any, cast from pyisy.constants import ISY_VALUE_UNKNOWN diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index d170854396c..4ace588bf4b 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -1,7 +1,5 @@ """Representation of ISYEntity Types.""" -from __future__ import annotations - from typing import Any, cast from pyisy.constants import ( @@ -151,7 +149,7 @@ class ISYNodeEntity(ISYEntity): await self._node.send_cmd(command, value, unit_of_measurement, parameters) async def async_get_zwave_parameter(self, parameter: Any) -> None: - """Respond to an entity service command to request a Z-Wave device parameter from the ISY.""" + """Respond to a service command to request a Z-Wave parameter.""" if self._node.protocol != PROTO_ZWAVE: raise HomeAssistantError( "Invalid service call: cannot request Z-Wave Parameter for non-Z-Wave" @@ -162,7 +160,7 @@ class ISYNodeEntity(ISYEntity): async def async_set_zwave_parameter( self, parameter: Any, value: Any | None, size: int | None ) -> None: - """Respond to an entity service command to set a Z-Wave device parameter via the ISY.""" + """Respond to a service command to set a Z-Wave parameter.""" if self._node.protocol != PROTO_ZWAVE: raise HomeAssistantError( "Invalid service call: cannot set Z-Wave Parameter for non-Z-Wave" diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 02542462788..c0f2f3f86f4 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -1,7 +1,5 @@ """Support for ISY fans.""" -from __future__ import annotations - import math from typing import Any diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 587c0544d6c..02487a12b7f 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -1,7 +1,5 @@ """Sorting helpers for ISY device classifications.""" -from __future__ import annotations - from typing import cast from pyisy.constants import ( diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index d3edc25c3e2..378634d9785 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -1,7 +1,5 @@ """Support for ISY lights.""" -from __future__ import annotations - from typing import Any, cast from pyisy.constants import ISY_VALUE_UNKNOWN @@ -83,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): def async_on_update(self, event: NodeProperty) -> None: """Save brightness in the update event from the ISY Node.""" if self._node.status not in (0, ISY_VALUE_UNKNOWN): - self._last_brightness = self._node.status if self._node.uom == UOM_PERCENTAGE: self._last_brightness = round(self._node.status * 255.0 / 100.0) else: diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index 056d1d0d492..ee76139f9b6 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -1,7 +1,5 @@ """Support for ISY locks.""" -from __future__ import annotations - from typing import Any from pyisy.constants import ISY_VALUE_UNKNOWN diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index e9e0fd625d0..02aa667602a 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.4.1"], + "requirements": ["pyisy==3.6.1"], "ssdp": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py index 4fc7b96fcd5..df0ab93c623 100644 --- a/homeassistant/components/isy994/models.py +++ b/homeassistant/components/isy994/models.py @@ -1,7 +1,5 @@ """The ISY/IoX integration data models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index c5797491e31..f940b836509 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -1,7 +1,5 @@ """Support for ISY number entities.""" -from __future__ import annotations - from dataclasses import replace from typing import Any @@ -91,7 +89,6 @@ async def async_setup_entry( key=node.address, name=node.name, entity_registry_enabled_default=var_id in node.name, - native_unit_of_measurement=None, native_step=step, native_min_value=-min_max, native_max_value=min_max, @@ -197,8 +194,9 @@ class ISYVariableNumberEntity(NumberEntity): self.entity_description = description self._change_handler: EventListener | None = None - # Two entities are created for each variable, one for current value and one for initial. - # Initial value entities are disabled by default + # Two entities are created for each variable, one for + # current value and one for initial. Initial value + # entities are disabled by default self._init_entity = init_entity self._attr_unique_id = unique_id self._attr_device_info = device_info diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index ce5e224bc88..ccd3744b2a8 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -1,7 +1,5 @@ """Support for ISY select entities.""" -from __future__ import annotations - from typing import cast from pyisy.constants import ( diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 6e0b5a89637..b4d653e4cec 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -1,7 +1,5 @@ """Support for ISY sensors.""" -from __future__ import annotations - from typing import Any, cast from pyisy.constants import ( @@ -29,13 +27,19 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.const import EntityCategory, Platform, UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + Platform, + UnitOfTemperature, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( _LOGGER, + TOTAL_INCREASING_DEVICE_CLASSES, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, UOM_INDEX, @@ -73,6 +77,7 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "DISTANC": SensorDeviceClass.DISTANCE, "ETO": SensorDeviceClass.PRECIPITATION_INTENSITY, # codespell:ignore eto "FATM": SensorDeviceClass.WEIGHT, + "FLOW": SensorDeviceClass.VOLUME_FLOW_RATE, "FREQ": SensorDeviceClass.FREQUENCY, "MUSCLEM": SensorDeviceClass.WEIGHT, "PF": SensorDeviceClass.POWER_FACTOR, @@ -95,9 +100,56 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "WEIGHT": SensorDeviceClass.WEIGHT, "WINDCH": SensorDeviceClass.TEMPERATURE, } -ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys( - ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT -) +UOM_TO_DEVICE_CLASS = { + "1": SensorDeviceClass.CURRENT, + "3": SensorDeviceClass.POWER, + "4": SensorDeviceClass.TEMPERATURE, + "7": SensorDeviceClass.VOLUME_FLOW_RATE, + "12": SensorDeviceClass.SOUND_PRESSURE, + "13": SensorDeviceClass.SOUND_PRESSURE, + "17": SensorDeviceClass.TEMPERATURE, + "23": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + "24": SensorDeviceClass.PRECIPITATION_INTENSITY, + "26": SensorDeviceClass.TEMPERATURE, + "28": SensorDeviceClass.WEIGHT, + "29": SensorDeviceClass.VOLTAGE, + "30": SensorDeviceClass.POWER, + "31": SensorDeviceClass.PRESSURE, + "32": SensorDeviceClass.SPEED, + "33": SensorDeviceClass.ENERGY, + "35": SensorDeviceClass.WATER, + "39": SensorDeviceClass.VOLUME_FLOW_RATE, + "40": SensorDeviceClass.SPEED, + "41": SensorDeviceClass.CURRENT, + "43": SensorDeviceClass.VOLTAGE, + "46": SensorDeviceClass.PRECIPITATION_INTENSITY, + "48": SensorDeviceClass.SPEED, + "49": SensorDeviceClass.SPEED, + "52": SensorDeviceClass.WEIGHT, + "54": SensorDeviceClass.CO2, + "69": SensorDeviceClass.WATER, + "72": SensorDeviceClass.VOLTAGE, + "73": SensorDeviceClass.POWER, + "74": SensorDeviceClass.IRRADIANCE, + "82": SensorDeviceClass.DISTANCE, + "83": SensorDeviceClass.DISTANCE, + "90": SensorDeviceClass.FREQUENCY, + "105": SensorDeviceClass.DISTANCE, + "106": SensorDeviceClass.PRECIPITATION_INTENSITY, + "116": SensorDeviceClass.DISTANCE, + "117": SensorDeviceClass.PRESSURE, + "118": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + "119": SensorDeviceClass.ENERGY, + "120": SensorDeviceClass.PRECIPITATION_INTENSITY, + "127": SensorDeviceClass.PRESSURE, + "130": SensorDeviceClass.VOLUME_FLOW_RATE, + "131": SensorDeviceClass.SIGNAL_STRENGTH, + "133": SensorDeviceClass.FREQUENCY, + "138": SensorDeviceClass.PRESSURE, + "142": SensorDeviceClass.VOLUME_FLOW_RATE, + "143": SensorDeviceClass.VOLUME_FLOW_RATE, + "144": SensorDeviceClass.VOLUME_FLOW_RATE, +} ISY_CONTROL_TO_ENTITY_CATEGORY = { PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, @@ -105,6 +157,21 @@ ISY_CONTROL_TO_ENTITY_CATEGORY = { } +def _check_volume_flow_rate_uom( + device_class: SensorDeviceClass | None, + uom: str | list[str] | None, +) -> SensorDeviceClass | None: + """Check if the volume flow rate unit is supported.""" + if device_class != SensorDeviceClass.VOLUME_FLOW_RATE: + return device_class + # Backwards compatibility for ISYv4 firmware which may return a list. + if isinstance(uom, list): + uom = uom[0] if uom else None + if uom is not None and UOM_FRIENDLY_NAME.get(uom) in UnitOfVolumeFlowRate: + return device_class + return None + + async def async_setup_entry( hass: HomeAssistant, entry: IsyConfigEntry, @@ -141,6 +208,26 @@ async def async_setup_entry( class ISYSensorEntity(ISYNodeEntity, SensorEntity): """Representation of an ISY sensor device.""" + def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: + """Initialize the ISY sensor.""" + super().__init__(node, device_info=device_info) + uom = self._node.uom + if isinstance(uom, list): + uom = uom[0] + + # Determine device class + self._attr_device_class = _check_volume_flow_rate_uom( + UOM_TO_DEVICE_CLASS.get(uom), uom + ) + + # Determine state class + if self._attr_device_class in TOTAL_INCREASING_DEVICE_CLASSES: + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif self._attr_device_class is not None: + self._attr_state_class = SensorStateClass.MEASUREMENT + else: + self._attr_state_class = None + @property def target(self) -> Node | NodeProperty | None: """Return target for the sensor.""" @@ -192,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): if uom in (UOM_INDEX, UOM_ON_OFF): return cast(str, self.target.formatted) - # Check if this is an index type and get formatted value - if uom == UOM_INDEX and hasattr(self.target, "formatted"): - return cast(str, self.target.formatted) - # Handle ISY precision and rounding value = convert_isy_value_to_hass(value, uom, self.target.prec) if value is None: @@ -240,8 +323,24 @@ class ISYAuxSensorEntity(ISYSensorEntity): self._control = control self._attr_entity_registry_enabled_default = enabled_default self._attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control) - self._attr_device_class = ISY_CONTROL_TO_DEVICE_CLASS.get(control) - self._attr_state_class = ISY_CONTROL_TO_STATE_CLASS.get(control) + + uom = None + if control in self._node.aux_properties: + uom = self._node.aux_properties[control].uom + + # Determine device class + self._attr_device_class = _check_volume_flow_rate_uom( + ISY_CONTROL_TO_DEVICE_CLASS.get(control), uom + ) + + # Determine state class + if self._attr_device_class in TOTAL_INCREASING_DEVICE_CLASSES: + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif self._attr_device_class is not None: + self._attr_state_class = SensorStateClass.MEASUREMENT + else: + self._attr_state_class = None + self._attr_unique_id = unique_id self._change_handler: EventListener = None self._availability_handler: EventListener = None @@ -262,7 +361,7 @@ class ISYAuxSensorEntity(ISYSensorEntity): """Return the target value.""" return None if self.target is None else self.target.value - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events. diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 3f31b2e5730..2ae466eae79 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -1,7 +1,5 @@ """ISY Services and Commands.""" -from __future__ import annotations - from typing import Any from pyisy.constants import COMMAND_FRIENDLY_NAME @@ -9,6 +7,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, + CONF_CODE, CONF_COMMAND, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, @@ -40,7 +39,6 @@ SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code" CONF_PARAMETER = "parameter" CONF_PARAMETERS = "parameters" CONF_USER_NUM = "user_num" -CONF_CODE = "code" CONF_VALUE = "value" CONF_INIT = "init" CONF_ISY = "isy" diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index f7c8aa22838..ef516bfb64f 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -9,6 +9,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "ssl_error": "TLS handshake failed. The controller may require a newer TLS version, or SSL verification may be failing due to a self-signed certificate.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})", @@ -25,8 +26,11 @@ "data": { "host": "[%key:common::config_flow::data::url%]", "password": "[%key:common::config_flow::data::password%]", - "tls": "The TLS version of the ISY controller.", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "verify_ssl": "Verify the controller's TLS certificate. Leave disabled for ISY-994/eisy/Polisy controllers using their default self-signed certificate." }, "description": "The host entry must be in full URL format, e.g., {sample_ip}", "title": "Connect to your ISY" diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index f44613317c5..2d4e665ee7c 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -1,7 +1,5 @@ """Support for ISY switches.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -157,7 +155,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): self._attr_name = description.name # Override super self._change_handler: EventListener | None = None - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events.""" self._change_handler = self._node.isy.nodes.status_events.subscribe( diff --git a/homeassistant/components/isy994/system_health.py b/homeassistant/components/isy994/system_health.py index 9c5a04ba34a..316946d3d1b 100644 --- a/homeassistant/components/isy994/system_health.py +++ b/homeassistant/components/isy994/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from homeassistant.components import system_health diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index 87cb450d08b..e69da2428eb 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -1,7 +1,5 @@ """ISY utils.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json index 68b34b4321e..ba3a1cfb727 100644 --- a/homeassistant/components/itach/manifest.json +++ b/homeassistant/components/itach/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/itach", "iot_class": "assumed_state", "quality_scale": "legacy", - "requirements": ["pyitachip2ir==0.0.7"] + "requirements": ["pyitachip2ir2==0.0.8"] } diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py index 9b53525bd9a..c05f8ab4c98 100644 --- a/homeassistant/components/itach/remote.py +++ b/homeassistant/components/itach/remote.py @@ -1,7 +1,5 @@ """Support for iTach IR devices.""" -from __future__ import annotations - from collections.abc import Iterable import logging from typing import Any diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 373f1003b0a..b8204de74d1 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing to iTunes API.""" -from __future__ import annotations - from typing import Any import requests diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index 41392c5cee1..f0c5c20ed39 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -1,7 +1,5 @@ """The Ituran integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ituran/binary_sensor.py b/homeassistant/components/ituran/binary_sensor.py index 506e38d2625..5b4a7a837d6 100644 --- a/homeassistant/components/ituran/binary_sensor.py +++ b/homeassistant/components/ituran/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for Ituran vehicles.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/ituran/config_flow.py b/homeassistant/components/ituran/config_flow.py index 9709e471503..1e4942fc5e6 100644 --- a/homeassistant/components/ituran/config_flow.py +++ b/homeassistant/components/ituran/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ituran integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 5f816709864..e2d0bf71e8c 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker for Ituran vehicles.""" -from __future__ import annotations - from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/ituran/entity.py b/homeassistant/components/ituran/entity.py index 597cdac9513..2b35ffa5036 100644 --- a/homeassistant/components/ituran/entity.py +++ b/homeassistant/components/ituran/entity.py @@ -1,7 +1,5 @@ """Base for all turan entities.""" -from __future__ import annotations - from pyituran import Vehicle from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index 53e893b8927..2a85982e8bf 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -1,7 +1,5 @@ """Sensors for Ituran vehicles.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 1fd9a03e05f..9f5e0d2e9c3 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -9,14 +9,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DATA_CONFIG, IZONE +from .const import DATA_CONFIG, DOMAIN from .discovery import async_start_discovery_service, async_stop_discovery_service PLATFORMS = [Platform.CLIMATE] CONFIG_SCHEMA = vol.Schema( { - IZONE: vol.Schema( + DOMAIN: vol.Schema( { vol.Optional(CONF_EXCLUDE, default=[]): vol.All( cv.ensure_list, [cv.string] @@ -32,13 +32,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" # Check for manually added config, this may exclude some devices - if conf := config.get(IZONE): + if conf := config.get(DOMAIN): hass.data[DATA_CONFIG] = conf # Explicitly added in the config file, create a config entry. hass.async_create_task( hass.config_entries.flow.async_init( - IZONE, context={"source": config_entries.SOURCE_IMPORT} + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index f0fd93834e1..ef53788ef6d 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,7 +1,5 @@ """Support for the iZone HVAC.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import logging from typing import Any, Concatenate @@ -45,7 +43,7 @@ from .const import ( DISPATCH_CONTROLLER_RECONNECTED, DISPATCH_CONTROLLER_UPDATE, DISPATCH_ZONE_UPDATE, - IZONE, + DOMAIN, ) type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] @@ -96,7 +94,7 @@ async def async_setup_entry( async_add_entities(device.zones.values()) # create any components not yet created - for controller in disco.pi_disco.controllers.values(): + for controller in (await disco.pi_disco.fetch_controllers()).values(): init_controller(controller) # connect to register any further components @@ -190,7 +188,7 @@ class ControllerDevice(ClimateEntity): self._attr_unique_id = controller.device_uid self._attr_device_info = DeviceInfo( - identifiers={(IZONE, controller.device_uid)}, + identifiers={(DOMAIN, controller.device_uid)}, manufacturer="IZone", model=controller.sys_type, name=f"iZone Controller {controller.device_uid}", @@ -343,7 +341,10 @@ class ControllerDevice(ClimateEntity): @property def control_zone_name(self): - """Return the zone that currently controls the AC unit (if target temp not set by controller).""" + """Return the zone that currently controls the AC unit. + + Only relevant if target temp not set by controller. + """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: return None zone_ctrl = self._controller.zone_ctrl @@ -354,7 +355,10 @@ class ControllerDevice(ClimateEntity): @property def control_zone_setpoint(self) -> float | None: - """Return the temperature setpoint of the zone that currently controls the AC unit (if target temp not set by controller).""" + """Return the temperature setpoint of the controlling zone. + + Only relevant if target temp not set by controller. + """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: return None zone_ctrl = self._controller.zone_ctrl @@ -366,7 +370,10 @@ class ControllerDevice(ClimateEntity): @property @_return_on_connection_error() def target_temperature(self) -> float | None: - """Return the temperature we try to reach (either from control zone or master unit).""" + """Return the temperature we try to reach. + + Either from control zone or master unit. + """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: return self._controller.temp_setpoint return self.control_zone_setpoint @@ -477,12 +484,12 @@ class ZoneDevice(ClimateEntity): assert controller.unique_id self._attr_device_info = DeviceInfo( identifiers={ - (IZONE, controller.unique_id, zone.index) # type:ignore[arg-type] + (DOMAIN, controller.unique_id, zone.index) # type:ignore[arg-type] }, manufacturer="IZone", model=zone.type.name.title(), name=zone.name.title(), - via_device=(IZONE, controller.unique_id), + via_device=(DOMAIN, controller.unique_id), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index d56fb93d4e6..762949b7e18 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DISPATCH_CONTROLLER_DISCOVERED, IZONE, TIMEOUT_DISCOVERY +from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY from .discovery import async_start_discovery_service, async_stop_discovery_service _LOGGER = logging.getLogger(__name__) @@ -29,13 +29,14 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() - if not disco.pi_disco.controllers: + controllers = await disco.pi_disco.fetch_controllers() + if not controllers: await async_stop_discovery_service(hass) _LOGGER.debug("No controllers found") return False - _LOGGER.debug("Controllers %s", disco.pi_disco.controllers) + _LOGGER.debug("Controllers %s", controllers) return True -config_entry_flow.register_discovery_flow(IZONE, "iZone Aircon", _async_has_devices) +config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices) diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py index fdee8dc7228..99d75bf92b5 100644 --- a/homeassistant/components/izone/const.py +++ b/homeassistant/components/izone/const.py @@ -1,6 +1,6 @@ """Constants used by the izone component.""" -IZONE = "izone" +DOMAIN = "izone" DATA_DISCOVERY_SERVICE = "izone_discovery" DATA_CONFIG = "izone_config" diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index 80b2c1c2dad..47fc9b2f926 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], - "requirements": ["python-izone==1.2.9"] + "requirements": ["python-izone==1.2.10"] } diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index c289f28e09a..66e709851e5 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -1,7 +1,5 @@ """Support for media browsing.""" -from __future__ import annotations - import asyncio from functools import partial from typing import Any diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 4855231184e..6909f2c84ab 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -1,7 +1,5 @@ """Utility methods for initializing a Jellyfin client.""" -from __future__ import annotations - import socket from typing import Any @@ -101,10 +99,12 @@ def get_artwork_url( parent_backdrop_id: str | None = item.get("ParentBackdropItemId") if "AlbumPrimaryImageTag" in item: - # jellyfin_apiclient_python doesn't support passing a specific tag to `.artwork`, - # so we don't use the actual value of AlbumPrimaryImageTag. - # However, its mere presence tells us that the album does have primary artwork, - # and the resulting URL will pull the primary album art even if the tag is not specified. + # jellyfin_apiclient_python doesn't support passing + # a specific tag to `.artwork`, so we don't use the + # actual value of AlbumPrimaryImageTag. However, its + # mere presence tells us that the album does have + # primary artwork, and the resulting URL will pull the + # primary album art even if the tag is not specified. artwork_type = "Primary" artwork_id = item["AlbumId"] elif "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 03c637a989f..2549e75093e 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Jellyfin integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index 30149453ba3..a0daf42a618 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Jellyfin integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/jellyfin/diagnostics.py b/homeassistant/components/jellyfin/diagnostics.py index 721e0ae654e..4d0303bcb22 100644 --- a/homeassistant/components/jellyfin/diagnostics.py +++ b/homeassistant/components/jellyfin/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Jellyfin.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py index 107a67d6a89..ff7cf69e42e 100644 --- a/homeassistant/components/jellyfin/entity.py +++ b/homeassistant/components/jellyfin/entity.py @@ -1,7 +1,5 @@ """Base Entity for Jellyfin.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 2be3090410e..8598b6295b0 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -1,7 +1,5 @@ """Support for the Jellyfin media player.""" -from __future__ import annotations - import logging from typing import Any @@ -152,7 +150,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): self._attr_state = state self._attr_is_volume_muted = volume_muted - # Only update volume_level if the API provides it, otherwise preserve current value + # Only update volume_level if the API provides it, + # otherwise preserve current value if volume_level is not None: self._attr_volume_level = volume_level self._attr_media_content_type = media_content_type @@ -168,7 +167,6 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): self._attr_media_duration = media_duration self._attr_media_position = media_position self._attr_media_position_updated_at = media_position_updated - self._attr_media_image_remotely_accessible = True @property def media_image_url(self) -> str | None: @@ -288,7 +286,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): ) -> BrowseMedia: """Return a BrowseMedia instance. - The BrowseMedia instance will be used by the "media_player/browse_media" websocket command. + The BrowseMedia instance will be used by the + "media_player/browse_media" websocket command. """ if media_content_id is None or media_content_id == "media-source://jellyfin": diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 7dc0745a51e..8a9a437e966 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -1,7 +1,5 @@ """The Media Source implementation for the Jellyfin integration.""" -from __future__ import annotations - import logging import mimetypes import os @@ -54,11 +52,7 @@ _LOGGER = logging.getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Jellyfin media source.""" - # Currently only a single Jellyfin server is supported - entry: JellyfinConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = entry.runtime_data - - return JellyfinSource(hass, coordinator.api_client, entry) + return JellyfinSource(hass) class JellyfinSource(MediaSource): @@ -66,21 +60,28 @@ class JellyfinSource(MediaSource): name: str = "Jellyfin" - def __init__( - self, hass: HomeAssistant, client: JellyfinClient, entry: JellyfinConfigEntry - ) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Jellyfin media source.""" super().__init__(DOMAIN) - self.hass = hass - self.entry = entry + self.entry: JellyfinConfigEntry + self.client: JellyfinClient + self.api: Any + self.url: str - self.client = client - self.api = client.jellyfin - self.url = jellyfin_url(client, "") + def _ensure_loaded(self) -> None: + """Ensure the Jellyfin integration is loaded and set up instance state.""" + if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)): + raise BrowseError("Jellyfin integration not loaded") + entry: JellyfinConfigEntry = entries[0] + self.entry = entry + self.client = entry.runtime_data.api_client + self.api = self.client.jellyfin + self.url = jellyfin_url(self.client, "") async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Return a streamable URL and associated mime type.""" + self._ensure_loaded() media_item = await self.hass.async_add_executor_job( self.api.get_item, item.identifier ) @@ -88,13 +89,15 @@ class JellyfinSource(MediaSource): stream_url = self._get_stream_url(media_item) mime_type = _media_mime_type(media_item) - # Media Sources without a mime type have been filtered out during library creation + # Media Sources without a mime type have been filtered + # out during library creation assert mime_type is not None return PlayMedia(stream_url, mime_type) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return a browsable Jellyfin media source.""" + self._ensure_loaded() if not item.identifier: return await self._build_libraries() diff --git a/homeassistant/components/jellyfin/remote.py b/homeassistant/components/jellyfin/remote.py index 27a0b131ca0..892ce7cedbe 100644 --- a/homeassistant/components/jellyfin/remote.py +++ b/homeassistant/components/jellyfin/remote.py @@ -1,7 +1,5 @@ """Support for Jellyfin remote commands.""" -from __future__ import annotations - from collections.abc import Iterable import time from typing import Any diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index e1100a9f43b..8e3ec8fa035 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -1,7 +1,5 @@ """Support for Jellyfin sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/jellyfin/services.py b/homeassistant/components/jellyfin/services.py index d829d4a1ff0..eeb9f303cbb 100644 --- a/homeassistant/components/jellyfin/services.py +++ b/homeassistant/components/jellyfin/services.py @@ -1,7 +1,5 @@ """Services for the Jellyfin integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -27,7 +25,9 @@ def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]: if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict): if ATTR_MEDIA_CONTENT_ID in data: raise vol.Invalid( - f"Play media cannot contain both '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}'" + "Play media cannot contain both" + f" '{ATTR_MEDIA}' and" + f" '{ATTR_MEDIA_CONTENT_ID}'" ) media_data = data[ATTR_MEDIA] diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 34189d4ab09..d68de205974 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -1,7 +1,5 @@ """The jewish_calendar component.""" -from __future__ import annotations - from functools import partial import logging @@ -34,7 +32,7 @@ from .entity import JewishCalendarConfigEntry from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 205691bc183..63f865bad4d 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Jewish Calendar binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import datetime as dt diff --git a/homeassistant/components/jewish_calendar/calendar.py b/homeassistant/components/jewish_calendar/calendar.py new file mode 100644 index 00000000000..435ccd5cf48 --- /dev/null +++ b/homeassistant/components/jewish_calendar/calendar.py @@ -0,0 +1,293 @@ +"""Jewish Calendar calendar platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, date, datetime, time, timedelta +import logging + +from hdate import HDateInfo, Zmanim +from hdate.parasha import Parasha + +from homeassistant.components.calendar import ( + CalendarEntity, + CalendarEntityDescription, + CalendarEvent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_DAILY_EVENTS, + CONF_LEARNING_SCHEDULE, + CONF_YEARLY_EVENTS, + DEFAULT_CALENDAR_EVENTS, + DailyCalendarEventType, + LearningScheduleEventType, + YearlyCalendarEventType, +) +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + +_SATURDAY = 5 +_SIMCHAT_TORAH = "simchat_torah" + + +type JewishCalendarEventType = ( + DailyCalendarEventType | LearningScheduleEventType | YearlyCalendarEventType +) + + +@dataclass(frozen=True, kw_only=True) +class JewishCalendarCalendarEntityDescription(CalendarEntityDescription): + """Jewish Calendar calendar entity description.""" + + value_fn: Callable[ + [JewishCalendarEventType, date, HDateInfo, Zmanim], + list[CalendarEvent] | CalendarEvent | None, + ] + + +def _create_daily_event( + event_type: JewishCalendarEventType, + target_date: date, + info: HDateInfo, + zmanim: Zmanim, +) -> CalendarEvent | None: + """Create a daily calendar event.""" + # Hebrew date + if event_type == DailyCalendarEventType.DATE: + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(info.hdate), + description=f"Hebrew date: {info.hdate}", + ) + + # Time-based daily events using enum properties + daily_event = DailyCalendarEventType(event_type) + time_value = zmanim.zmanim.get(daily_event.value) + + if time_value is not None: + return CalendarEvent( + start=time_value.utc, + end=time_value.utc, + summary=daily_event.summary, + description=f"{daily_event.description_prefix}: {time_value.local.strftime('%H:%M')}", + ) + + return None # Should never happen + + +def _create_yearly_event( + event_type: JewishCalendarEventType, + target_date: date, + info: HDateInfo, + zmanim: Zmanim, +) -> list[CalendarEvent] | CalendarEvent | None: + """Create a yearly calendar event.""" + if event_type == YearlyCalendarEventType.HOLIDAY and info.holidays: + return [ + CalendarEvent( + start=target_date, + end=target_date, + summary=str(holiday), + description=( + f"Jewish Holiday: {holiday}\nHoliday Type: {holiday.type}" + ), + ) + for holiday in info.holidays + ] + + if event_type == YearlyCalendarEventType.WEEKLY_PORTION: + is_shabbat = target_date.weekday() == _SATURDAY + is_simchat_torah = any( + holiday.name == _SIMCHAT_TORAH for holiday in info.holidays + ) + if (is_shabbat or is_simchat_torah) and info.parasha != str(Parasha.NONE): + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(info.parasha), + description=f"Parshat Hashavua: {info.parasha}", + ) + return None + + if event_type == YearlyCalendarEventType.OMER_COUNT and info.omer.total_days > 0: + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(info.omer), + description=f"Sefirat HaOmer: {info.omer.count_str()}", + ) + + if event_type == YearlyCalendarEventType.CANDLE_LIGHTING and zmanim.candle_lighting: + return CalendarEvent( + start=zmanim.candle_lighting.astimezone(UTC), + end=zmanim.candle_lighting.astimezone(UTC), + summary="Candle Lighting", + description=f"Candle lighting time: {zmanim.candle_lighting.strftime('%H:%M')}", + ) + + if event_type == YearlyCalendarEventType.HAVDALAH and zmanim.havdalah: + return CalendarEvent( + start=zmanim.havdalah.astimezone(UTC), + end=zmanim.havdalah.astimezone(UTC), + summary="Havdalah", + description=f"Havdalah time: {zmanim.havdalah.strftime('%H:%M')}", + ) + + return None + + +def _create_learning_event( + event_type: JewishCalendarEventType, + target_date: date, + info: HDateInfo, + zmanim: Zmanim, +) -> CalendarEvent | None: + """Create a learning schedule event.""" + if event_type == LearningScheduleEventType.DAF_YOMI and info.daf_yomi: + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(info.daf_yomi), + description=f"Daf Yomi: {info.daf_yomi}", + ) + + return None + + +CALENDARS = ( + JewishCalendarCalendarEntityDescription( + key=CONF_DAILY_EVENTS, + translation_key=CONF_DAILY_EVENTS, + value_fn=_create_daily_event, + ), + JewishCalendarCalendarEntityDescription( + key=CONF_LEARNING_SCHEDULE, + translation_key=CONF_LEARNING_SCHEDULE, + value_fn=_create_learning_event, + entity_registry_enabled_default=False, + ), + JewishCalendarCalendarEntityDescription( + key=CONF_YEARLY_EVENTS, + translation_key=CONF_YEARLY_EVENTS, + value_fn=_create_yearly_event, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: JewishCalendarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Jewish Calendar config entry.""" + async_add_entities( + JewishCalendar(config_entry, description) for description in CALENDARS + ) + + +class JewishCalendar(JewishCalendarEntity, CalendarEntity): + """Representation of a Jewish Calendar element.""" + + entity_description: JewishCalendarCalendarEntityDescription + + def __init__( + self, + config_entry: JewishCalendarConfigEntry, + description: JewishCalendarCalendarEntityDescription, + ) -> None: + """Initialize the calendar entity.""" + super().__init__(config_entry, description) + self._events_config = config_entry.options.get( + description.key, DEFAULT_CALENDAR_EVENTS[description.key] + ) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + # Get today's events first + if _event := self._get_next_event(dt_util.now()): + return _event + + # Look for the next event in the next 30 days + today = dt_util.now().date() + for days_ahead in range(1, 31): + future_date = today + timedelta(days=days_ahead) + if _event := self._get_next_event(future_date): + return _event + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + events = [] + + # Convert datetime to date for iteration + start_ordinal = start_date.date().toordinal() + end_ordinal = end_date.date().toordinal() + + for ordinal in range(start_ordinal, end_ordinal + 1): + current_date = date.fromordinal(ordinal) + day_events = self._get_events_for_date(current_date) + events.extend(day_events) + + # Filter out events not in start/end range + return self._filter_start_end(events, start_date, end_date) + + def _get_next_event(self, _date: date | datetime) -> CalendarEvent | None: + """For a given datetime or date, return the next event.""" + if not isinstance(_date, datetime): + _date = datetime.combine(_date, datetime.min.time(), tzinfo=UTC) + + if events := self._get_events_for_date(_date.date()): + if filtered_events := self._filter_start_end(events, _date): + return filtered_events[0] + + return None + + def _filter_start_end( + self, events: list[CalendarEvent], start: datetime, end: datetime | None = None + ) -> list[CalendarEvent]: + """Keep only the events that are in the start-end range specified.""" + return [ + e + for e in events + if start <= self._date_to_dt(e.start, time.max) + and (end is None or self._date_to_dt(e.start, time.max) <= end) + ] + + def _event_sort_key(self, event: CalendarEvent) -> datetime: + """Return a key for calendar events based on event start.""" + return self._date_to_dt(event.start, time.min) + + def _date_to_dt(self, val: date | datetime, _time: time) -> datetime: + """Return a datetime for comparison.""" + if isinstance(val, datetime): + return val + return datetime.combine(val, _time, tzinfo=UTC) + + def _get_events_for_date(self, target_date: date) -> list[CalendarEvent]: + """Get all configured events for a specific date.""" + events = [] + + info = HDateInfo(target_date, self.coordinator.data.diaspora) + zmanim = self.coordinator.make_zmanim(target_date) + + for event_type in self._events_config: + if _events := self.entity_description.value_fn( + event_type, target_date, info, zmanim + ): + events.extend(_events if isinstance(_events, list) else [_events]) + + return sorted(events, key=self._event_sort_key) + + def _update_times(self, zmanim: Zmanim) -> list[datetime | None]: + """Return a list of times to update the calendar.""" + # Calendar entities do not require periodic updates besides the retrieval of events. + return [] diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index f52e14537b3..e4d4d8ac060 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Jewish calendar integration.""" -from __future__ import annotations - import logging from typing import Any, get_args import zoneinfo @@ -30,18 +28,26 @@ from homeassistant.helpers.selector import ( LocationSelector, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, ) from .const import ( CONF_CANDLE_LIGHT_MINUTES, + CONF_DAILY_EVENTS, CONF_DIASPORA, CONF_HAVDALAH_OFFSET_MINUTES, + CONF_LEARNING_SCHEDULE, + CONF_YEARLY_EVENTS, + DEFAULT_CALENDAR_EVENTS, DEFAULT_CANDLE_LIGHT, DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, DEFAULT_NAME, DOMAIN, + DailyCalendarEventType, + LearningScheduleEventType, + YearlyCalendarEventType, ) from .entity import JewishCalendarConfigEntry @@ -51,6 +57,39 @@ OPTIONS_SCHEMA = vol.Schema( vol.Optional( CONF_HAVDALAH_OFFSET_MINUTES, default=DEFAULT_HAVDALAH_OFFSET_MINUTES ): int, + vol.Optional( + CONF_DAILY_EVENTS, + default=DEFAULT_CALENDAR_EVENTS[CONF_DAILY_EVENTS], + ): SelectSelector( + SelectSelectorConfig( + options=list(DailyCalendarEventType), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_DAILY_EVENTS, + ) + ), + vol.Optional( + CONF_LEARNING_SCHEDULE, + default=DEFAULT_CALENDAR_EVENTS[CONF_LEARNING_SCHEDULE], + ): SelectSelector( + SelectSelectorConfig( + options=list(LearningScheduleEventType), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LEARNING_SCHEDULE, + ) + ), + vol.Optional( + CONF_YEARLY_EVENTS, + default=DEFAULT_CALENDAR_EVENTS[CONF_YEARLY_EVENTS], + ): SelectSelector( + SelectSelectorConfig( + options=list(YearlyCalendarEventType), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_YEARLY_EVENTS, + ) + ), } ) diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index b3a0dea5da0..4780e516601 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -1,15 +1,20 @@ """Jewish Calendar constants.""" +from enum import StrEnum +from typing import TYPE_CHECKING, Self + DOMAIN = "jewish_calendar" ATTR_AFTER_SUNSET = "after_sunset" -ATTR_DATE = "date" ATTR_NUSACH = "nusach" CONF_ALTITUDE = "altitude" # The name used by the hdate library for elevation CONF_DIASPORA = "diaspora" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" +CONF_DAILY_EVENTS = "daily_events" +CONF_YEARLY_EVENTS = "yearly_events" +CONF_LEARNING_SCHEDULE = "learning_schedule" DEFAULT_NAME = "Jewish Calendar" DEFAULT_CANDLE_LIGHT = 18 @@ -17,4 +22,100 @@ DEFAULT_DIASPORA = False DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 DEFAULT_LANGUAGE = "en" + +class DailyCalendarEventType(StrEnum): + """Daily Calendar event types with metadata.""" + + DATE = "date" + ALOT_HASHACHAR = ( + "alot_hashachar", + "Alot Hashachar", # codespell:ignore alot + "Halachic dawn", + ) + NETZ_HACHAMA = ("netz_hachama", "Netz Hachama", "Halachic sunrise") + SOF_ZMAN_SHEMA_GRA = ( + "sof_zman_shema_gra", + 'Sof Zman Shema (Gr"A)', # codespell:ignore shema + "Latest time for Shema", # codespell:ignore shema + ) + SOF_ZMAN_SHEMA_MGA = ( + "sof_zman_shema_mga", + 'Sof Zman Shema (Mg"A)', # codespell:ignore shema + "Latest time for Shema", # codespell:ignore shema + ) + SOF_ZMAN_TFILLA_GRA = ( + "sof_zman_tfilla_gra", + 'Sof Zman Tefilla (Gr"A)', + "Latest time for Tefilla", + ) + SOF_ZMAN_TFILLA_MGA = ( + "sof_zman_tfilla_mga", + 'Sof Zman Tefilla (Mg"A)', + "Latest time for Tefilla", + ) + CHATZOT_HAYOM = ("chatzot_hayom", "Chatzot Hayom", "Halachic midday") + MINCHA_GEDOLA = ("mincha_gedola", "Mincha Gedola", "Earliest time for Mincha") + MINCHA_KETANA = ("mincha_ketana", "Mincha Ketana", "Preferable time for Mincha") + PLAG_HAMINCHA = ("plag_hamincha", "Plag Hamincha", "Plag Hamincha") + SHKIA = ("shkia", "Shkia", "Sunset") + TSET_HAKOHAVIM = ("tset_hakohavim_tsom", "T'set Hakochavim", "Nightfall") + + if TYPE_CHECKING: + _summary: str + _description_prefix: str + + def __new__( + cls, value: str, summary: str = "", description_prefix: str = "" + ) -> Self: + """Create new enum member with additional attributes.""" + obj = str.__new__(cls, value) + obj._value_ = value + obj._summary = summary # noqa: SLF001 + obj._description_prefix = description_prefix # noqa: SLF001 + return obj + + @property + def summary(self) -> str: + """Return the summary for the event.""" + return self._summary + + @property + def description_prefix(self) -> str: + """Return the description prefix for the event.""" + return self._description_prefix + + +class YearlyCalendarEventType(StrEnum): + """Yearly Calendar event types.""" + + HOLIDAY = "holiday" + WEEKLY_PORTION = "weekly_portion" + OMER_COUNT = "omer_count" + CANDLE_LIGHTING = "candle_lighting" + HAVDALAH = "havdalah" + + +class LearningScheduleEventType(StrEnum): + """Learning Schedule event types.""" + + DAF_YOMI = "daf_yomi" + + +DEFAULT_CALENDAR_EVENTS = { + CONF_DAILY_EVENTS: [ + DailyCalendarEventType.DATE, + DailyCalendarEventType.NETZ_HACHAMA, + DailyCalendarEventType.SHKIA, + DailyCalendarEventType.TSET_HAKOHAVIM, + ], + CONF_LEARNING_SCHEDULE: [LearningScheduleEventType.DAF_YOMI], + CONF_YEARLY_EVENTS: [ + YearlyCalendarEventType.HOLIDAY, + YearlyCalendarEventType.WEEKLY_PORTION, + YearlyCalendarEventType.CANDLE_LIGHTING, + YearlyCalendarEventType.HAVDALAH, + ], +} + + SERVICE_COUNT_OMER = "count_omer" diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index b9fff1d0f50..a729fcd1bc7 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -52,8 +52,9 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - # When coordinator updates (e.g., from tests forcing refresh or midnight update), - # reschedule our entity-specific updates + # When coordinator updates (e.g., from tests forcing + # refresh or midnight update), reschedule our + # entity-specific updates self._schedule_update() super()._handle_coordinator_update() diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json index b3997bcc9e8..178e66c33e4 100644 --- a/homeassistant/components/jewish_calendar/icons.json +++ b/homeassistant/components/jewish_calendar/icons.json @@ -1,10 +1,15 @@ { "entity": { "binary_sensor": { - "erev_shabbat_hag": { "default": "mdi:candle-light" }, + "erev_shabbat_hag": { "default": "mdi:candle" }, "issur_melacha_in_effect": { "default": "mdi:power-plug-off" }, "motzei_shabbat_hag": { "default": "mdi:fire" } }, + "calendar": { + "daily_events": { "default": "mdi:calendar" }, + "learning_schedule": { "default": "mdi:book-open" }, + "yearly_events": { "default": "mdi:calendar" } + }, "sensor": { "alot_hashachar": { "default": "mdi:weather-sunset-up" }, "chatzot_hayom": { "default": "mdi:calendar-clock" }, diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 1ab967ecfa4..0cbb0df787e 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.1.2"], + "requirements": ["hdate[astral]==1.2.1"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index ee008950de9..3a03ea579f2 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,7 +1,5 @@ """Support for Jewish calendar sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import datetime as dt @@ -90,6 +88,9 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( dict.fromkeys(_holiday.type.name for _holiday in info.holidays) ), }, + next_update_fn=lambda zmanim: ( + zmanim.candle_lighting or zmanim.havdalah or zmanim.shkia.local + ), ), JewishCalendarSensorDescription( key="omer_count", diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index f77f9be4e64..08714282a9d 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -9,7 +9,7 @@ from hdate.omer import Nusach, Omer from hdate.translator import Language, set_language import voluptuous as vol -from homeassistant.const import CONF_LANGUAGE, SUN_EVENT_SUNSET +from homeassistant.const import ATTR_DATE, CONF_LANGUAGE, SUN_EVENT_SUNSET from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,7 +23,7 @@ from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorCon from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import dt as dt_util -from .const import ATTR_AFTER_SUNSET, ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER +from .const import ATTR_AFTER_SUNSET, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER _LOGGER = logging.getLogger(__name__) OMER_SCHEMA = vol.Schema( diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index fe7ed69b9c1..ff1ee29d3c8 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -60,6 +60,17 @@ "name": "Motzei Shabbat/Hag" } }, + "calendar": { + "daily_events": { + "name": "Daily events" + }, + "learning_schedule": { + "name": "Learning schedule" + }, + "yearly_events": { + "name": "Yearly events" + } + }, "sensor": { "alot_hashachar": { "name": "Halachic dawn (Alot Hashachar)" @@ -153,17 +164,45 @@ "init": { "data": { "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighting", - "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah" + "daily_events": "Daily events to display", + "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah", + "learning_schedule": "Learning schedule to display", + "yearly_events": "Yearly events to display" }, "data_description": { "candle_lighting_minutes_before_sunset": "Defaults to 18 minutes. In Israel you probably want to use 20/30/40 depending on your location. Outside of Israel you probably want to use 18/24.", - "havdalah_minutes_after_sunset": "Setting this to 0 means 36 minutes as fixed degrees (8.5°) will be used instead" + "daily_events": "Select which daily Jewish calendar events should be displayed in the Daily Events calendar", + "havdalah_minutes_after_sunset": "Setting this to 0 means 36 minutes as fixed degrees (8.5°) will be used instead", + "learning_schedule": "Select which learning schedules should be displayed in the Learning Schedule calendar", + "yearly_events": "Select which yearly Jewish calendar events should be displayed in the Yearly Events calendar" }, "title": "Configure options for Jewish Calendar" } } }, "selector": { + "daily_events": { + "options": { + "alot_hashachar": "Alot Hashachar (dawn)", + "chatzot_hayom": "Chatzot Hayom (midday)", + "date": "Hebrew date", + "mincha_gedola": "Mincha Gedola", + "mincha_ketana": "Mincha Ketana", + "netz_hachama": "Netz Hachama (sunrise)", + "plag_hamincha": "Plag Hamincha", + "shkia": "Shkia (sunset)", + "sof_zman_shema_gra": "Sof Zman Shema (Gr\"A)", + "sof_zman_shema_mga": "Sof Zman Shema (Mg\"A)", + "sof_zman_tfilla_gra": "Sof Zman Tefilla (Gr\"A)", + "sof_zman_tfilla_mga": "Sof Zman Tefilla (Mg\"A)", + "tset_hakohavim_tsom": "T'set Hakochavim (nightfall)" + } + }, + "learning_schedule": { + "options": { + "daf_yomi": "Daf Yomi" + } + }, "nusach": { "options": { "adot_mizrah": "Adot Mizrah", @@ -171,6 +210,15 @@ "italian": "Italian", "sfarad": "Sfarad" } + }, + "yearly_events": { + "options": { + "candle_lighting": "Candle lighting", + "havdalah": "Havdalah", + "holiday": "Jewish holidays", + "omer_count": "Omer count", + "weekly_portion": "Torah portion" + } } }, "services": { diff --git a/homeassistant/components/joaoapps_join/manifest.json b/homeassistant/components/joaoapps_join/manifest.json index 55a908bf090..e239bab54fe 100644 --- a/homeassistant/components/joaoapps_join/manifest.json +++ b/homeassistant/components/joaoapps_join/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyjoin"], "quality_scale": "legacy", - "requirements": ["python-join-api==0.0.9"] + "requirements": ["python-join-api==0.1.1"] } diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py index 6a1e7bb8e6d..01d0cf233d1 100644 --- a/homeassistant/components/joaoapps_join/notify.py +++ b/homeassistant/components/joaoapps_join/notify.py @@ -1,7 +1,5 @@ """Support for Join notifications.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 5f369027b00..80066a6a17e 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -1,7 +1,5 @@ """The JustNimbus integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed diff --git a/homeassistant/components/justnimbus/config_flow.py b/homeassistant/components/justnimbus/config_flow.py index 7b0d3f8e5db..f2d50ea7205 100644 --- a/homeassistant/components/justnimbus/config_flow.py +++ b/homeassistant/components/justnimbus/config_flow.py @@ -1,7 +1,5 @@ """Config flow for JustNimbus integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index b51058a8e54..e08a7fd91f3 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -1,7 +1,5 @@ """JustNimbus coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index 1d0e6a4c1bc..87a123b25d0 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -1,7 +1,5 @@ """Base Entity for JustNimbus sensors.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 88f12cad113..5e557595dc0 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -1,7 +1,5 @@ """Support for the JustNimbus platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py index a12bea0e158..0bac933505b 100644 --- a/homeassistant/components/jvc_projector/__init__.py +++ b/homeassistant/components/jvc_projector/__init__.py @@ -1,7 +1,5 @@ """The jvc_projector integration.""" -from __future__ import annotations - from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorTimeoutError from homeassistant.const import ( diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py index 55c8ab765c3..ef762701eb0 100644 --- a/homeassistant/components/jvc_projector/binary_sensor.py +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for JVC Projector integration.""" -from __future__ import annotations - from jvcprojector import command as cmd from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py index 26131f687c2..b7ba49fed87 100644 --- a/homeassistant/components/jvc_projector/config_flow.py +++ b/homeassistant/components/jvc_projector/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the jvc_projector integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index cbde80b65bc..65ca509485f 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -1,13 +1,16 @@ """Data update coordinator for the jvc_projector integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging from typing import TYPE_CHECKING, Any -from jvcprojector import JvcProjector, JvcProjectorTimeoutError, command as cmd +from jvcprojector import ( + JvcProjector, + JvcProjectorCommandError, + JvcProjectorTimeoutError, + command as cmd, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -78,7 +81,8 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): new_state = await self._get_device_state(commands) break except JvcProjectorTimeoutError as err: - # Timeouts are expected when the projector loses signal and ignores commands for a brief time. + # Timeouts are expected when the projector + # loses signal and ignores commands briefly. last_timeout = err await asyncio.sleep(TIMEOUT_SLEEP) else: @@ -144,7 +148,16 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): self, command: type[Command], new_state: dict[type[Command], str] ) -> str | None: """Update state with the current value of a command.""" - value = await self.device.get(command) + try: + value = await self.device.get(command) + except JvcProjectorCommandError as err: + _LOGGER.warning("Command %s failed: %s", command.name, err) + cached = self.state.get(command) + if command is cmd.Power and cached is None: + raise UpdateFailed( + f"Failed to fetch {command.name} and no cached value is available" + ) from err + return cached if value != self.state.get(command): new_state[command] = value diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py index 4bb084dc7f9..c7b545a2532 100644 --- a/homeassistant/components/jvc_projector/entity.py +++ b/homeassistant/components/jvc_projector/entity.py @@ -1,7 +1,5 @@ """Base Entity for the jvc_projector integration.""" -from __future__ import annotations - import logging from jvcprojector import Command, JvcProjector diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 389b9ff2b55..d2913b5dd90 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -1,11 +1,11 @@ { "domain": "jvc_projector", "name": "JVC Projector", - "codeowners": ["@SteveEasley", "@msavazzi"], + "codeowners": ["@SteveEasley"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jvc_projector", "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.5"] + "requirements": ["pyjvcprojector==2.0.6"] } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index 07a8d1c835b..9f5e3f95da4 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -1,7 +1,5 @@ """Remote platform for the jvc_projector integration.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 4d2d48dd1c6..9b55c1d3b40 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -1,7 +1,5 @@ """Select platform for the jvc_projector integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index 8267e62f2bf..70ea6ceb5d8 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for JVC Projector integration.""" -from __future__ import annotations - from dataclasses import dataclass from jvcprojector import Command, command as cmd diff --git a/homeassistant/components/jvc_projector/switch.py b/homeassistant/components/jvc_projector/switch.py index ae80c7bf109..b37bd08b5b0 100644 --- a/homeassistant/components/jvc_projector/switch.py +++ b/homeassistant/components/jvc_projector/switch.py @@ -1,7 +1,5 @@ """Switch platform for the jvc_projector integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/jvc_projector/util.py b/homeassistant/components/jvc_projector/util.py index e37ceaab934..66b17c0574a 100644 --- a/homeassistant/components/jvc_projector/util.py +++ b/homeassistant/components/jvc_projector/util.py @@ -1,7 +1,5 @@ """Utility helpers for the jvc_projector integration.""" -from __future__ import annotations - from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.const import Platform @@ -95,7 +93,9 @@ def get_automations_and_scripts_using_entity( # Prefer entity-registry metadata so we can render edit links. if item := entity_registry.async_get(used_entity_id): items.append( - f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + f"- [{item.original_name}]" + f"(/config/{integration}" + f"/edit/{item.unique_id})" ) else: # Keep unresolved references as plain text so they still count as usage. diff --git a/homeassistant/components/kaiterra/air_quality.py b/homeassistant/components/kaiterra/air_quality.py index cdd9f3461ce..2a82eedc1cd 100644 --- a/homeassistant/components/kaiterra/air_quality.py +++ b/homeassistant/components/kaiterra/air_quality.py @@ -1,7 +1,5 @@ """Support for Kaiterra Air Quality Sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.air_quality import AirQualityEntity diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index 22401f9027a..a0b5564342f 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -1,7 +1,5 @@ """Support for Kaiterra Temperature ahn Humidity Sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( diff --git a/homeassistant/components/kaleidescape/__init__.py b/homeassistant/components/kaleidescape/__init__.py index 3f6277b85cc..9b541bf2196 100644 --- a/homeassistant/components/kaleidescape/__init__.py +++ b/homeassistant/components/kaleidescape/__init__.py @@ -1,7 +1,5 @@ """The Kaleidescape integration.""" -from __future__ import annotations - from dataclasses import dataclass from kaleidescape import Device as KaleidescapeDevice, KaleidescapeError diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py index 031709db9f2..cd139bb7797 100644 --- a/homeassistant/components/kaleidescape/config_flow.py +++ b/homeassistant/components/kaleidescape/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Kaleidescape.""" -from __future__ import annotations - from typing import Any, cast from urllib.parse import urlparse diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index f9a67323f82..7057c8a5c49 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -1,7 +1,5 @@ """Base Entity for Kaleidescape.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index 564b0c41c30..cb218e282e7 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -1,7 +1,5 @@ """Kaleidescape Media Player.""" -from __future__ import annotations - from datetime import datetime import logging diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index a71fb7f917a..80c34714671 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -1,7 +1,5 @@ """Sensor platform for Kaleidescape integration.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 8d7365aa20b..d1e4320fe14 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Kaleidescape integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index 0543e45abae..70360be2dd6 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -1,7 +1,5 @@ """Support for customised Kankun SP3 Wifi switch.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/keba/binary_sensor.py b/homeassistant/components/keba/binary_sensor.py index 9f8e0ac3f3e..ed27eab5567 100644 --- a/homeassistant/components/keba/binary_sensor.py +++ b/homeassistant/components/keba/binary_sensor.py @@ -1,7 +1,5 @@ """Support for KEBA charging station binary sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/keba/icons.json b/homeassistant/components/keba/icons.json index 7a07cceab4a..c084eec230f 100644 --- a/homeassistant/components/keba/icons.json +++ b/homeassistant/components/keba/icons.json @@ -7,7 +7,7 @@ "service": "mdi:lock-open" }, "disable": { - "service": "mdi:fash-off" + "service": "mdi:flash-off" }, "enable": { "service": "mdi:flash" diff --git a/homeassistant/components/keba/lock.py b/homeassistant/components/keba/lock.py index be005b92874..045f58e99ab 100644 --- a/homeassistant/components/keba/lock.py +++ b/homeassistant/components/keba/lock.py @@ -1,7 +1,5 @@ """Support for KEBA charging station switch.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import LockEntity diff --git a/homeassistant/components/keba/notify.py b/homeassistant/components/keba/notify.py index 3495e46649c..c3749805730 100644 --- a/homeassistant/components/keba/notify.py +++ b/homeassistant/components/keba/notify.py @@ -1,7 +1,5 @@ """Support for Keba notifications.""" -from __future__ import annotations - from typing import Any from homeassistant.components.notify import ATTR_DATA, BaseNotificationService diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 1878a7f6e49..1f920a581c8 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -1,7 +1,5 @@ """Support for KEBA charging station sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 358f9600845..4f709ab98fe 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -1,10 +1,8 @@ """The keenetic_ndms2 component.""" -from __future__ import annotations - import logging -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -17,7 +15,6 @@ from .const import ( DEFAULT_CONSIDER_HOME, DEFAULT_INTERFACE, DEFAULT_SCAN_INTERVAL, - DOMAIN, ) from .router import KeeneticConfigEntry, KeeneticRouter @@ -27,7 +24,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> bool: """Set up the component.""" - hass.data.setdefault(DOMAIN, {}) async_add_defaults(hass, entry) router = KeeneticRouter(hass, entry) @@ -85,10 +81,8 @@ async def async_unload_entry( return unload_ok -def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): +def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: """Populate default options.""" - host: str = entry.data[CONF_HOST] - imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {}) options = { CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME, @@ -96,7 +90,6 @@ def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): CONF_TRY_HOTSPOT: True, CONF_INCLUDE_ARP: True, CONF_INCLUDE_ASSOCIATED: True, - **imported_options, **entry.options, } diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index cec4796176e..898e1d4dbf7 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Keenetic NDMS2.""" -from __future__ import annotations - from typing import Any, cast from urllib.parse import urlparse @@ -198,6 +196,8 @@ class KeeneticOptionsFlowHandler(OptionsFlowWithReload): options = vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Required( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 94cdb13d79e..9960e89901c 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -1,7 +1,5 @@ """Support for Keenetic routers as device tracker.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 364e921cd40..a58ed7a5e8f 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -1,7 +1,5 @@ """The Keenetic Client class.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index c5f350e00cd..6f9b34c126b 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -1,7 +1,5 @@ """Platform for the KEF Wireless Speakers.""" -from __future__ import annotations - from datetime import timedelta from functools import partial import ipaddress diff --git a/homeassistant/components/kegtron/__init__.py b/homeassistant/components/kegtron/__init__.py index ec2ebee6995..d48800ce20d 100644 --- a/homeassistant/components/kegtron/__init__.py +++ b/homeassistant/components/kegtron/__init__.py @@ -1,7 +1,5 @@ """The Kegtron integration.""" -from __future__ import annotations - import logging from kegtron_ble import KegtronBluetoothDeviceData diff --git a/homeassistant/components/kegtron/config_flow.py b/homeassistant/components/kegtron/config_flow.py index 396692491dc..09589614ea5 100644 --- a/homeassistant/components/kegtron/config_flow.py +++ b/homeassistant/components/kegtron/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Kegtron ble integration.""" -from __future__ import annotations - from typing import Any from kegtron_ble import KegtronBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/kegtron/device.py b/homeassistant/components/kegtron/device.py index 033094e41d7..a5f9550a193 100644 --- a/homeassistant/components/kegtron/device.py +++ b/homeassistant/components/kegtron/device.py @@ -1,7 +1,5 @@ """Support for Kegtron devices.""" -from __future__ import annotations - import logging from kegtron_ble import DeviceKey diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py index f0023e8ef6a..3ead3407476 100644 --- a/homeassistant/components/kegtron/sensor.py +++ b/homeassistant/components/kegtron/sensor.py @@ -1,7 +1,5 @@ """Support for Kegtron sensors.""" -from __future__ import annotations - from kegtron_ble import ( SensorDeviceClass as KegtronSensorDeviceClass, SensorUpdate, @@ -118,7 +116,9 @@ async def async_setup_entry( KegtronBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class KegtronBluetoothSensorEntity( diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 7a5eae0eec6..9e66629ae82 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,7 +1,5 @@ """Receive signals from a keyboard and use it as a remote control.""" -from __future__ import annotations - import asyncio from contextlib import suppress import logging @@ -76,7 +74,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class KeyboardRemote: - """Manage device connection/disconnection using inotify to asynchronously monitor.""" + """Manage device connection/disconnection. + + Uses inotify to asynchronously monitor. + """ def __init__(self, hass: HomeAssistant, config: list[dict[str, Any]]) -> None: """Create handlers and setup dictionaries to keep track of them.""" @@ -196,7 +197,7 @@ class KeyboardRemote: return (dev, handler) async def async_monitor_devices(self): - """Monitor asynchronously for device connection/disconnection or permissions changes.""" + """Monitor for device connection/disconnection or permissions changes.""" _LOGGER.debug("Start monitoring loop") @@ -255,7 +256,10 @@ class KeyboardRemote: self.descriptor = None async def async_device_keyrepeat(self, code, delay, repeat): - """Emulate keyboard delay/repeat behaviour by sending key events on a timer.""" + """Emulate keyboard delay/repeat behaviour. + + Sends key events on a timer. + """ await asyncio.sleep(delay) while True: @@ -275,7 +279,9 @@ class KeyboardRemote: _LOGGER.debug("Keyboard async_device_start_monitoring, %s", dev.name) if self.monitor_task is None: self.dev = dev - # set the descriptor to the one provided to the config if any, falling back to the device path if not set + # set the descriptor to the one provided to + # the config if any, falling back to the + # device path if not set if self.config_descriptor: self.descriptor = self.config_descriptor else: diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 2159dd9d90e..76197d32fe5 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.9.3", "asyncinotify==4.4.0"] + "requirements": ["evdev==1.9.3", "asyncinotify==4.4.4"] } diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 01948006852..6ccd9d09a8e 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -1,7 +1,5 @@ """Integration to integrate Keymitt BLE devices with Home Assistant.""" -from __future__ import annotations - from microbot import MicroBotApiClient from homeassistant.components import bluetooth diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index d5fcf442e4c..31f34ff3294 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for MicroBot.""" -from __future__ import annotations - import logging from typing import Any @@ -14,6 +12,7 @@ from microbot import ( ) import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -85,6 +84,7 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovered_adv: self._discovered_advs[discovery.address] = discovery else: + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass): self._ble_device = discovery_info.device diff --git a/homeassistant/components/keymitt_ble/coordinator.py b/homeassistant/components/keymitt_ble/coordinator.py index 9d2b250ba82..59725bdf65f 100644 --- a/homeassistant/components/keymitt_ble/coordinator.py +++ b/homeassistant/components/keymitt_ble/coordinator.py @@ -1,7 +1,5 @@ """Integration to integrate Keymitt BLE devices with Home Assistant.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py index 94bb1498744..41be3e091bf 100644 --- a/homeassistant/components/keymitt_ble/entity.py +++ b/homeassistant/components/keymitt_ble/entity.py @@ -1,7 +1,5 @@ """MicroBot class.""" -from __future__ import annotations - from typing import Any from homeassistant.components.bluetooth.passive_update_coordinator import ( diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index dab7d8c2d36..97d242107d9 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -1,7 +1,5 @@ """Switch platform for MicroBot.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py new file mode 100644 index 00000000000..2291eb14ec8 --- /dev/null +++ b/homeassistant/components/kiosker/__init__.py @@ -0,0 +1,32 @@ +"""The Kiosker integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: + """Set up Kiosker from a config entry.""" + + coordinator = KioskerDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/kiosker/binary_sensor.py b/homeassistant/components/kiosker/binary_sensor.py new file mode 100644 index 00000000000..4782e76c9bf --- /dev/null +++ b/homeassistant/components/kiosker/binary_sensor.py @@ -0,0 +1,71 @@ +"""Support for Kiosker binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .coordinator import KioskerData +from .entity import KioskerEntity + +# These entities rely on the shared data coordinator instead of per-entity polling. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KioskerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Kiosker binary sensor entity.""" + + value_fn: Callable[[KioskerData], bool] + + +BINARY_SENSORS: tuple[KioskerBinarySensorEntityDescription, ...] = ( + KioskerBinarySensorEntityDescription( + key="blackoutState", + translation_key="blackout_state", + value_fn=lambda x: x.blackout.visible if x.blackout else False, + ), + KioskerBinarySensorEntityDescription( + key="screensaverState", + translation_key="screensaver_state", + value_fn=lambda x: x.screensaver.visible if x.screensaver else False, + ), + KioskerBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda x: ( + (x.status.battery_state or "").casefold() in ("charging", "fully charged") + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker binary sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + KioskerBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class KioskerBinarySensor(KioskerEntity, BinarySensorEntity): + """Representation of a Kiosker binary sensor.""" + + entity_description: KioskerBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/kiosker/button.py b/homeassistant/components/kiosker/button.py new file mode 100644 index 00000000000..d9a3f20b479 --- /dev/null +++ b/homeassistant/components/kiosker/button.py @@ -0,0 +1,99 @@ +"""Button platform for Kiosker.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from kiosker import KioskerAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .entity import KioskerEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class KioskerButtonEntityDescription(ButtonEntityDescription): + """Describe a Kiosker button.""" + + action_fn: Callable[[KioskerAPI], None] | None = None + + +BUTTONS: tuple[KioskerButtonEntityDescription, ...] = ( + KioskerButtonEntityDescription( + key="ping", + translation_key="ping", + entity_category=EntityCategory.DIAGNOSTIC, + action_fn=lambda api: api.ping(), + ), + KioskerButtonEntityDescription( + key="navigateRefresh", + translation_key="navigate_refresh", + action_fn=lambda api: api.navigate_refresh(), + ), + KioskerButtonEntityDescription( + key="navigateHome", + translation_key="navigate_home", + action_fn=lambda api: api.navigate_home(), + ), + KioskerButtonEntityDescription( + key="navigateForward", + translation_key="navigate_forward", + action_fn=lambda api: api.navigate_forward(), + ), + KioskerButtonEntityDescription( + key="navigateBackward", + translation_key="navigate_backward", + action_fn=lambda api: api.navigate_backward(), + ), + KioskerButtonEntityDescription( + key="print", + translation_key="print", + action_fn=lambda api: api.print(), + ), + KioskerButtonEntityDescription( + key="clearCache", + translation_key="clear_cache", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.clear_cache(), + ), + KioskerButtonEntityDescription( + key="clearCookies", + translation_key="clear_cookies", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.clear_cookies(), + ), + KioskerButtonEntityDescription( + key="screensaverInteract", + translation_key="screensaver_interact", + action_fn=lambda api: api.screensaver_interact(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker buttons based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + KioskerButton(coordinator, description) for description in BUTTONS + ) + + +class KioskerButton(KioskerEntity, ButtonEntity): + """Representation of a Kiosker button.""" + + entity_description: KioskerButtonEntityDescription + + async def async_press(self) -> None: + """Handle button press.""" + if action_fn := self.entity_description.action_fn: + await self.hass.async_add_executor_job(action_fn, self.coordinator.api) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py new file mode 100644 index 00000000000..c029d069c2b --- /dev/null +++ b/homeassistant/components/kiosker/config_flow.py @@ -0,0 +1,199 @@ +"""Config flow for the Kiosker integration.""" + +import logging +from typing import Any + +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + PingError, + TLSVerificationError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, + } +) +STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, + } +) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> tuple[dict[str, str], str | None]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns a tuple of (errors dict, device_id). + If validation succeeds, errors will be empty. + """ + api = KioskerAPI( + host=data[CONF_HOST], + port=PORT, + token=data[CONF_API_TOKEN], + ssl=data[CONF_SSL], + verify=data[CONF_VERIFY_SSL], + ) + + try: + # Test connection by getting status + status = await hass.async_add_executor_job(api.status) + except ConnectionError: + return ({"base": "cannot_connect"}, None) + except AuthenticationError: + return ({"base": "invalid_auth"}, None) + except IPAuthenticationError: + return ({"base": "invalid_ip_auth"}, None) + except TLSVerificationError: + return ({"base": "tls_error"}, None) + except BadRequestError: + return ({"base": "bad_request"}, None) + except PingError: + return ({"base": "cannot_connect"}, None) + except Exception: + _LOGGER.exception("Unexpected exception while connecting to Kiosker") + return ({"base": "unknown"}, None) + + # Ensure we have a device_id from the status response + if not status.device_id: + _LOGGER.error("Device did not return a valid device_id") + return ({"base": "cannot_connect"}, None) + + return ({}, status.device_id) + + +class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Kiosker.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + + self._discovered_host: str | None = None + self._discovered_device_id: str | None = None + self._discovered_version: str | None = None + self._discovered_ssl: bool | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + validation_errors, device_id = await validate_input(self.hass, user_input) + if validation_errors: + errors.update(validation_errors) + elif device_id: + # Use device ID as unique identifier + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + # Use first 8 characters of device_id for consistency with entity naming + display_id = device_id[:8] if len(device_id) > 8 else device_id + title = f"Kiosker {display_id}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + host = discovery_info.host + hostname = discovery_info.hostname + name = hostname.rstrip(".").removesuffix(".local") + + # Extract device information from zeroconf properties + properties = discovery_info.properties + device_id = properties.get("uuid") + app_name = properties.get("app", "Kiosker") + version = properties.get("version", "") + ssl = properties.get("ssl", "false").lower() == "true" + + # Use device_id from zeroconf + if device_id: + device_name = f"{name or host or app_name} ({device_id[:8].upper()})" + unique_id = device_id + else: + _LOGGER.debug("Zeroconf properties did not include a valid device_id") + return self.async_abort(reason="cannot_connect") + + # Set unique ID and check for duplicates + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Store discovery info for confirmation step + self.context["title_placeholders"] = { + "name": device_name, + "host": host, + } + + # Store discovered information for later use + self._discovered_host = host + self._discovered_device_id = device_id + self._discovered_version = version + self._discovered_ssl = ssl + + # Show confirmation dialog + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle zeroconf confirmation.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Use stored discovery info and user-provided token + host = self._discovered_host + ssl = self._discovered_ssl + + # Create config with discovered host and user-provided token + config_data = { + CONF_HOST: host, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, DEFAULT_SSL_VERIFY), + } + + validation_errors, device_id = await validate_input(self.hass, config_data) + if validation_errors: + errors.update(validation_errors) + elif device_id: + # Use first 8 characters of device_id for consistency with entity naming + display_id = device_id[:8] if len(device_id) > 8 else device_id + title = f"Kiosker {display_id}" + return self.async_create_entry(title=title, data=config_data) + + # Show form to get API token for discovered device + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=STEP_ZEROCONF_CONFIRM_DATA_SCHEMA, + description_placeholders=self.context["title_placeholders"], + errors=errors, + ) diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py new file mode 100644 index 00000000000..1ddb268c90a --- /dev/null +++ b/homeassistant/components/kiosker/const.py @@ -0,0 +1,10 @@ +"""Constants for the Kiosker integration.""" + +DOMAIN = "kiosker" + +# Default values +PORT = 8081 +POLL_INTERVAL = 15 +DEFAULT_SSL = False +DEFAULT_SSL_VERIFY = False +REFRESH_DELAY = 0.5 diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py new file mode 100644 index 00000000000..3554a24695d --- /dev/null +++ b/homeassistant/components/kiosker/coordinator.py @@ -0,0 +1,103 @@ +"""DataUpdateCoordinator for Kiosker.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + PingError, + ScreensaverState, + Status, + TLSVerificationError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, POLL_INTERVAL, PORT + +_LOGGER = logging.getLogger(__name__) + +type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] + + +@dataclass +class KioskerData: + """Data structure for Kiosker integration.""" + + status: Status + blackout: Blackout | None + screensaver: ScreensaverState | None + + +class KioskerDataUpdateCoordinator(DataUpdateCoordinator[KioskerData]): + """Class to manage fetching data from the Kiosker API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: KioskerConfigEntry, + ) -> None: + """Initialize.""" + self.api = KioskerAPI( + host=config_entry.data[CONF_HOST], + port=PORT, + token=config_entry.data[CONF_API_TOKEN], + ssl=config_entry.data.get(CONF_SSL, False), + verify=config_entry.data.get(CONF_VERIFY_SSL, False), + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=POLL_INTERVAL), + config_entry=config_entry, + ) + + def _fetch_all_data(self) -> tuple[Status, Blackout, ScreensaverState]: + """Fetch all data from the API in a single executor job.""" + status = self.api.status() + blackout = self.api.blackout_get() + screensaver = self.api.screensaver_get_state() + return status, blackout, screensaver + + async def _async_update_data(self) -> KioskerData: + """Update data via library.""" + try: + status, blackout, screensaver = await self.hass.async_add_executor_job( + self._fetch_all_data + ) + except AuthenticationError as exc: + raise ConfigEntryAuthFailed( + "Authentication failed. Check your API token." + ) from exc + except IPAuthenticationError as exc: + raise ConfigEntryAuthFailed( + "IP authentication failed. Check your IP whitelist." + ) from exc + except (ConnectionError, PingError) as exc: + raise UpdateFailed(f"Connection failed: {exc}") from exc + except TLSVerificationError as exc: + raise UpdateFailed(f"TLS verification failed: {exc}") from exc + except BadRequestError as exc: + raise UpdateFailed(f"Bad request: {exc}") from exc + except (OSError, TimeoutError) as exc: + raise UpdateFailed(f"Connection timeout: {exc}") from exc + except Exception as exc: + _LOGGER.exception("Unexpected error updating Kiosker data") + raise UpdateFailed(f"Unexpected error: {exc}") from exc + + return KioskerData( + status=status, + blackout=blackout, + screensaver=screensaver, + ) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py new file mode 100644 index 00000000000..b18bb67c068 --- /dev/null +++ b/homeassistant/components/kiosker/entity.py @@ -0,0 +1,51 @@ +"""Base entity for Kiosker.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import KioskerDataUpdateCoordinator + + +class KioskerEntity(CoordinatorEntity[KioskerDataUpdateCoordinator]): + """Base class for Kiosker entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: KioskerDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = description + + status = coordinator.data.status + device_id = status.device_id + model = status.model + app_name = status.app_name + app_version = status.app_version + os_version = status.os_version + + # Use uppercased truncated device ID for display purposes (device name, titles) + device_id_short_display = device_id[:8].upper() + + # Set device info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=(f"Kiosker {device_id_short_display}"), + sw_version=(f"{app_name} {app_version}"), + hw_version=( + None + if model is None + else model + if os_version is None + else f"{model} ({os_version})" + ), + serial_number=device_id, + ) + + self._attr_unique_id = f"{device_id}_{description.key}" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json new file mode 100644 index 00000000000..88693edd7ac --- /dev/null +++ b/homeassistant/components/kiosker/icons.json @@ -0,0 +1,66 @@ +{ + "entity": { + "binary_sensor": { + "blackout_state": { + "default": "mdi:monitor", + "state": { + "on": "mdi:monitor-off" + } + }, + "screensaver_state": { + "default": "mdi:power-sleep", + "state": { + "off": "mdi:monitor-shimmer" + } + } + }, + "button": { + "clear_cache": { + "default": "mdi:cached" + }, + "clear_cookies": { + "default": "mdi:cookie-alert-outline" + }, + "navigate_backward": { + "default": "mdi:arrow-left" + }, + "navigate_forward": { + "default": "mdi:arrow-right" + }, + "navigate_home": { + "default": "mdi:home-outline" + }, + "navigate_refresh": { + "default": "mdi:refresh" + }, + "ping": { + "default": "mdi:lan-pending" + }, + "print": { + "default": "mdi:printer" + }, + "screensaver_interact": { + "default": "mdi:sleep-off" + } + }, + "sensor": { + "ambient_light": { + "default": "mdi:brightness-6" + }, + "blackout_state": { + "default": "mdi:monitor-off" + }, + "last_interaction": { + "default": "mdi:gesture-tap" + }, + "last_motion": { + "default": "mdi:motion-sensor" + } + }, + "switch": { + "disable_screensaver": { + "default": "mdi:power-sleep" + } + } + } +} diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json new file mode 100644 index 00000000000..fc8c2ed911f --- /dev/null +++ b/homeassistant/components/kiosker/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "kiosker", + "name": "Kiosker", + "codeowners": ["@Claeysson"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/kiosker", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["kiosker-python-api==1.2.9"], + "zeroconf": ["_kiosker._tcp.local."] +} diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml new file mode 100644 index 00000000000..36e0f730ed9 --- /dev/null +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom actions to document + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration is polling-only and does not subscribe to external events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not provide custom actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + discovery-update-info: todo + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Integration does not create or remove devices dynamically after setup + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Integration does not create or remove devices dynamically after setup + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py new file mode 100644 index 00000000000..37c9043917f --- /dev/null +++ b/homeassistant/components/kiosker/sensor.py @@ -0,0 +1,84 @@ +"""Sensor platform for Kiosker.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from kiosker import Status + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import KioskerConfigEntry +from .entity import KioskerEntity + +# Coordinator-based platform; no per-entity polling concurrency needed +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KioskerSensorEntityDescription(SensorEntityDescription): + """Kiosker sensor description.""" + + value_fn: Callable[[Status], StateType | datetime | None] + + +SENSORS: tuple[KioskerSensorEntityDescription, ...] = ( + KioskerSensorEntityDescription( + key="batteryLevel", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.battery_level, + ), + KioskerSensorEntityDescription( + key="lastInteraction", + translation_key="last_interaction", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda x: x.last_interaction, + ), + KioskerSensorEntityDescription( + key="lastMotion", + translation_key="last_motion", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda x: x.last_motion, + ), + KioskerSensorEntityDescription( + key="ambientLight", + translation_key="ambient_light", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.ambient_light, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + KioskerSensor(coordinator, description) for description in SENSORS + ) + + +class KioskerSensor(KioskerEntity, SensorEntity): + """Representation of a Kiosker sensor.""" + + entity_description: KioskerSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.status) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json new file mode 100644 index 00000000000..a282824099b --- /dev/null +++ b/homeassistant/components/kiosker/strings.json @@ -0,0 +1,105 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "bad_request": "Invalid request. Check your configuration.", + "cannot_connect": "Failed to connect to the Kiosker device.", + "invalid_auth": "Authentication failed. Check your API token.", + "invalid_ip_auth": "IP authentication failed. Check your IP whitelist.", + "tls_error": "TLS verification failed. Check your SSL settings.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "host": "The hostname or IP address of the device running the Kiosker App", + "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", + "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." + }, + "description": "Enable the API in Kiosker settings to pair with Home Assistant.", + "title": "Pair Kiosker App" + }, + "zeroconf": { + "description": "Do you want to configure {name} at {host}?" + }, + "zeroconf_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." + }, + "description": "You are about to pair `{name}` at `{host}` with Home Assistant.\n\nPlease provide the API token to complete setup.", + "submit": "Pair", + "title": "Discovered Kiosker App" + } + } + }, + "entity": { + "binary_sensor": { + "blackout_state": { + "name": "Blackout" + }, + "screensaver_state": { + "name": "Screensaver" + } + }, + "button": { + "clear_cache": { + "name": "Clear cache" + }, + "clear_cookies": { + "name": "Clear cookies" + }, + "navigate_backward": { + "name": "Go back" + }, + "navigate_forward": { + "name": "Go forward" + }, + "navigate_home": { + "name": "Go home" + }, + "navigate_refresh": { + "name": "Refresh page" + }, + "ping": { + "name": "Ping" + }, + "print": { + "name": "Print page" + }, + "screensaver_interact": { + "name": "Dismiss screensaver" + } + }, + "sensor": { + "ambient_light": { + "name": "Ambient light" + }, + "last_interaction": { + "name": "Last interaction" + }, + "last_motion": { + "name": "Last motion" + } + }, + "switch": { + "disable_screensaver": { + "name": "Disable screensaver" + } + } + } +} diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py new file mode 100644 index 00000000000..1900285725e --- /dev/null +++ b/homeassistant/components/kiosker/switch.py @@ -0,0 +1,97 @@ +"""Switch platform for Kiosker.""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + TLSVerificationError, +) + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .const import REFRESH_DELAY +from .coordinator import KioskerData +from .entity import KioskerEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KioskerSwitchEntityDescription(SwitchEntityDescription): + """Kiosker switch description.""" + + set_state_fn: Callable[[KioskerAPI, bool], None] + is_on_fn: Callable[[KioskerData], bool | None] + + +SWITCHES: tuple[KioskerSwitchEntityDescription, ...] = ( + KioskerSwitchEntityDescription( + key="disableScreensaver", + translation_key="disable_screensaver", + set_state_fn=lambda api, disabled: api.screensaver_set_disabled_state(disabled), + is_on_fn=lambda x: x.screensaver.disabled if x.screensaver else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker switches based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + KioskerSwitch(coordinator, description) for description in SWITCHES + ) + + +class KioskerSwitch(KioskerEntity, SwitchEntity): + """Representation of a Kiosker switch.""" + + entity_description: KioskerSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + async def _handle_method_call(self, state: bool) -> None: + """Handle method call with error handling.""" + try: + await self.hass.async_add_executor_job( + self.entity_description.set_state_fn, self.coordinator.api, state + ) + except AuthenticationError as exc: + raise HomeAssistantError("Authentication failed") from exc + except IPAuthenticationError as exc: + raise HomeAssistantError("IP Authentication failed") from exc + except ConnectionError as exc: + raise HomeAssistantError(f"Connection failed: {exc}") from exc + except TLSVerificationError as exc: + raise HomeAssistantError(f"TLS verification failed: {exc}") from exc + except BadRequestError as exc: + raise ServiceValidationError(f"Bad request: {exc}") from exc + + await asyncio.sleep(REFRESH_DELAY) + await self.coordinator.async_refresh() + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn the switch on.""" + await self._handle_method_call(True) + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn the switch off.""" + await self._handle_method_call(False) diff --git a/homeassistant/components/kira/remote.py b/homeassistant/components/kira/remote.py index c1d28f8b077..fa6ed1ba41f 100644 --- a/homeassistant/components/kira/remote.py +++ b/homeassistant/components/kira/remote.py @@ -1,7 +1,5 @@ """Support for Keene Electronics IR-IP devices.""" -from __future__ import annotations - from collections.abc import Iterable import logging from typing import Any diff --git a/homeassistant/components/kira/sensor.py b/homeassistant/components/kira/sensor.py index 5779ed4df35..75795503a4d 100644 --- a/homeassistant/components/kira/sensor.py +++ b/homeassistant/components/kira/sensor.py @@ -1,7 +1,5 @@ """KIRA interface to receive UDP packets from an IR-IP bridge.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorEntity diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 6bf5896dd70..7f6c928e561 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -4,14 +4,10 @@ This sets up a demo environment of features which are obscure or which represent incorrect behavior, and are thus not wanted in the demo integration. """ -from __future__ import annotations - import datetime from functools import partial from random import random -import voluptuous as vol - from homeassistant.components.labs import ( EventLabsUpdatedData, async_is_preview_feature_enabled, @@ -36,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.issue_registry import ( @@ -53,15 +49,19 @@ from homeassistant.util.unit_conversion import ( ) from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .services import async_setup_services COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.BUTTON, + Platform.DEVICE_TRACKER, Platform.FAN, + Platform.EVENT, Platform.IMAGE, Platform.INFRARED, Platform.LAWN_MOWER, Platform.LOCK, Platform.NOTIFY, + Platform.RADIO_FREQUENCY, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, @@ -69,15 +69,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema( - { - vol.Required("field_1"): vol.Coerce(int), - vol.Required("field_2"): vol.In(["off", "auto", "cool"]), - vol.Optional("field_3"): vol.Coerce(int), - vol.Optional("field_4"): vol.In(["forwards", "reverse"]), - } -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" @@ -87,24 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) - @callback - def service_handler(call: ServiceCall | None = None) -> ServiceResponse: - """Do nothing.""" - return None - - hass.services.async_register( - DOMAIN, - "test_service_1", - service_handler, - SCHEMA_SERVICE_TEST_SERVICE_1, - description_placeholders={ - "meep_1": "foo", - "meep_2": "bar", - "meep_3": "beer", - "meep_4": "milk", - "meep_5": "https://example.com", - }, - ) + async_setup_services(hass) return True diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index 1ff9cc5e05d..91543dcc44c 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -1,7 +1,5 @@ """Backup platform for the kitchen_sink integration.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterator, Callable, Coroutine import logging diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py index 1ee9bd78095..489f1feb8be 100644 --- a/homeassistant/components/kitchen_sink/button.py +++ b/homeassistant/components/kitchen_sink/button.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake button entity.""" -from __future__ import annotations - from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 2fbceef3062..d5eca451bda 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Kitchen Sink component.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -11,6 +9,7 @@ from homeassistant import data_entry_flow from homeassistant.components.infrared import ( DOMAIN as INFRARED_DOMAIN, async_get_emitters, + async_get_receivers, ) from homeassistant.config_entries import ( ConfigEntry, @@ -24,7 +23,7 @@ from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig -from .const import CONF_INFRARED_ENTITY_ID, DOMAIN +from .const import CONF_INFRARED_ENTITY_ID, CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN CONF_BOOLEAN = "bool" CONF_INT = "int" @@ -180,25 +179,36 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """User flow to add an infrared fan.""" - entities = async_get_emitters(self.hass) - if not entities: - return self.async_abort(reason="no_emitters") - if user_input is not None: title = user_input.pop("name") return self.async_create_entry(data=user_input, title=title) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required("name"): str, - vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( - EntitySelectorConfig( - domain=INFRARED_DOMAIN, - include_entities=entities, - ) - ), - } - ), - ) + emitter_entities = async_get_emitters(self.hass) + receiver_entities = async_get_receivers(self.hass) + + if not emitter_entities and not receiver_entities: + return self.async_abort(reason="no_infrared_entities") + + schema_dict: dict[vol.Marker, Any] = { + vol.Required("name"): str, + } + + if emitter_entities: + schema_dict[vol.Optional(CONF_INFRARED_ENTITY_ID)] = EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entities, + ) + ) + + if receiver_entities: + schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = ( + EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=receiver_entities, + ) + ) + ) + + return self.async_show_form(step_id="user", data_schema=vol.Schema(schema_dict)) diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py index bce291bd5d6..249cb8bb8f0 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -1,13 +1,19 @@ """Constants for the Kitchen Sink integration.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.util.hass_dict import HassKey DOMAIN = "kitchen_sink" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( f"{DOMAIN}.backup_agent_listeners" ) + +INFRARED_FAN_ADDRESS = 0x1234 +INFRARED_CMD_POWER_ON = 0x01 +INFRARED_CMD_POWER_OFF = 0x02 +INFRARED_CMD_SPEED_LOW = 0x03 +INFRARED_CMD_SPEED_MEDIUM = 0x04 +INFRARED_CMD_SPEED_HIGH = 0x05 diff --git a/homeassistant/components/kitchen_sink/device.py b/homeassistant/components/kitchen_sink/device.py index fef41f7917c..1b77213dacb 100644 --- a/homeassistant/components/kitchen_sink/device.py +++ b/homeassistant/components/kitchen_sink/device.py @@ -1,7 +1,5 @@ """Create device without entities.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/kitchen_sink/device_tracker.py b/homeassistant/components/kitchen_sink/device_tracker.py new file mode 100644 index 00000000000..7b4623d7857 --- /dev/null +++ b/homeassistant/components/kitchen_sink/device_tracker.py @@ -0,0 +1,97 @@ +"""Demo platform that has a couple of fake device trackers.""" + +from homeassistant.components.device_tracker import ( + BaseScannerEntity, + SourceType, + TrackerEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Everything but the Kitchen Sink config entry.""" + async_add_entities( + [ + DemoTracker( + unique_id="kitchen_sink_tracker_001", + name="Demo tracker", + latitude=hass.config.latitude, + longitude=hass.config.longitude, + accuracy=10, + ), + DemoScanner( + unique_id="kitchen_sink_scanner_001", + name="Demo scanner", + is_connected=True, + ), + ] + ) + + +class DemoTracker(TrackerEntity): + """Representation of a demo tracker.""" + + _attr_should_poll = False + _attr_source_type = SourceType.GPS + + def __init__( + self, + *, + unique_id: str, + name: str, + latitude: float | None, + longitude: float | None, + accuracy: float, + ) -> None: + """Initialize the tracker.""" + self._attr_unique_id = unique_id + self._attr_name = name + self._attr_latitude = latitude + self._attr_longitude = longitude + self._attr_location_accuracy = accuracy + + @callback + def async_set_tracker_location( + self, latitude: float, longitude: float, accuracy: float + ) -> None: + """Update the tracker location.""" + self._attr_latitude = latitude + self._attr_longitude = longitude + self._attr_location_accuracy = accuracy + self.async_write_ha_state() + + +class DemoScanner(BaseScannerEntity): + """Representation of a demo scanner.""" + + _attr_should_poll = False + _attr_source_type = SourceType.ROUTER + + def __init__( + self, + *, + unique_id: str, + name: str, + is_connected: bool, + ) -> None: + """Initialize the scanner.""" + self._attr_unique_id = unique_id + self._attr_name = name + self._is_connected = is_connected + + @property + def is_connected(self) -> bool: + """Return true if the device is connected.""" + return self._is_connected + + @callback + def async_set_scanner_connected(self, connected: bool) -> None: + """Update the scanner connected state.""" + self._is_connected = connected + self.async_write_ha_state() diff --git a/homeassistant/components/kitchen_sink/event.py b/homeassistant/components/kitchen_sink/event.py new file mode 100644 index 00000000000..a43fc3e1ecf --- /dev/null +++ b/homeassistant/components/kitchen_sink/event.py @@ -0,0 +1,89 @@ +"""Demo platform that offers a fake infrared receiver event entity.""" + +from infrared_protocols.commands.nec import NECCommand + +from homeassistant.components.event import EventEntity +from homeassistant.components.infrared import ( + InfraredReceivedSignal, + InfraredReceiverConsumerEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, + INFRARED_CMD_POWER_OFF, + INFRARED_CMD_POWER_ON, + INFRARED_CMD_SPEED_HIGH, + INFRARED_CMD_SPEED_LOW, + INFRARED_CMD_SPEED_MEDIUM, + INFRARED_FAN_ADDRESS, +) + +PARALLEL_UPDATES = 0 + +COMMAND_EVENTS = { + INFRARED_CMD_POWER_ON: "power_on", + INFRARED_CMD_POWER_OFF: "power_off", + INFRARED_CMD_SPEED_LOW: "speed_low", + INFRARED_CMD_SPEED_MEDIUM: "speed_medium", + INFRARED_CMD_SPEED_HIGH: "speed_high", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the demo infrared event platform.""" + for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "infrared_fan": + continue + if subentry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID) is None: + continue + async_add_entities( + [ + DemoInfraredEvent( + subentry_id=subentry_id, + device_name=subentry.title, + infrared_receiver_entity_id=subentry.data[ + CONF_INFRARED_RECEIVER_ENTITY_ID + ], + ) + ], + config_subentry_id=subentry_id, + ) + + +class DemoInfraredEvent(InfraredReceiverConsumerEntity, EventEntity): + """Representation of a demo infrared event entity.""" + + _attr_has_entity_name = True + _attr_name = "Received IR Event" + _attr_event_types = list(COMMAND_EVENTS.values()) + + def __init__( + self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str + ) -> None: + """Initialize the demo infrared event entity.""" + self._infrared_receiver_entity_id = infrared_receiver_entity_id + self._attr_unique_id = subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry_id)}, name=device_name + ) + + @callback + def _handle_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal.""" + command = NECCommand.from_raw_timings(signal.timings) + if command is None or command.address != INFRARED_FAN_ADDRESS: + return + event_type = COMMAND_EVENTS.get(command.command) + if event_type is None: + return + self._trigger_event(event_type, {"raw_code": signal.timings}) + self.async_write_ha_state() diff --git a/homeassistant/components/kitchen_sink/fan.py b/homeassistant/components/kitchen_sink/fan.py index db02da6930c..7d9b14444a6 100644 --- a/homeassistant/components/kitchen_sink/fan.py +++ b/homeassistant/components/kitchen_sink/fan.py @@ -1,31 +1,29 @@ """Demo platform that offers a fake infrared fan entity.""" -from __future__ import annotations - from typing import Any -import infrared_protocols +from infrared_protocols.commands.nec import NECCommand from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.components.infrared import async_send_command +from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_INFRARED_ENTITY_ID, DOMAIN +from .const import ( + CONF_INFRARED_ENTITY_ID, + DOMAIN, + INFRARED_CMD_POWER_OFF, + INFRARED_CMD_POWER_ON, + INFRARED_CMD_SPEED_HIGH, + INFRARED_CMD_SPEED_LOW, + INFRARED_CMD_SPEED_MEDIUM, + INFRARED_FAN_ADDRESS, +) PARALLEL_UPDATES = 0 -DUMMY_FAN_ADDRESS = 0x1234 -DUMMY_CMD_POWER_ON = 0x01 -DUMMY_CMD_POWER_OFF = 0x02 -DUMMY_CMD_SPEED_LOW = 0x03 -DUMMY_CMD_SPEED_MEDIUM = 0x04 -DUMMY_CMD_SPEED_HIGH = 0x05 - async def async_setup_entry( hass: HomeAssistant, @@ -36,24 +34,25 @@ async def async_setup_entry( for subentry_id, subentry in config_entry.subentries.items(): if subentry.subentry_type != "infrared_fan": continue + if subentry.data.get(CONF_INFRARED_ENTITY_ID) is None: + continue async_add_entities( [ DemoInfraredFan( subentry_id=subentry_id, device_name=subentry.title, - infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID], + infrared_emitter_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID], ) ], config_subentry_id=subentry_id, ) -class DemoInfraredFan(FanEntity): +class DemoInfraredFan(InfraredEmitterConsumerEntity, FanEntity): """Representation of a demo infrared fan entity.""" _attr_has_entity_name = True _attr_name = None - _attr_should_poll = False _attr_assumed_state = True _attr_speed_count = 3 _attr_supported_features = ( @@ -66,10 +65,10 @@ class DemoInfraredFan(FanEntity): self, subentry_id: str, device_name: str, - infrared_entity_id: str, + infrared_emitter_entity_id: str, ) -> None: """Initialize the demo infrared fan entity.""" - self._infrared_entity_id = infrared_entity_id + self._infrared_emitter_entity_id = infrared_emitter_entity_id self._attr_unique_id = subentry_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, subentry_id)}, @@ -77,40 +76,14 @@ class DemoInfraredFan(FanEntity): ) self._attr_percentage = 0 - async def async_added_to_hass(self) -> None: - """Subscribe to infrared entity state changes.""" - await super().async_added_to_hass() - - @callback - def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: - """Handle infrared entity state changes.""" - new_state = event.data["new_state"] - self._attr_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._infrared_entity_id], _async_ir_state_changed - ) - ) - - # Set initial availability based on current infrared entity state - ir_state = self.hass.states.get(self._infrared_entity_id) - self._attr_available = ( - ir_state is not None and ir_state.state != STATE_UNAVAILABLE - ) - - async def _send_command(self, command_code: int) -> None: + async def _send_fan_command(self, command_code: int) -> None: """Send an IR command using the NEC protocol.""" - command = infrared_protocols.NECCommand( - address=DUMMY_FAN_ADDRESS, - command=command_code, - modulation=38000, - ) - await async_send_command( - self.hass, self._infrared_entity_id, command, context=self._context + await self._send_command( + NECCommand( + address=INFRARED_FAN_ADDRESS, + command=command_code, + modulation=38000, + ) ) async def async_turn_on( @@ -123,13 +96,13 @@ class DemoInfraredFan(FanEntity): if percentage is not None: await self.async_set_percentage(percentage) return - await self._send_command(DUMMY_CMD_POWER_ON) + await self._send_fan_command(INFRARED_CMD_POWER_ON) self._attr_percentage = 33 self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self._send_command(DUMMY_CMD_POWER_OFF) + await self._send_fan_command(INFRARED_CMD_POWER_OFF) self._attr_percentage = 0 self.async_write_ha_state() @@ -140,11 +113,10 @@ class DemoInfraredFan(FanEntity): return if percentage <= 33: - await self._send_command(DUMMY_CMD_SPEED_LOW) + await self._send_fan_command(INFRARED_CMD_SPEED_LOW) elif percentage <= 66: - await self._send_command(DUMMY_CMD_SPEED_MEDIUM) + await self._send_fan_command(INFRARED_CMD_SPEED_MEDIUM) else: - await self._send_command(DUMMY_CMD_SPEED_HIGH) - + await self._send_fan_command(INFRARED_CMD_SPEED_HIGH) self._attr_percentage = percentage self.async_write_ha_state() diff --git a/homeassistant/components/kitchen_sink/icons.json b/homeassistant/components/kitchen_sink/icons.json index d9d91a6054e..751034dd09c 100644 --- a/homeassistant/components/kitchen_sink/icons.json +++ b/homeassistant/components/kitchen_sink/icons.json @@ -9,9 +9,15 @@ } }, "services": { + "set_scanner_connected": { + "service": "mdi:lan-connect" + }, + "set_tracker_location": { + "service": "mdi:map-marker" + }, "test_service_1": { "sections": { - "advanced_fields": "mdi:test-tube" + "additional_fields": "mdi:test-tube" }, "service": "mdi:flask" } diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py index 130317f4bc5..d5c1d40ef66 100644 --- a/homeassistant/components/kitchen_sink/image.py +++ b/homeassistant/components/kitchen_sink/image.py @@ -1,7 +1,5 @@ """Demo image platform.""" -from __future__ import annotations - from pathlib import Path from homeassistant.components.image import ImageEntity diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index 4f93c9be0c5..80406b73c95 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -1,20 +1,29 @@ """Demo platform that offers a fake infrared entity.""" -from __future__ import annotations - -import infrared_protocols +from infrared_protocols.commands import Command as InfraredCommand from homeassistant.components import persistent_notification -from homeassistant.components.infrared import InfraredEntity +from homeassistant.components.infrared import ( + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN PARALLEL_UPDATES = 0 +INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal" + async def async_setup_entry( hass: HomeAssistant, @@ -24,42 +33,60 @@ async def async_setup_entry( """Set up the demo infrared platform.""" async_add_entities( [ - DemoInfrared( - unique_id="ir_transmitter", - device_name="IR Blaster", - entity_name="Infrared Transmitter", + DemoInfraredEmitter( + unique_id="ir_emitter", + entity_name="Infrared Emitter", + ), + DemoInfraredReceiver( + unique_id="ir_receiver", + entity_name="Infrared Receiver", ), ] ) -class DemoInfrared(InfraredEntity): +# pylint: disable=home-assistant-enforce-class-module +class DemoInfraredEntityBase(Entity): """Representation of a demo infrared entity.""" _attr_has_entity_name = True _attr_should_poll = False - def __init__( - self, - unique_id: str, - device_name: str, - entity_name: str, - ) -> None: + def __init__(self, unique_id: str, entity_name: str) -> None: """Initialize the demo infrared entity.""" + super().__init__() self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, + identifiers={(DOMAIN, "infrared")}, name="IR Blaster" ) self._attr_name = entity_name - async def async_send_command(self, command: infrared_protocols.Command) -> None: + +class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity): + """Representation of a demo infrared emitter entity.""" + + async def async_send_command(self, command: InfraredCommand) -> None: """Send an IR command.""" - timings = [ - interval - for timing in command.get_raw_timings() - for interval in (timing.high_us, -timing.low_us) - ] + raw_timings = command.get_raw_timings() persistent_notification.async_create( - self.hass, str(timings), title="Infrared Command" + self.hass, str(raw_timings), title="Infrared Command Sent" + ) + async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings) + + +class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity): + """Representation of a demo infrared receiver entity.""" + + @callback + def _on_dispatcher_signal(self, raw_timings: list[int]) -> None: + """Handle received infrared command signal.""" + self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings)) + + async def async_added_to_hass(self) -> None: + """Called when entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispatcher_signal + ) ) diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py index 18a3f3dee77..ecc3666da2c 100644 --- a/homeassistant/components/kitchen_sink/lawn_mower.py +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -1,7 +1,5 @@ """Demo platform that has a couple fake lawn mowers.""" -from __future__ import annotations - from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 63566482cdf..655d58364de 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -1,7 +1,5 @@ """Demo platform that has a couple of fake locks.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState diff --git a/homeassistant/components/kitchen_sink/notify.py b/homeassistant/components/kitchen_sink/notify.py index be5bad58109..6f80c00fd51 100644 --- a/homeassistant/components/kitchen_sink/notify.py +++ b/homeassistant/components/kitchen_sink/notify.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake notify entity.""" -from __future__ import annotations - from homeassistant.components import persistent_notification from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/kitchen_sink/radio_frequency.py b/homeassistant/components/kitchen_sink/radio_frequency.py new file mode 100644 index 00000000000..ec47e4863de --- /dev/null +++ b/homeassistant/components/kitchen_sink/radio_frequency.py @@ -0,0 +1,65 @@ +"""Demo platform that offers a fake radio frequency entity.""" + +from rf_protocols import RadioFrequencyCommand + +from homeassistant.components import persistent_notification +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the demo radio frequency platform.""" + async_add_entities( + [ + DemoRadioFrequency( + unique_id="rf_transmitter", + device_name="RF Blaster", + entity_name="Radio Frequency Transmitter", + ), + ] + ) + + +class DemoRadioFrequency(RadioFrequencyTransmitterEntity): + """Representation of a demo radio frequency entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str, + ) -> None: + """Initialize the demo radio frequency entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges.""" + return [(300_000_000, 928_000_000)] + + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command.""" + persistent_notification.async_create( + self.hass, + str(command.get_raw_timings()), + title="Radio Frequency Command", + ) diff --git a/homeassistant/components/kitchen_sink/repairs.py b/homeassistant/components/kitchen_sink/repairs.py index 51b474dcf0f..ef863010700 100644 --- a/homeassistant/components/kitchen_sink/repairs.py +++ b/homeassistant/components/kitchen_sink/repairs.py @@ -1,11 +1,12 @@ """Repairs platform for the demo integration.""" -from __future__ import annotations - import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant @@ -14,14 +15,14 @@ class DemoFixFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: return self.async_create_entry(data={}) @@ -34,7 +35,7 @@ class DemoColdTeaFixFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_abort(reason="not_tea_time") diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 15f73b781bc..dd6be92a6e5 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -1,7 +1,5 @@ """Demo platform that has a couple of fake sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/kitchen_sink/services.py b/homeassistant/components/kitchen_sink/services.py new file mode 100644 index 00000000000..6ebcbd1ba01 --- /dev/null +++ b/homeassistant/components/kitchen_sink/services.py @@ -0,0 +1,72 @@ +"""Services for the Everything but the Kitchen Sink integration.""" + +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema( + { + vol.Required("field_1"): vol.Coerce(int), + vol.Required("field_2"): vol.In(["off", "auto", "cool"]), + vol.Optional("field_3"): vol.Coerce(int), + vol.Optional("field_4"): vol.In(["forward", "reverse"]), + } +) + +SERVICE_TEST_SERVICE_1 = "test_service_1" +SERVICE_SET_TRACKER_LOCATION = "set_tracker_location" +SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected" + +ATTR_ACCURACY = "accuracy" +ATTR_CONNECTED = "connected" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Kitchen Sink integration.""" + + @callback + def service_handler(call: ServiceCall) -> ServiceResponse: + """Do nothing.""" + return None + + hass.services.async_register( + DOMAIN, + SERVICE_TEST_SERVICE_1, + service_handler, + SCHEMA_SERVICE_TEST_SERVICE_1, + description_placeholders={ + "meep_1": "foo", + "meep_2": "bar", + "meep_3": "beer", + "meep_4": "milk", + "meep_5": "https://example.com", + }, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_TRACKER_LOCATION, + entity_domain=DEVICE_TRACKER_DOMAIN, + schema={ + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)), + }, + func="async_set_tracker_location", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_SCANNER_CONNECTED, + entity_domain=DEVICE_TRACKER_DOMAIN, + schema={vol.Required(ATTR_CONNECTED): cv.boolean}, + func="async_set_scanner_connected", + ) diff --git a/homeassistant/components/kitchen_sink/services.yaml b/homeassistant/components/kitchen_sink/services.yaml index c65495095dc..665024ba2f3 100644 --- a/homeassistant/components/kitchen_sink/services.yaml +++ b/homeassistant/components/kitchen_sink/services.yaml @@ -15,7 +15,7 @@ test_service_1: - "off" - "auto" - "cool" - advanced_fields: + additional_fields: collapsed: true fields: field_3: @@ -30,3 +30,44 @@ test_service_1: options: - "forward" - "reverse" +set_tracker_location: + target: + entity: + integration: kitchen_sink + domain: device_tracker + fields: + latitude: + required: true + example: 52.379189 + selector: + number: + min: -90 + max: 90 + step: any + longitude: + required: true + example: 4.899431 + selector: + number: + min: -180 + max: 180 + step: any + accuracy: + required: true + example: 10 + selector: + number: + min: 0 + max: 10000 + unit_of_measurement: m +set_scanner_connected: + target: + entity: + integration: kitchen_sink + domain: device_tracker + fields: + connected: + required: true + example: true + selector: + boolean: diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e369e0942bd..c45581028f8 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -35,7 +35,7 @@ }, "infrared_fan": { "abort": { - "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" }, "entry_type": "Infrared fan", "initiate_flow": { @@ -44,10 +44,11 @@ "step": { "user": { "data": { - "infrared_entity_id": "Infrared transmitter", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]", + "infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]", "name": "[%key:common::config_flow::data::name%]" }, - "description": "Select an infrared transmitter to control the fan." + "description": "Select infrared devices for the fan." } } } @@ -134,6 +135,34 @@ } }, "services": { + "set_scanner_connected": { + "description": "Sets the connected state of a demo scanner entity.", + "fields": { + "connected": { + "description": "Whether the device should be reported as connected.", + "name": "Connected" + } + }, + "name": "Set scanner connected" + }, + "set_tracker_location": { + "description": "Sets the location and accuracy of a demo tracker entity.", + "fields": { + "accuracy": { + "description": "Location accuracy in meters.", + "name": "Accuracy" + }, + "latitude": { + "description": "Latitude of the new location.", + "name": "Latitude" + }, + "longitude": { + "description": "Longitude of the new location.", + "name": "Longitude" + } + }, + "name": "Set tracker location" + }, "test_service_1": { "description": "Fake action for testing {meep_2}", "fields": { @@ -158,9 +187,9 @@ }, "name": "Test action {meep_1}", "sections": { - "advanced_fields": { - "description": "Some very advanced things", - "name": "Advanced options" + "additional_fields": { + "description": "Some additional things", + "name": "Additional options" } } } diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py index 45d3cb14eca..6447f1b612f 100644 --- a/homeassistant/components/kitchen_sink/switch.py +++ b/homeassistant/components/kitchen_sink/switch.py @@ -1,7 +1,5 @@ """Demo platform that has some fake switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index a6b7cc69d05..7c6b2dad5a0 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -1,7 +1,5 @@ """Demo platform that offers fake meteorological data.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.components.weather import ( diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index d378fcbcbed..0091fd1cd34 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -1,7 +1,5 @@ """Support for the KIWI.KI lock platform.""" -from __future__ import annotations - import logging from typing import Any @@ -113,6 +111,7 @@ class KiwiLock(LockEntity): try: self._client.open_door(self.lock_id) + # pylint: disable-next=home-assistant-action-swallowed-exception except KiwiException: _LOGGER.error("Failed to open door") else: diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index 56b1d4675bc..0d1093e1d5a 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -1,7 +1,5 @@ """Config flow for kmtronic integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py index 966f1dbf309..d8a299843ac 100644 --- a/homeassistant/components/knocki/__init__.py +++ b/homeassistant/components/knocki/__init__.py @@ -1,7 +1,5 @@ """The Knocki integration.""" -from __future__ import annotations - from knocki import Event, EventType, KnockiClient from homeassistant.const import CONF_TOKEN, Platform diff --git a/homeassistant/components/knocki/config_flow.py b/homeassistant/components/knocki/config_flow.py index 7818c752a87..7afbbdb362e 100644 --- a/homeassistant/components/knocki/config_flow.py +++ b/homeassistant/components/knocki/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Knocki integration.""" -from __future__ import annotations - from typing import Any from knocki import KnockiClient, KnockiConnectionError, KnockiInvalidAuthError diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 40c5ea8a65b..b9ced9220b1 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -1,7 +1,5 @@ """The KNX integration.""" -from __future__ import annotations - import contextlib from pathlib import Path from typing import Final @@ -123,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.ui_time_server_controller.start( knx_module.xknx, knx_module.config_store.get_time_server_config() ) + knx_module.ui_expose_controller.start( + hass, knx_module.xknx, knx_module.config_store.get_exposes() + ) if CONF_KNX_EXPOSE in config: knx_module.yaml_exposures.extend( create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE]) @@ -134,9 +135,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups( entry, { - Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.) - *SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management - *configured_platforms_yaml, # forward yaml-only managed platforms on demand, + # always forward sensor for system entities + # (telegram counter, etc.) + Platform.SENSOR, + # forward all platforms that support UI entity + # management + *SUPPORTED_PLATFORMS_UI, + # forward yaml-only managed platforms on demand + *configured_platforms_yaml, }, ) @@ -157,6 +163,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for exposure in knx_module.service_exposures.values(): exposure.async_remove() knx_module.ui_time_server_controller.stop() + knx_module.ui_expose_controller.stop() configured_platforms_yaml = { platform @@ -166,9 +173,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms( entry, { - Platform.SENSOR, # always unload system entities (telegram counter, etc.) - *SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management - *configured_platforms_yaml, # unload yaml-only managed platforms if configured, + # always unload system entities + # (telegram counter, etc.) + Platform.SENSOR, + # unload all platforms that support UI entity + # management + *SUPPORTED_PLATFORMS_UI, + # unload yaml-only managed platforms if configured + *configured_platforms_yaml, }, ) if unload_ok: diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 9706036acab..3a9db86311d 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -1,7 +1,5 @@ """Support for KNX binary sensor entities.""" -from __future__ import annotations - from typing import Any from xknx.devices import BinarySensor as XknxBinarySensor diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index c09be1c11e9..2eb1acf0ffc 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,7 +1,5 @@ """Support for KNX button entities.""" -from __future__ import annotations - from xknx.devices import RawValue as XknxRawValue from homeassistant import config_entries diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index e1a2c9b8eb9..6946b58d70f 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -1,7 +1,5 @@ """Support for KNX climate entities.""" -from __future__ import annotations - from typing import Any from xknx import XKNX @@ -382,7 +380,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase): self._attr_fan_modes = [fan_zero_mode, FAN_LOW, FAN_HIGH] elif fan_max_step == 1: self._attr_fan_modes = [fan_zero_mode, FAN_ON] - elif device.fan_speed_mode == FanSpeedMode.STEP: + elif device.fan_speed_mode is FanSpeedMode.STEP: self._attr_fan_modes = [fan_zero_mode] + [ str(i) for i in range(1, fan_max_step + 1) ] @@ -552,7 +550,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase): if not fan_speed or self._attr_fan_modes is None: return self.fan_zero_mode - if self._device.fan_speed_mode == FanSpeedMode.STEP: + if self._device.fan_speed_mode is FanSpeedMode.STEP: return self._attr_fan_modes[fan_speed] # Find the closest fan mode percentage @@ -572,7 +570,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase): fan_mode_index = self._attr_fan_modes.index(fan_mode) - if self._device.fan_speed_mode == FanSpeedMode.STEP: + if self._device.fan_speed_mode is FanSpeedMode.STEP: await self._device.set_fan_speed(fan_mode_index) return diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index bcfcbd18a2a..ca574a78714 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -1,7 +1,5 @@ """Config flow for KNX.""" -from __future__ import annotations - from collections.abc import AsyncGenerator from typing import Any, Final, Literal @@ -255,7 +253,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): self._gatewayscanner = GatewayScanner( self._xknx, stop_on_found=0, timeout_in_seconds=2 ) - # keep a reference to the generator to scan in background until user selects a connection type + # keep a reference to the generator to scan in + # background until user selects a connection type self._async_scan_gen = self._gatewayscanner.async_scan() try: await anext(self._async_scan_gen) @@ -342,7 +341,11 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): label=( f"{tunnel}" f"{' TCP' if tunnel.supports_tunnelling_tcp else ' UDP'}" - f"{' 🔐 Secure tunneling' if tunnel.tunnelling_requires_secure else ''}" + f"{ + ' 🔐 Secure tunneling' + if tunnel.tunnelling_requires_secure + else '' + }" ), ) for tunnel in self._found_tunnels @@ -370,7 +373,6 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) ) } - return self.async_show_form(step_id="tunnel", data_schema=vol.Schema(fields)) async def async_step_tcp_tunnel_endpoint( @@ -391,7 +393,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.finish_flow() - # this step is only called from async_step_tunnel so self._selected_tunnel is always set + # this step is only called from async_step_tunnel + # so self._selected_tunnel is always set assert self._selected_tunnel # skip if only one tunnel endpoint or no tunnelling slot infos if len(self._selected_tunnel.tunnelling_slots) <= 1: @@ -407,7 +410,13 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): selector.SelectOptionDict( value=str(slot), label=( - f"{slot} - {'current connection' if slot == _current_ia else 'occupied' if not slot_status.free else 'free'}" + f"{slot} - { + 'current connection' + if slot == _current_ia + else 'occupied' + if not slot_status.free + else 'free' + }" ), ) for slot, slot_status in self._selected_tunnel.tunnelling_slots.items() @@ -434,7 +443,10 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_manual_tunnel( self, user_input: dict | None = None ) -> ConfigFlowResult: - """Manually configure tunnel connection parameters. Fields default to preselected gateway if one was found.""" + """Manually configure tunnel connection parameters. + + Fields default to preselected gateway if one was found. + """ errors: dict = {} if user_input is not None: @@ -446,7 +458,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "invalid_ip_address" _local_ip = None - if _local := user_input.get(CONF_KNX_LOCAL_IP): + if _local := (user_input.get(CONF_KNX_LOCAL_IP) or None): try: _local_ip = await xknx_validate_ip(_local) ip_v4_validator(_local_ip, multicast=False) @@ -488,11 +500,10 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): if selected_tunneling_type == CONF_KNX_TUNNELING_TCP_SECURE: return await self.async_step_secure_key_source_menu_tunnel() - self.new_title = ( - "Tunneling " - f"{'UDP' if selected_tunneling_type == CONF_KNX_TUNNELING else 'TCP'} " - f"@ {_host}" + _proto = ( + "UDP" if selected_tunneling_type == CONF_KNX_TUNNELING else "TCP" ) + self.new_title = f"Tunneling {_proto} @ {_host}" return self.finish_flow() _reconfiguring_existing_tunnel = ( @@ -544,9 +555,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required( CONF_KNX_ROUTE_BACK, default=_route_back ): selector.BooleanSelector(), + vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR, } - if self.show_advanced_options: - fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR if not self._found_tunnels and not errors.get("base"): errors["base"] = "no_tunnel_discovered" @@ -595,7 +605,6 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD), ), } - return self.async_show_form( step_id="secure_tunnel_manual", data_schema=vol.Schema(fields), @@ -622,7 +631,10 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE ], ) - self.new_title = f"Secure Routing as {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" + self.new_title = ( + "Secure Routing as" + f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" + ) return self.finish_flow() fields = { @@ -648,7 +660,6 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): vol.Coerce(int), ), } - return self.async_show_form( step_id="secure_routing_manual", data_schema=vol.Schema(fields), @@ -684,7 +695,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): return self.finish_flow() # Tunneling / Automatic - # skip selection step if we have a keyfile update that includes a configured tunnel + # skip selection step if we have a keyfile update + # that includes a configured tunnel if self.tunnel_endpoint_ia is not None and self.tunnel_endpoint_ia in [ str(_if.individual_address) for _if in self._keyring.interfaces ]: @@ -745,7 +757,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.finish_flow() - # this step is only called from async_step_secure_knxkeys so self._keyring is always set + # this step is only called from async_step_secure_knxkeys + # so self._keyring is always set assert self._keyring # Filter for selected tunnel @@ -827,7 +840,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ip_v4_validator(_multicast_group, multicast=True) except vol.Invalid: errors[CONF_KNX_MCAST_GRP] = "invalid_ip_address" - if _local := user_input.get(CONF_KNX_LOCAL_IP): + if _local := (user_input.get(CONF_KNX_LOCAL_IP) or None): try: _local_ip = await xknx_validate_ip(_local) ip_v4_validator(_local_ip, multicast=False) @@ -873,11 +886,8 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ): selector.BooleanSelector(), vol.Required(CONF_KNX_MCAST_GRP, default=_multicast_group): _IP_SELECTOR, vol.Required(CONF_KNX_MCAST_PORT, default=_multicast_port): _PORT_SELECTOR, + vol.Optional(CONF_KNX_LOCAL_IP): _IP_SELECTOR, } - if self.show_advanced_options: - # Optional with default doesn't work properly in flow UI - fields[vol.Optional(CONF_KNX_LOCAL_IP)] = _IP_SELECTOR - return self.async_show_form( step_id="routing", data_schema=vol.Schema(fields), errors=errors ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 8c27353e7f0..1dc28956d8c 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -1,7 +1,5 @@ """Constants for the KNX integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from enum import Enum, StrEnum from typing import TYPE_CHECKING, Final, TypedDict @@ -19,7 +17,8 @@ if TYPE_CHECKING: DOMAIN: Final = "knx" KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) -# Address is used for configuration and services by the same functions so the key has to match +# Address is used for configuration and services by the +# same functions so the key has to match KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index f3df45bcb60..4a59b461bb7 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,7 +1,5 @@ """Support for KNX cover entities.""" -from __future__ import annotations - from typing import Any from xknx import XKNX diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 980abdcd8c0..608d756316d 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -1,7 +1,5 @@ """Support for KNX date entities.""" -from __future__ import annotations - from datetime import date as dt_date from typing import Any diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 03619842c3b..97342153db8 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -1,7 +1,5 @@ """Support for KNX datetime entities.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index 44fa7163360..568572b93f6 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -1,7 +1,5 @@ """Handle Home Assistant Devices for the KNX integration.""" -from __future__ import annotations - from xknx import XKNX from xknx.core import XknxConnectionState from xknx.io.gateway_scanner import GatewayDescriptor diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index e4a48c9c68d..e3d5141c1ce 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -1,7 +1,5 @@ """Provide device triggers for KNX.""" -from __future__ import annotations - from typing import Any, Final import voluptuous as vol @@ -117,6 +115,7 @@ async def async_attach_trigger( try: trigger_config = TRIGGER_TRIGGER_SCHEMA(trigger_config) except vol.Invalid as err: + # pylint: disable-next=home-assistant-exception-not-translated raise InvalidDeviceAutomationConfig(f"{err}") from err return await trigger.async_attach_trigger( diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 8f98089a567..308a9192ecb 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the KNX integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py index b07e5046db7..bb5792c0061 100644 --- a/homeassistant/components/knx/dpt.py +++ b/homeassistant/components/knx/dpt.py @@ -10,7 +10,7 @@ from xknx.dpt.dpt_16 import DPTString from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import UnitOfReactiveEnergy -HaDptClass = Literal["numeric", "enum", "complex", "string"] +type HaDptClass = Literal["numeric", "enum", "complex", "string"] class DPTInfo(TypedDict): @@ -139,9 +139,12 @@ _sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = { } _sensor_unit_overrides: Mapping[str, str] = { - "13.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy (VARh in KNX) - "13.015": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergykVARh (kVARh in KNX) - "29.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy8Byte (VARh in KNX) + # DPTReactiveEnergy (VARh in KNX) + "13.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + # DPTReactiveEnergykVARh (kVARh in KNX) + "13.015": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + # DPTReactiveEnergy8Byte (VARh in KNX) + "29.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, } diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index 593e91c99c6..b136e4d0438 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -1,7 +1,6 @@ """Base classes for KNX entities.""" -from __future__ import annotations - +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from xknx.devices import Device as XknxDevice @@ -20,6 +19,15 @@ if TYPE_CHECKING: from .knx_module import KNXModule +@dataclass(slots=True, frozen=True) +class KnxEntityIdentifier: + """Class to identify KNX entities in KNX frontend.""" + + platform: str + unique_id: str + ui: bool # ui or yaml entity + + class KnxUiEntityPlatformController(PlatformControllerBase): """Class to manage dynamic adding and reloading of UI entities.""" @@ -57,6 +65,8 @@ class _KnxEntityBase(Entity): _knx_module: KNXModule _device: XknxDevice + _knx_entity_identifier: KnxEntityIdentifier | None = None + @property def available(self) -> bool: """Return True if entity is available.""" @@ -75,10 +85,16 @@ class _KnxEntityBase(Entity): self._device.register_device_updated_cb(self.after_update_callback) self._device.xknx.devices.async_add(self._device) if uid := self.unique_id: + self._knx_entity_identifier = KnxEntityIdentifier( + platform=self.platform_data.domain, + unique_id=uid, + ui=isinstance(self, KnxUiEntity), + ) self._knx_module.add_to_group_address_entities( group_addresses=self._device.group_addresses(), - identifier=(self.platform_data.domain, uid), + identifier=self._knx_entity_identifier, ) + # super call needed to have methods of multi-inherited classes called # eg. for restoring state (like _KNXSwitch) await super().async_added_to_hass() @@ -87,10 +103,10 @@ class _KnxEntityBase(Entity): """Disconnect device object when removed.""" self._device.unregister_device_updated_cb(self.after_update_callback) self._device.xknx.devices.async_remove(self._device) - if uid := self.unique_id: + if self._knx_entity_identifier: self._knx_module.remove_from_group_address_entities( group_addresses=self._device.group_addresses(), - identifier=(self.platform_data.domain, uid), + identifier=self._knx_entity_identifier, ) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 07dc8b70a02..c7f478fedd1 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,7 +1,5 @@ """Expose Home Assistant entity states to KNX.""" -from __future__ import annotations - from asyncio import TaskGroup from collections.abc import Callable, Iterable from dataclasses import dataclass @@ -129,10 +127,13 @@ def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions: value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] dpt: type[DPTBase] if value_type == "binary": - # HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then) + # HA yaml expose flag for DPT-1 + # (no explicit DPT 1 definitions in xknx back then) dpt = DPTSwitch else: - dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation + dpt = DPTBase.parse_transcoder( # type: ignore[assignment] + config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] + ) ga = parse_device_group_address(config[KNX_ADDRESS]) cooldown_seconds = config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN].total_seconds() periodic_send_seconds = config[ diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 21db22d515c..c5238f78fd5 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -1,7 +1,5 @@ """Support for KNX fan entities.""" -from __future__ import annotations - import logging import math from typing import Any @@ -62,7 +60,8 @@ def async_migrate_yaml_uids( break else: _LOGGER.info( - "No YAML entry found to migrate fan entity '%s' unique_id from '%s'. Removing entry", + "No YAML entry found to migrate fan entity '%s'" + " unique_id from '%s'. Removing entry", none_entity_id, invalid_uid, ) @@ -82,7 +81,8 @@ def async_migrate_yaml_uids( new_uid, ) except ValueError: - # New unique_id already exists - remove invalid entry. User might have changed YAML + # New unique_id already exists - remove invalid + # entry. User might have changed YAML _LOGGER.info( "Failed to migrate fan entity '%s' unique_id from '%s' to '%s'. " "Removing the invalid entry", diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py index 105817a04d5..ce09f5ab9f9 100644 --- a/homeassistant/components/knx/knx_module.py +++ b/homeassistant/components/knx/knx_module.py @@ -1,7 +1,5 @@ """Base module for the KNX integration.""" -from __future__ import annotations - import logging from xknx import XKNX @@ -54,10 +52,12 @@ from .const import ( TELEGRAM_LOG_DEFAULT, ) from .device import KNXInterfaceDevice +from .entity import KnxEntityIdentifier from .expose import KnxExposeEntity, KnxExposeTime from .project import KNXProject from .repairs import data_secure_group_key_issue_dispatcher from .storage.config_store import KNXConfigStore +from .storage.expose_controller import ExposeController from .storage.time_server import TimeServerController from .telegrams import Telegrams @@ -76,6 +76,7 @@ class KNXModule: self.connected = False self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = [] self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {} + self.ui_expose_controller = ExposeController() self.ui_time_server_controller = TimeServerController() self.entry = entry @@ -111,7 +112,7 @@ class KNXModule: self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} self.group_address_entities: dict[ - DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),} + DeviceGroupAddress, set[KnxEntityIdentifier] ] = {} self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() @@ -235,7 +236,7 @@ class KNXModule: def add_to_group_address_entities( self, group_addresses: set[DeviceGroupAddress], - identifier: tuple[str, str], # (platform, unique_id) + identifier: KnxEntityIdentifier, ) -> None: """Register entity in group_address_entities map.""" for ga in group_addresses: @@ -246,7 +247,7 @@ class KNXModule: def remove_from_group_address_entities( self, group_addresses: set[DeviceGroupAddress], - identifier: tuple[str, str], + identifier: KnxEntityIdentifier, ) -> None: """Unregister entity from group_address_entities map.""" for ga in group_addresses: @@ -257,7 +258,7 @@ class KNXModule: def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" - self.connected = state == XknxConnectionState.CONNECTED + self.connected = state is XknxConnectionState.CONNECTED for device in self.xknx.devices: device.after_update() @@ -279,7 +280,9 @@ class KNXModule: or next( ( _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() + for _filter, _transcoder in ( + self._address_filter_transcoder.items() + ) if _filter.match(telegram.destination_address) ), None, diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 4ceeda7b932..daa3cd20fdb 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -1,7 +1,5 @@ """Support for KNX light entities.""" -from __future__ import annotations - from typing import Any, cast from propcache.api import cached_property @@ -353,7 +351,8 @@ class _KnxLight(LightEntity): rgb, _ = self._device.current_color if rgb is not None: if not self._device.supports_brightness: - # brightness will be calculated from color so color must not hold brightness again + # brightness will be calculated from color + # so color must not hold brightness again return cast( tuple[int, int, int], color_util.match_max_scale((255,), rgb) ) @@ -367,7 +366,8 @@ class _KnxLight(LightEntity): rgb, white = self._device.current_color if rgb is not None and white is not None: if not self._device.supports_brightness: - # brightness will be calculated from color so color must not hold brightness again + # brightness will be calculated from color + # so color must not hold brightness again return cast( tuple[int, int, int, int], color_util.match_max_scale((255,), (*rgb, white)), @@ -378,7 +378,8 @@ class _KnxLight(LightEntity): @property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" - # Hue is scaled 0..360 int encoded in 1 byte in KNX (-> only 256 possible values) + # Hue is scaled 0..360 int encoded in 1 byte in KNX + # (-> only 256 possible values) # Saturation is scaled 0..100 int return self._device.current_hs_color diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index c0a838b48c0..2f41732d03d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,8 +12,8 @@ "quality_scale": "platinum", "requirements": [ "xknx==3.15.0", - "xknxproject==3.8.2", - "knx-frontend==2026.3.28.223133" + "xknxproject==3.9.0", + "knx-frontend==2026.6.1.213802" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 8fced84f31e..63e15aea7ec 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -1,7 +1,5 @@ """Support for KNX notify entities.""" -from __future__ import annotations - from xknx import XKNX from xknx.devices import Notification as XknxNotification diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 4611cd50261..8107ea1076d 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -1,7 +1,5 @@ """Support for KNX number entities.""" -from __future__ import annotations - from typing import cast from xknx.devices import NumericValue @@ -129,7 +127,9 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity): self._attr_device_class = config.get( CONF_DEVICE_CLASS, try_parse_enum( - # sensor device classes should, with some exceptions ("enum" etc.), align with number device classes + # sensor device classes should, with some + # exceptions ("enum" etc.), align with + # number device classes NumberDeviceClass, dpt_info["sensor_device_class"], ), @@ -193,7 +193,9 @@ class KnxUiNumber(_KnxNumber, KnxUiEntity): ) else: self._attr_device_class = try_parse_enum( - # sensor device classes should, with some exceptions ("enum" etc.), align with number device classes + # sensor device classes should, with some + # exceptions ("enum" etc.), align with + # number device classes NumberDeviceClass, dpt_info["sensor_device_class"], ) diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py index 04cac68aab0..aabb893edc2 100644 --- a/homeassistant/components/knx/project.py +++ b/homeassistant/components/knx/project.py @@ -1,7 +1,5 @@ """Handle KNX project data.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Final diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py index 37bdaaa0f42..42b5e75f52f 100644 --- a/homeassistant/components/knx/repairs.py +++ b/homeassistant/components/knx/repairs.py @@ -1,7 +1,5 @@ """Repairs for KNX integration.""" -from __future__ import annotations - from collections.abc import Callable from functools import partial from typing import TYPE_CHECKING, Any, Final @@ -10,8 +8,7 @@ import voluptuous as vol from xknx.exceptions.exception import InvalidSecureConfiguration from xknx.telegram import GroupAddress, IndividualAddress, Telegram -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir, selector from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -112,13 +109,13 @@ class DataSecureGroupIssueRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_secure_knxkeys() async def async_step_secure_knxkeys( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Manage upload of new KNX Keyring file.""" errors: dict[str, str] = {} @@ -154,9 +151,7 @@ class DataSecureGroupIssueRepairFlow(RepairsFlow): ) @callback - def finish_flow( - self, new_entry_data: KNXConfigEntryData - ) -> data_entry_flow.FlowResult: + def finish_flow(self, new_entry_data: KNXConfigEntryData) -> RepairsFlowResult: """Finish the repair flow. Reload the config entry.""" knx_config_entries = self.hass.config_entries.async_entries(DOMAIN) if knx_config_entries: diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index 5c9bb7db4a1..edc23cd5566 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -1,7 +1,5 @@ """Support for KNX scene entities.""" -from __future__ import annotations - from typing import Any from xknx.devices import Device as XknxDevice, Scene as XknxScene diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index d8ef4adc4da..0d416c7b7a1 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -1,7 +1,5 @@ """Voluptuous schemas for the KNX integration.""" -from __future__ import annotations - from abc import ABC from collections import OrderedDict from datetime import timedelta @@ -150,8 +148,10 @@ def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: def _sensor_attribute_sub_validator(config: dict) -> dict: - """Validate that state_class is compatible with device_class and unit_of_measurement.""" - transcoder: type[DPTBase] = DPTBase.parse_transcoder(config[CONF_TYPE]) # type: ignore[assignment] # already checked in sensor_type_validator + """Validate state_class, device_class and unit compatibility.""" + transcoder: type[DPTBase] = DPTBase.parse_transcoder( # type: ignore[assignment] + config[CONF_TYPE] + ) dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] return validate_sensor_attributes(dpt_metadata, config) diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 3b9e0f7b059..96ee0afdd23 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -1,7 +1,5 @@ """Support for KNX select entities.""" -from __future__ import annotations - from xknx import XKNX from xknx.devices import Device as XknxDevice, RawValue diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index cf0436e0430..5056e17cf3d 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,7 +1,5 @@ """Support for KNX sensor entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 0e6798e1584..3341abd209a 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -1,7 +1,5 @@ """KNX integration services.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -116,20 +114,27 @@ async def service_event_register_modify(call: ServiceCall) -> None: knx_module = get_knx_module(call.hass) attr_address = call.data[KNX_ADDRESS] - group_addresses = list(map(parse_device_group_address, attr_address)) + group_addresses = set(map(parse_device_group_address, attr_address)) if call.data.get(SERVICE_KNX_ATTR_REMOVE): + _error_gas = set() for group_address in group_addresses: try: knx_module.knx_event_callback.group_addresses.remove(group_address) except ValueError: - _LOGGER.warning( - "Service event_register could not remove event for '%s'", - str(group_address), - ) + _error_gas.add(group_address) if group_address in knx_module.group_address_transcoder: del knx_module.group_address_transcoder[group_address] - return + + if not _error_gas: + return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_event_register_ga_not_found", + translation_placeholders={ + "group_addresses": ", ".join(map(str, sorted(_error_gas))) + }, + ) if (dpt := call.data.get(CONF_TYPE)) and ( transcoder := DPTBase.parse_transcoder(dpt) diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 05a74fcc15d..7a51b01d9c9 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -11,15 +11,16 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.util.ulid import ulid_now -from ..const import DOMAIN +from ..const import DOMAIN, KNX_MODULE_KEY from . import migration from .const import CONF_DATA +from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel from .time_server import KNXTimeServerStoreModel _LOGGER = logging.getLogger(__name__) STORAGE_VERSION: Final = 2 -STORAGE_VERSION_MINOR: Final = 3 +STORAGE_VERSION_MINOR: Final = 4 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -32,6 +33,7 @@ class KNXConfigStoreModel(TypedDict): """Represent KNX configuration store data.""" entities: KNXEntityStoreModel + expose: KNXExposeStoreModel time_server: KNXTimeServerStoreModel @@ -68,6 +70,10 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): # version 2.3 introduced in 2026.3 migration.migrate_2_2_to_2_3(old_data) + if old_major_version <= 2 and old_minor_version < 4: + # version 2.4 introduced in 2026.5 + migration.migrate_2_3_to_2_4(old_data) + return old_data @@ -87,6 +93,7 @@ class KNXConfigStore: ) self.data = KNXConfigStoreModel( # initialize with default structure entities={}, + expose={}, time_server={}, ) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} @@ -99,6 +106,10 @@ class KNXConfigStore: "Loaded KNX config data from storage. %s entity platforms", len(self.data["entities"]), ) + _LOGGER.debug( + "Loaded KNX config data from storage. %s exposes", + len(self.data["expose"]), + ) def add_platform( self, platform: Platform, controller: PlatformControllerBase @@ -183,6 +194,54 @@ class KNXConfigStore: if registry_entry.unique_id in unique_ids ] + def get_exposes(self) -> KNXExposeStoreModel: + """Return KNX entity state expose configuration.""" + return self.data["expose"] + + def get_expose_groups(self) -> dict[str, list[str]]: + """Return KNX entity state exposes and their group addresses.""" + return { + entity_id: [option["ga"]["write"] for option in config["options"]] + for entity_id, config in self.data["expose"].items() + } + + def get_expose_config(self, entity_id: str) -> KNXExposeStoreConfigModel: + """Return KNX entity state expose configuration and notes for an entity.""" + return self.data["expose"].get(entity_id, KNXExposeStoreConfigModel(options=[])) + + async def update_expose( + self, entity_id: str, expose_config: KNXExposeStoreConfigModel + ) -> None: + """Update KNX expose configuration for an entity. + + Args: + entity_id: The entity ID to configure. + expose_config: Expose configuration with options and optional notes. + """ + knx_module = self.hass.data[KNX_MODULE_KEY] + expose_controller = knx_module.ui_expose_controller + + expose_controller.update_entity_expose( + self.hass, knx_module.xknx, entity_id, expose_config + ) + + self.data["expose"][entity_id] = expose_config + await self._store.async_save(self.data) + + async def delete_expose(self, entity_id: str) -> None: + """Delete KNX expose configuration for an entity.""" + knx_module = self.hass.data[KNX_MODULE_KEY] + expose_controller = knx_module.ui_expose_controller + expose_controller.remove_entity_expose(entity_id) + + try: + del self.data["expose"][entity_id] + except KeyError as err: + raise ConfigStoreException( + f"Entity not found in expose configuration: {entity_id}" + ) from err + await self._store.async_save(self.data) + @callback def get_time_server_config(self) -> KNXTimeServerStoreModel: """Return KNX time server configuration.""" @@ -191,7 +250,7 @@ class KNXConfigStore: async def update_time_server_config(self, config: KNXTimeServerStoreModel) -> None: """Update time server configuration.""" self.data["time_server"] = config - knx_module = self.hass.data.get(DOMAIN) + knx_module = self.hass.data[KNX_MODULE_KEY] if knx_module: knx_module.ui_time_server_controller.start(knx_module.xknx, config) await self._store.async_save(self.data) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index c1b5d77c63f..7fffd6baaa0 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -479,7 +479,8 @@ NUMBER_KNX_SCHEMA = AllSerializeFirst( vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( options=[cls.value for cls in NumberDeviceClass], - translation_key="component.knx.selector.sensor_device_class", # should align with sensor + # should align with sensor + translation_key="component.knx.selector.sensor_device_class", sort=True, ) ), @@ -683,7 +684,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema( def _sensor_attribute_sub_validator(config: dict) -> dict: - """Validate that state_class is compatible with device_class and unit_of_measurement.""" + """Validate state_class, device_class and unit compatibility.""" dpt = config[CONF_GA_SENSOR][CONF_DPT] dpt_metadata = get_supported_dpts()[dpt] return validate_sensor_attributes(dpt_metadata, config) diff --git a/homeassistant/components/knx/storage/expose_controller.py b/homeassistant/components/knx/storage/expose_controller.py new file mode 100644 index 00000000000..96206d5c235 --- /dev/null +++ b/homeassistant/components/knx/storage/expose_controller.py @@ -0,0 +1,166 @@ +"""KNX configuration storage for entity state exposes.""" + +from typing import Any, NotRequired, TypedDict + +import voluptuous as vol +from xknx import XKNX +from xknx.dpt import DPTBase +from xknx.telegram.address import parse_device_group_address + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + selector, + template as template_helper, +) + +from ..expose import KnxExposeEntity, KnxExposeOptions +from .entity_store_validation import validate_config_store_data +from .knx_selector import GASelector + + +class KNXExposeStoreOptionModel(TypedDict): + """Represent KNX entity state expose configuration for an entity.""" + + ga: dict[str, Any] # group address configuration with write and dpt + attribute: NotRequired[str] + cooldown: NotRequired[float] + default: NotRequired[Any] + periodic_send: NotRequired[float] + respond_to_read: NotRequired[bool] + value_template: NotRequired[str] + + +class KNXExposeStoreConfigModel(TypedDict): + """Represent stored KNX expose configuration with metadata.""" + + options: list[KNXExposeStoreOptionModel] + notes: NotRequired[str] + + +type KNXExposeStoreModel = dict[str, KNXExposeStoreConfigModel] # dict[entity_id: conf] + + +class KNXExposeDataModel(TypedDict): + """Represent a loaded KNX expose config for validation.""" + + entity_id: str + data: KNXExposeStoreConfigModel + + +def validate_expose_template_no_coerce(value: str) -> str: + """Validate an expose template without coercing to Template.""" + temp = cv.template(value) # validate template + if temp.is_static: + raise vol.Invalid( + "Static templates are not supported." + " Template should start with '{{'" + " and end with '}}'" + ) + return value # return original string for storage and later template creation + + +EXPOSE_OPTION_SCHEMA = vol.Schema( + { + vol.Required("ga"): GASelector( + state=False, + passive=False, + write_required=True, + dpt=["numeric", "enum", "complex", "string"], + ), + vol.Optional("attribute"): str, + vol.Optional("default"): object, + vol.Optional("cooldown"): cv.positive_float, # frontend renders to duration + vol.Optional("periodic_send"): cv.positive_float, + vol.Optional("respond_to_read"): bool, + vol.Optional("value_template"): validate_expose_template_no_coerce, + } +) + +EXPOSE_CONFIG_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): selector.EntitySelector(), + vol.Required("data"): vol.Schema( + { + vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + vol.Optional("notes"): str, + } + ), + }, + extra=vol.REMOVE_EXTRA, +) + + +def validate_expose_data(data: dict) -> KNXExposeDataModel: + """Validate and convert expose configuration data.""" + return validate_config_store_data(EXPOSE_CONFIG_SCHEMA, data) # type: ignore[return-value] + + +def _store_to_expose_option( + hass: HomeAssistant, config: KNXExposeStoreOptionModel +) -> KnxExposeOptions: + """Convert config store option model to expose options.""" + ga = parse_device_group_address(config["ga"]["write"]) + dpt: type[DPTBase] = DPTBase.parse_transcoder(config["ga"]["dpt"]) # type: ignore[assignment] + value_template = None + if (_value_template_config := config.get("value_template")) is not None: + value_template = template_helper.Template(_value_template_config, hass) + return KnxExposeOptions( + group_address=ga, + dpt=dpt, + attribute=config.get("attribute"), + cooldown=config.get("cooldown", 0), + default=config.get("default"), + periodic_send=config.get("periodic_send", 0), + respond_to_read=config.get("respond_to_read", True), + value_template=value_template, + ) + + +class ExposeController: + """Controller class for UI entity exposures.""" + + def __init__(self) -> None: + """Initialize entity expose controller.""" + self._entity_exposes: dict[str, KnxExposeEntity] = {} + + @callback + def stop(self) -> None: + """Shutdown entity expose controller.""" + for expose in self._entity_exposes.values(): + expose.async_remove() + self._entity_exposes.clear() + + @callback + def start( + self, hass: HomeAssistant, xknx: XKNX, config: KNXExposeStoreModel + ) -> None: + """Update entity expose configuration.""" + if self._entity_exposes: + self.stop() + for entity_id, options in config.items(): + self.update_entity_expose(hass, xknx, entity_id, options) + + @callback + def update_entity_expose( + self, + hass: HomeAssistant, + xknx: XKNX, + entity_id: str, + expose_config: KNXExposeStoreConfigModel, + ) -> None: + """Update entity expose configuration for an entity.""" + self.remove_entity_expose(entity_id) + + expose_options = [ + _store_to_expose_option(hass, config) for config in expose_config["options"] + ] + expose = KnxExposeEntity(hass, xknx, entity_id, expose_options) + self._entity_exposes[entity_id] = expose + expose.async_register() + + @callback + def remove_entity_expose(self, entity_id: str) -> None: + """Remove entity expose configuration for an entity.""" + if entity_id in self._entity_exposes: + self._entity_exposes.pop(entity_id).async_remove() diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 896eef4876e..6ec0efb1760 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -34,14 +34,16 @@ class KNXSelectorBase: def serialize(self) -> dict[str, Any]: """Serialize the selector to a dictionary.""" - # don't use "name", "default", "optional" or "required" in base output - # as it will be overwritten by the parent keys attributes - # "schema" will be overwritten by knx serializer if `self.serialize_subschema` is True + # don't use "name", "default", "optional" or + # "required" in base output as it will be + # overwritten by the parent keys attributes + # "schema" will be overwritten by knx serializer + # if `self.serialize_subschema` is True raise NotImplementedError("Subclasses must implement this method.") class KNXSectionFlat(KNXSelectorBase): - """Generate a schema-neutral section with title and description for the following siblings.""" + """Generate a schema-neutral section with title and description.""" selector_type = "knx_section_flat" schema = vol.Schema(None) diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py index de158f4c5f9..e4c33e319d1 100644 --- a/homeassistant/components/knx/storage/migration.py +++ b/homeassistant/components/knx/storage/migration.py @@ -55,3 +55,8 @@ def migrate_2_1_to_2_2(data: dict[str, Any]) -> None: def migrate_2_2_to_2_3(data: dict[str, Any]) -> None: """Migrate from schema 2.2 to schema 2.3.""" data.setdefault("time_server", {}) + + +def migrate_2_3_to_2_4(data: dict[str, Any]) -> None: + """Migrate from schema 2.3 to schema 2.4.""" + data.setdefault("expose", {}) diff --git a/homeassistant/components/knx/storage/time_server.py b/homeassistant/components/knx/storage/time_server.py index 47e2fd0669e..ba1e0c323bf 100644 --- a/homeassistant/components/knx/storage/time_server.py +++ b/homeassistant/components/knx/storage/time_server.py @@ -1,7 +1,5 @@ """Time server controller for KNX integration.""" -from __future__ import annotations - from typing import Any, TypedDict import voluptuous as vol diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 04372c78fda..1e041a87fe7 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -154,6 +154,12 @@ } }, "config_panel": { + "common": { + "exposes_count": "Exposes: {count}", + "group_address": "Group address", + "group_addresses": "Group addresses", + "monitor_x_group_addresses": "Monitor {count} group addresses" + }, "dashboard": { "connection_flow": { "description": "Reconfigure KNX connection or import a new KNX keyring file", @@ -950,6 +956,53 @@ "description": "Add and manage KNX entities", "title": "Entities" }, + "expose": { + "create": { + "add_expose": "Add expose", + "attribute": { + "description": "Expose changes of a specific attribute of the entity instead of the state. Optional. If the attribute is not set, the entity state is exposed." + }, + "cooldown": { + "description": "Minimum time between consecutive sends. This can be used to prevent high traffic on the KNX bus when values change very frequently. Only the most recent value during the cooldown period is sent.", + "label": "Cooldown" + }, + "copy_info": "Copying options of {entity_name} ({entity_id}).", + "default": { + "description": "The value to send if the entity state is `unavailable` or `unknown`, or if the attribute is not set. If `default` is omitted, nothing is sent in these cases, but the last known value remains available for read requests.", + "label": "Default value" + }, + "entity": { + "description": "Home Assistant entity to expose state changes to the KNX bus.", + "label": "Entity" + }, + "ga": { + "label": "[%key:component::knx::config_panel::common::group_address%]" + }, + "notes": { + "label": "Notes", + "placeholder": "Add your notes here..." + }, + "periodic_send": { + "description": "Time interval to automatically resend the current value to the KNX bus, even if it hasn’t changed.", + "label": "Periodic send interval" + }, + "respond_to_read": { + "description": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::description%]", + "label": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::label%]" + }, + "section_advanced_options": { + "title": "Advanced options" + }, + "show_raw_values": "Show raw values", + "title": "Add exposure", + "value_template": { + "description": "Optionally transform the entity state or attribute value before sending it to KNX using a template. The template receives the entity state or attribute value as `value` variable.", + "label": "Value template" + } + }, + "description": "Expose Home Assistant entity states to the KNX bus", + "title": "Expose" + }, "group_monitor": { "description": "Monitor KNX group communication", "title": "Group monitor" @@ -1041,6 +1094,9 @@ "integration_not_loaded": { "message": "KNX integration is not loaded." }, + "service_event_register_ga_not_found": { + "message": "Could not find registered event for `{group_addresses}` to remove." + }, "service_exposure_remove_not_found": { "message": "Could not find exposure for `{group_address}` to remove." }, @@ -1171,7 +1227,7 @@ "fields": { "address": { "description": "Group address(es) that shall be added or removed. Lists are allowed.", - "name": "[%key:component::knx::services::send::fields::address::name%]" + "name": "[%key:component::knx::config_panel::common::group_address%]" }, "remove": { "description": "Whether the group address(es) will be removed.", @@ -1189,7 +1245,7 @@ "fields": { "address": { "description": "Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered.", - "name": "[%key:component::knx::services::send::fields::address::name%]" + "name": "[%key:component::knx::config_panel::common::group_address%]" }, "attribute": { "description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.", @@ -1219,7 +1275,7 @@ "fields": { "address": { "description": "Group address(es) to send read request to. Lists will read multiple group addresses.", - "name": "[%key:component::knx::services::send::fields::address::name%]" + "name": "[%key:component::knx::config_panel::common::group_address%]" } }, "name": "Read from KNX bus" @@ -1233,7 +1289,7 @@ "fields": { "address": { "description": "Group address(es) to write to. Lists will send to multiple group addresses successively.", - "name": "Group address" + "name": "[%key:component::knx::config_panel::common::group_address%]" }, "payload": { "description": "Payload to send to the bus. Integers are treated as DPT 1/2/3 payloads. For DPTs > 6 bits send a list. Each value represents 1 octet (0-255). Pad with 0 to DPT byte length.", diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 633ccced857..f2aae1d9159 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -1,7 +1,5 @@ """Support for KNX switch entities.""" -from __future__ import annotations - from typing import Any from xknx.devices import Switch as XknxSwitch diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 1aa75aa1141..db86c2b0fee 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -1,7 +1,5 @@ """KNX Telegram handler.""" -from __future__ import annotations - from collections import deque from typing import Final, TypedDict diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 0e8a383c311..5fa5f0a4940 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -1,7 +1,5 @@ """Support for KNX text entities.""" -from __future__ import annotations - from propcache.api import cached_property from xknx.devices import Notification as XknxNotification from xknx.dpt import DPTLatin1 diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 67da5af71ad..ee07d5f34c5 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -1,7 +1,5 @@ """Support for KNX time entities.""" -from __future__ import annotations - from datetime import time as dt_time from typing import Any diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index f218dec0fae..ddd7c6775b5 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -55,7 +55,8 @@ def ga_validator(value: Any) -> str | int: """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" if not isinstance(value, (str, int)): raise vol.Invalid( - f"'{value}' is not a valid KNX group address: Invalid type '{type(value).__name__}'" + f"'{value}' is not a valid KNX group address:" + f" Invalid type '{type(value).__name__}'" ) try: parse_device_group_address(value) @@ -214,9 +215,12 @@ def validate_number_attributes( and (d_c_units := NUMBER_DEVICE_CLASS_UNITS.get(device_class)) is not None and unit_of_measurement not in d_c_units ): + _options = ", ".join(sorted(map(str, d_c_units), key=str.casefold)) raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + f"Unit of measurement '{unit_of_measurement}'" + f" is not valid for device class" + f" '{device_class}'." + f" Valid options are: {_options}", path=( [CONF_DEVICE_CLASS] if CONF_DEVICE_CLASS in config @@ -230,7 +234,7 @@ def validate_number_attributes( def validate_sensor_attributes( dpt_info: DPTInfo, config: dict[str, Any] ) -> dict[str, Any]: - """Validate that state_class is compatible with device_class and unit_of_measurement. + """Validate state_class, device_class and unit compatibility. Works for both, UI and YAML configuration schema since they share same names for all tested attributes. @@ -253,9 +257,11 @@ def validate_sensor_attributes( and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None and state_class not in state_classes ): + _options = ", ".join(sorted(map(str, state_classes), key=str.casefold)) raise vol.Invalid( - f"State class '{state_class}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", + f"State class '{state_class}' is not valid" + f" for device class '{device_class}'." + f" Valid options are: {_options}", path=[CONF_SENSOR_STATE_CLASS], ) if ( @@ -263,9 +269,12 @@ def validate_sensor_attributes( and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None and unit_of_measurement not in d_c_units ): + _options = ", ".join(sorted(map(str, d_c_units), key=str.casefold)) raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + f"Unit of measurement '{unit_of_measurement}'" + f" is not valid for device class" + f" '{device_class}'." + f" Valid options are: {_options}", path=( [CONF_DEVICE_CLASS] if CONF_DEVICE_CLASS in config @@ -277,9 +286,12 @@ def validate_sensor_attributes( and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None and unit_of_measurement not in s_c_units ): + _options = ", ".join(sorted(map(str, s_c_units), key=str.casefold)) raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " - f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", + f"Unit of measurement '{unit_of_measurement}'" + f" is not valid for state class" + f" '{state_class}'." + f" Valid options are: {_options}", path=( [CONF_SENSOR_STATE_CLASS] if CONF_SENSOR_STATE_CLASS in config diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 40067493aab..1e73bc28d1a 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -1,7 +1,5 @@ """Support for KNX weather entities.""" -from __future__ import annotations - from xknx import XKNX from xknx.devices import Weather as XknxWeather diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index e70f89d5934..223668de7e9 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -1,7 +1,5 @@ """KNX Websocket API.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from contextlib import ExitStack from functools import wraps @@ -14,6 +12,7 @@ from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.frontend import async_panel_exists from homeassistant.components.http import StaticPathConfig from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, callback @@ -35,6 +34,7 @@ from .storage.entity_store_validation import ( EntityStoreValidationSuccess, validate_entity_data, ) +from .storage.expose_controller import validate_expose_data from .storage.serialize import get_serialized_schema from .storage.time_server import validate_time_server_data from .telegrams import ( @@ -63,13 +63,18 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_update_entity) websocket_api.async_register_command(hass, ws_delete_entity) websocket_api.async_register_command(hass, ws_get_entity_config) - websocket_api.async_register_command(hass, ws_get_entity_entries) + websocket_api.async_register_command(hass, ws_get_entities_by_group) websocket_api.async_register_command(hass, ws_create_device) websocket_api.async_register_command(hass, ws_get_schema) websocket_api.async_register_command(hass, ws_get_time_server_config) websocket_api.async_register_command(hass, ws_update_time_server_config) + websocket_api.async_register_command(hass, ws_get_expose_groups) + websocket_api.async_register_command(hass, ws_get_expose_config) + websocket_api.async_register_command(hass, ws_update_expose) + websocket_api.async_register_command(hass, ws_delete_expose) + websocket_api.async_register_command(hass, ws_validate_expose) - if DOMAIN not in hass.data.get("frontend_panels", {}): + if not async_panel_exists(hass, DOMAIN): await hass.http.async_register_static_paths( [ StaticPathConfig( @@ -511,22 +516,22 @@ async def ws_delete_entity( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "knx/get_entity_entries", + vol.Required("type"): "knx/get_entities_by_group", } ) @provide_knx @callback -def ws_get_entity_entries( +def ws_get_entities_by_group( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: - """Get entities configured from entity store.""" - entity_entries = [ - entry.extended_dict for entry in knx.config_store.get_entity_entries() - ] - connection.send_result(msg["id"], entity_entries) + """Get entities by group address.""" + data = { + str(ga): identifiers for ga, identifiers in knx.group_address_entities.items() + } + connection.send_result(msg["id"], data) @websocket_api.require_admin @@ -588,6 +593,142 @@ def ws_create_device( connection.send_result(msg["id"], _device.dict_repr) +######## +# Expose +######## + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_expose_groups", + } +) +@provide_knx +@callback +def ws_get_expose_groups( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get exposes from config store.""" + connection.send_result(msg["id"], knx.config_store.get_expose_groups()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_expose_config", + vol.Required("entity_id"): str, + } +) +@provide_knx +@callback +def ws_get_expose_config( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get expose configuration from config store.""" + connection.send_result( + msg["id"], knx.config_store.get_expose_config(msg["entity_id"]) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/update_expose", + vol.Required("entity_id"): str, + vol.Required("data"): dict, # validation done in handler + } +) +@websocket_api.async_response +@provide_knx +async def ws_update_expose( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Update expose configuration in config store.""" + try: + validated_data = validate_expose_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + try: + await knx.config_store.update_expose( + validated_data["entity_id"], validated_data["data"] + ) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/delete_expose", + vol.Required("entity_id"): str, + } +) +@websocket_api.async_response +@provide_knx +async def ws_delete_expose( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Delete expose configuration from config store.""" + try: + await knx.config_store.delete_expose(msg["entity_id"]) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/validate_expose", + vol.Required("entity_id"): str, + vol.Required("data"): dict, # validation done in handler + } +) +@callback +def ws_validate_expose( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Validate expose data.""" + try: + validate_expose_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +############# +# Time server +############# + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index aa98ca7e8be..1106a2ea80a 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None): media_content_id=search_id, media_content_type=search_type, title=title, - can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id, + can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id), can_expand=True, children=children, thumbnail=thumbnail, diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index 30cffded660..b6a9627a600 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Kodi integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 8659872f8c1..0df6c8fdc32 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Kodi.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 52030ec74f2..695fbc48dff 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing with the XBMC/Kodi JSON-RPC API.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta from functools import wraps diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 49e326334b9..7a86f6ebf2b 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -1,7 +1,5 @@ """Kodi notification service.""" -from __future__ import annotations - import logging from typing import Any @@ -104,5 +102,6 @@ class KodiNotificationService(BaseNotificationService): title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) await self._server.GUI.ShowNotification(title, message, icon, displaytime) + # pylint: disable-next=home-assistant-action-swallowed-exception except jsonrpc_async.TransportError: _LOGGER.warning("Unable to fetch Kodi data. Is Kodi online?") diff --git a/homeassistant/components/kodi/services.py b/homeassistant/components/kodi/services.py index 1e6266b3318..2e1139603d1 100644 --- a/homeassistant/components/kodi/services.py +++ b/homeassistant/components/kodi/services.py @@ -1,7 +1,5 @@ """Support for interfacing with the XBMC/Kodi JSON-RPC API.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 42cd39d1473..93336392204 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,449 +1,53 @@ -"""Support for Konnected devices.""" +"""The Konnected.io integration.""" -import copy -import hmac -from http import HTTPStatus -import json -import logging - -from aiohttp.hdrs import AUTHORIZATION -from aiohttp.web import Request, Response import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA -from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_ACCESS_TOKEN, - CONF_BINARY_SENSORS, - CONF_DEVICES, - CONF_DISCOVERY, - CONF_HOST, - CONF_ID, - CONF_NAME, - CONF_PIN, - CONF_PORT, - CONF_REPEAT, - CONF_SENSORS, - CONF_SWITCHES, - CONF_TYPE, - CONF_ZONE, - STATE_OFF, - STATE_ON, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -from .config_flow import ( # Loading the config flow file will register the flow - CONF_DEFAULT_OPTIONS, - CONF_IO, - CONF_IO_BIN, - CONF_IO_DIG, - CONF_IO_SWI, - OPTIONS_SCHEMA, -) -from .const import ( - CONF_ACTIVATION, - CONF_API_HOST, - CONF_BLINK, - CONF_INVERSE, - CONF_MOMENTARY, - CONF_PAUSE, - CONF_POLL_INTERVAL, - DOMAIN, - PIN_TO_ZONE, - STATE_HIGH, - STATE_LOW, - UPDATE_ENDPOINT, - ZONE_TO_PIN, - ZONES, -) -from .handlers import HANDLERS -from .panel import AlarmPanel +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - - -def ensure_pin(value): - """Check if valid pin and coerce to string.""" - if value is None: - raise vol.Invalid("pin value is None") - - if PIN_TO_ZONE.get(str(value)) is None: - raise vol.Invalid("pin not valid") - - return str(value) - - -def ensure_zone(value): - """Check if valid zone and coerce to string.""" - if value is None: - raise vol.Invalid("zone value is None") - - if str(value) not in ZONES: - raise vol.Invalid("zone not valid") - - return str(value) - - -def import_device_validator(config): - """Validate zones and reformat for import.""" - config = copy.deepcopy(config) - io_cfgs = {} - # Replace pins with zones - for conf_platform, conf_io in ( - (CONF_BINARY_SENSORS, CONF_IO_BIN), - (CONF_SENSORS, CONF_IO_DIG), - (CONF_SWITCHES, CONF_IO_SWI), - ): - for zone in config.get(conf_platform, []): - if zone.get(CONF_PIN): - zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]] - del zone[CONF_PIN] - io_cfgs[zone[CONF_ZONE]] = conf_io - - # Migrate config_entry data into default_options structure - config[CONF_IO] = io_cfgs - config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config) - - # clean up fields migrated to options - config.pop(CONF_BINARY_SENSORS, None) - config.pop(CONF_SENSORS, None) - config.pop(CONF_SWITCHES, None) - config.pop(CONF_BLINK, None) - config.pop(CONF_DISCOVERY, None) - config.pop(CONF_API_HOST, None) - config.pop(CONF_IO, None) - return config - - -def import_validator(config): - """Reformat for import.""" - config = copy.deepcopy(config) - - # push api_host into device configs - for device in config.get(CONF_DEVICES, []): - device[CONF_API_HOST] = config.get(CONF_API_HOST, "") - - return config - - -# configuration.yaml schemas (legacy) -BINARY_SENSOR_SCHEMA_YAML = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, - vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, - vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_INVERSE, default=False): cv.boolean, - } - ), - cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), -) - -SENSOR_SCHEMA_YAML = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, - vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, - vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } - ), - cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), -) - -SWITCH_SCHEMA_YAML = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, - vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( - vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) - ), - vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)), - } - ), - cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), -) - -DEVICE_SCHEMA_YAML = vol.All( - vol.Schema( - { - vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML] - ), - vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]), - vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]), - vol.Inclusive(CONF_HOST, "host_info"): cv.string, - vol.Inclusive(CONF_PORT, "host_info"): cv.port, - vol.Optional(CONF_BLINK, default=True): cv.boolean, - vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url), - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - } - ), - import_device_validator, -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - import_validator, - vol.Schema( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_API_HOST): vol.Url(), - vol.Optional(CONF_DEVICES): vol.All( - cv.ensure_list, [DEVICE_SCHEMA_YAML] - ), - } - ), - ) - }, - extra=vol.ALLOW_EXTRA, -) - -YAML_CONFIGS = "yaml_configs" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Konnected platform.""" - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_firmware", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_firmware", - translation_placeholders={ - "kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome", - }, - ) - if (cfg := config.get(DOMAIN)) is None: - cfg = {} - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = { - CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN), - CONF_API_HOST: cfg.get(CONF_API_HOST), - CONF_DEVICES: {}, - } - - hass.http.register_view(KonnectedView) - - # Check if they have yaml configured devices - if CONF_DEVICES not in cfg: - return True - - for device in cfg.get(CONF_DEVICES, []): - # Attempt to importing the cfg. Use - # hass.async_add_job to avoid a deadlock. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device - ) - ) + """Set up the Konnected.io integration.""" + if DOMAIN in config: + _create_issue(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up panel from a config entry.""" - client = AlarmPanel(hass, entry) - # creates a panel data store in hass.data[DOMAIN][CONF_DEVICES] - await client.async_save_data() - - # if the cfg entry was created we know we could connect to the panel at some point - # async_connect will handle retries until it establishes a connection - await client.async_connect() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - entry.async_on_unload(entry.add_update_listener(async_entry_updated)) + """Set up Konnected.io from a config entry.""" + _create_issue(hass) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - if unload_ok: - hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID]) - - return unload_ok + return True -async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when options change.""" - await hass.config_entries.async_reload(entry.entry_id) - - -class KonnectedView(HomeAssistantView): - """View creates an endpoint to receive push updates from the device.""" - - url = UPDATE_ENDPOINT - name = "api:konnected" - requires_auth = False # Uses access token from configuration - - def __init__(self) -> None: - """Initialize the view.""" - - @staticmethod - def binary_value(state, activation): - """Return binary value for GPIO based on state and activation.""" - if activation == STATE_HIGH: - return 1 if state == STATE_ON else 0 - return 0 if state == STATE_ON else 1 - - async def update_sensor(self, request: Request, device_id) -> Response: - """Process a put or post.""" - hass = request.app[KEY_HASS] - data = hass.data[DOMAIN] - - auth = request.headers.get(AUTHORIZATION) - tokens = [] - if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN): - tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]]) - tokens.extend( - [ - entry.data[CONF_ACCESS_TOKEN] - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_ACCESS_TOKEN) - ] - ) - if auth is None or not next( - (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)), - False, - ): - return self.json_message( - "unauthorized", status_code=HTTPStatus.UNAUTHORIZED - ) - - try: # Konnected 2.2.0 and above supports JSON payloads - payload = await request.json() - except json.decoder.JSONDecodeError: - _LOGGER.error( - "Your Konnected device software may be out of " - "date. Visit https://help.konnected.io for " - "updating instructions" - ) - - if (device := data[CONF_DEVICES].get(device_id)) is None: - return self.json_message( - "unregistered device", status_code=HTTPStatus.BAD_REQUEST - ) - - if (panel := device.get("panel")) is not None: - # connect if we haven't already - hass.async_create_task(panel.async_connect()) - - try: - zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]]) - payload[CONF_ZONE] = zone_num - zone_data = ( - device[CONF_BINARY_SENSORS].get(zone_num) - or next( - (s for s in device[CONF_SWITCHES] if s[CONF_ZONE] == zone_num), None - ) - or next( - (s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None - ) - ) - except KeyError: - zone_data = None - - if zone_data is None: - return self.json_message( - "unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST - ) - - zone_data["device_id"] = device_id - - for attr in ("state", "temp", "humi", "addr"): - value = payload.get(attr) - handler = HANDLERS.get(attr) - if value is not None and handler: - hass.async_create_task(handler(hass, zone_data, payload)) - - return self.json_message("ok") - - async def get(self, request: Request, device_id) -> Response: - """Return the current binary state of a switch.""" - hass = request.app[KEY_HASS] - data = hass.data[DOMAIN] - - if not (device := data[CONF_DEVICES].get(device_id)): - return self.json_message( - f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND - ) - - if (panel := device.get("panel")) is not None: - # connect if we haven't already - hass.async_create_task(panel.async_connect()) - - # Our data model is based on zone ids but we convert from/to pin ids - # based on whether they are specified in the request - try: - zone_num = str( - request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]] - ) - zone = next( - switch - for switch in device[CONF_SWITCHES] - if switch[CONF_ZONE] == zone_num - ) - - except StopIteration: - zone = None - except KeyError: - zone = None - zone_num = None - - if not zone: - target = request.query.get( - CONF_ZONE, request.query.get(CONF_PIN, "unknown") - ) - return self.json_message( - f"Switch on zone or pin {target} not configured", - status_code=HTTPStatus.NOT_FOUND, - ) - - resp = {} - if request.query.get(CONF_ZONE): - resp[CONF_ZONE] = zone_num - elif zone_num: - resp[CONF_PIN] = ZONE_TO_PIN[zone_num] - - # Make sure entity is setup - if zone_entity_id := zone.get(ATTR_ENTITY_ID): - resp["state"] = self.binary_value( - hass.states.get(zone_entity_id).state, # type: ignore[union-attr] - zone[CONF_ACTIVATION], - ) - return self.json(resp) - - _LOGGER.warning("Konnected entity not yet setup, returning default") - resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION]) - return self.json(resp) - - async def put(self, request: Request, device_id) -> Response: - """Receive a sensor update via PUT request and async set state.""" - return await self.update_sensor(request, device_id) - - async def post(self, request: Request, device_id) -> Response: - """Receive a sensor update via POST request and async set state.""" - return await self.update_sensor(request, device_id) +def _create_issue(hass: HomeAssistant) -> None: + """Create the integration removed repair issue.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/konnected", + "kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome", + }, + ) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py deleted file mode 100644 index d6bdab37a9c..00000000000 --- a/homeassistant/components/konnected/binary_sensor.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Support for wired binary sensors attached to a Konnected device.""" - -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_STATE, - CONF_BINARY_SENSORS, - CONF_DEVICES, - CONF_NAME, - CONF_TYPE, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up binary sensors attached to a Konnected device from a config entry.""" - data = hass.data[DOMAIN] - device_id = config_entry.data["id"] - sensors = [ - KonnectedBinarySensor(device_id, pin_num, pin_data) - for pin_num, pin_data in data[CONF_DEVICES][device_id][ - CONF_BINARY_SENSORS - ].items() - ] - async_add_entities(sensors) - - -class KonnectedBinarySensor(BinarySensorEntity): - """Representation of a Konnected binary sensor.""" - - _attr_should_poll = False - - def __init__(self, device_id, zone_num, data): - """Initialize the Konnected binary sensor.""" - self._data = data - self._attr_is_on = data.get(ATTR_STATE) - self._attr_device_class = data.get(CONF_TYPE) - self._attr_unique_id = f"{device_id}-{zone_num}" - self._attr_name = data.get(CONF_NAME) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - ) - - async def async_added_to_hass(self) -> None: - """Store entity_id and register state change callback.""" - self._data[ATTR_ENTITY_ID] = self.entity_id - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"konnected.{self.entity_id}.update", self.async_set_state - ) - ) - - @callback - def async_set_state(self, state): - """Update the sensor's state.""" - self._attr_is_on = state - self.async_write_ha_state() diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 7f5f4d8abd4..e82c5e86028 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -1,892 +1,11 @@ -"""Config flow for konnected.io integration.""" +"""Config flow for Konnected.io integration.""" -from __future__ import annotations +from homeassistant.config_entries import ConfigFlow -import asyncio -import copy -import logging -import random -import string -from typing import Any -from urllib.parse import urlparse - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - BinarySensorDeviceClass, -) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_BINARY_SENSORS, - CONF_DISCOVERY, - CONF_HOST, - CONF_ID, - CONF_MODEL, - CONF_NAME, - CONF_PORT, - CONF_REPEAT, - CONF_SENSORS, - CONF_SWITCHES, - CONF_TYPE, - CONF_ZONE, -) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.service_info.ssdp import ( - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - SsdpServiceInfo, -) - -from .const import ( - CONF_ACTIVATION, - CONF_API_HOST, - CONF_BLINK, - CONF_DEFAULT_OPTIONS, - CONF_INVERSE, - CONF_MOMENTARY, - CONF_PAUSE, - CONF_POLL_INTERVAL, - DOMAIN, - STATE_HIGH, - STATE_LOW, - ZONES, -) -from .errors import CannotConnect -from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status - -_LOGGER = logging.getLogger(__name__) - -ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName -CONF_IO = "io" -CONF_IO_DIS = "Disabled" -CONF_IO_BIN = "Binary Sensor" -CONF_IO_DIG = "Digital Sensor" -CONF_IO_SWI = "Switchable Output" - -CONF_MORE_STATES = "more_states" -CONF_YES = "Yes" -CONF_NO = "No" - -CONF_OVERRIDE_API_HOST = "override_api_host" - -KONN_MANUFACTURER = "konnected.io" -KONN_PANEL_MODEL_NAMES = { - KONN_MODEL: "Konnected Alarm Panel", - KONN_MODEL_PRO: "Konnected Alarm Panel Pro", -} - -OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI]) -OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN]) -OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI]) - - -# Config entry schemas -IO_SCHEMA = vol.Schema( - { - vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY, - vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, - vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, - vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, - vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, - vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, - vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, - vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, - vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, - } -) - -BINARY_SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ZONE): vol.In(ZONES), - vol.Required( - CONF_TYPE, default=BinarySensorDeviceClass.DOOR - ): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_INVERSE, default=False): cv.boolean, - } -) - -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ZONE): vol.In(ZONES), - vol.Required(CONF_TYPE, default="dht"): vol.All( - vol.Lower, vol.In(["dht", "ds18b20"]) - ), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } -) - -SWITCH_SCHEMA = vol.Schema( - { - vol.Required(CONF_ZONE): vol.In(ZONES), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( - vol.Lower, vol.In([STATE_HIGH, STATE_LOW]) - ), - vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)), - } -) - -OPTIONS_SCHEMA = vol.Schema( - { - vol.Required(CONF_IO): IO_SCHEMA, - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), - vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), - vol.Optional(CONF_BLINK, default=True): cv.boolean, - vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url), - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - }, - extra=vol.REMOVE_EXTRA, -) - -CONFIG_ENTRY_SCHEMA = vol.Schema( - { - vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES), - vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"), - vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA, - }, - extra=vol.REMOVE_EXTRA, -) +from .const import DOMAIN class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Konnected Panels.""" + """Handle a config flow for Konnected.io.""" VERSION = 1 - - # class variable to store/share discovered host information - DISCOVERED_HOSTS: dict[str, dict[str, Any]] = {} - - unique_id: str - - def __init__(self) -> None: - """Initialize the Konnected flow.""" - self.data: dict[str, Any] = {} - self.options = OPTIONS_SCHEMA({CONF_IO: {}}) - - async def async_gen_config(self, host, port): - """Populate self.data based on panel status. - - This will raise CannotConnect if an error occurs - """ - self.data[CONF_HOST] = host - self.data[CONF_PORT] = port - try: - status = await get_status(self.hass, host, port) - self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", "")) - except (CannotConnect, KeyError) as err: - raise CannotConnect from err - - self.data[CONF_MODEL] = status.get("model", KONN_MODEL) - self.data[CONF_ACCESS_TOKEN] = "".join( - random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) - ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a configuration.yaml config. - - This flow is triggered by `async_setup` for configured panels. - """ - _LOGGER.debug(import_data) - - # save the data and confirm connection via user step - await self.async_set_unique_id(import_data["id"]) - self.options = import_data[CONF_DEFAULT_OPTIONS] - - # config schema ensures we have port if we have host - if import_data.get(CONF_HOST): - # automatically connect if we have host info - return await self.async_step_user( - user_input={ - CONF_HOST: import_data[CONF_HOST], - CONF_PORT: import_data[CONF_PORT], - } - ) - - # if we have no host info wait for it or abort if previously configured - self._abort_if_unique_id_configured() - return await self.async_step_import_confirm() - - async def async_step_import_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm the user wants to import the config entry.""" - if user_input is None: - return self.async_show_form( - step_id="import_confirm", - description_placeholders={"id": self.unique_id}, - ) - - # if we have ssdp discovered applicable host info use it - if KonnectedFlowHandler.DISCOVERED_HOSTS.get(self.unique_id): - return await self.async_step_user( - user_input={ - CONF_HOST: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][ - CONF_HOST - ], - CONF_PORT: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][ - CONF_PORT - ], - } - ) - return await self.async_step_user() - - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo - ) -> ConfigFlowResult: - """Handle a discovered konnected panel. - - This flow is triggered by the SSDP component. It will check if the - device is already configured and attempt to finish the config if not. - """ - _LOGGER.debug(discovery_info) - - try: - if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: - return self.async_abort(reason="not_konn_panel") - - if not any( - name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME] - for name in KONN_PANEL_MODEL_NAMES - ): - _LOGGER.warning( - "Discovered unrecognized Konnected device %s", - discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"), - ) - return self.async_abort(reason="not_konn_panel") - - # If MAC is missing it is a bug in the device fw but we'll guard - # against it since the field is so vital - except KeyError: - _LOGGER.error("Malformed Konnected SSDP info") - else: - # extract host/port from ssdp_location - assert discovery_info.ssdp_location - netloc = urlparse(discovery_info.ssdp_location).netloc.split(":") - self._async_abort_entries_match( - {CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])} - ) - - try: - status = await get_status(self.hass, netloc[0], int(netloc[1])) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - - self.data[CONF_HOST] = netloc[0] - self.data[CONF_PORT] = int(netloc[1]) - self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", "")) - self.data[CONF_MODEL] = status.get("model", KONN_MODEL) - - KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = { - CONF_HOST: self.data[CONF_HOST], - CONF_PORT: self.data[CONF_PORT], - } - return await self.async_step_confirm() - - return self.async_abort(reason="unknown") - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Connect to panel and get config.""" - errors = {} - if user_input: - # build config info and wait for user confirmation - self.data[CONF_HOST] = user_input[CONF_HOST] - self.data[CONF_PORT] = user_input[CONF_PORT] - - # brief delay to allow processing of recent status req - await asyncio.sleep(0.1) - try: - status = await get_status( - self.hass, self.data[CONF_HOST], self.data[CONF_PORT] - ) - except CannotConnect: - errors["base"] = "cannot_connect" - else: - self.data[CONF_ID] = status.get( - "chipId", status["mac"].replace(":", "") - ) - self.data[CONF_MODEL] = status.get("model", KONN_MODEL) - - # save off our discovered host info - KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = { - CONF_HOST: self.data[CONF_HOST], - CONF_PORT: self.data[CONF_PORT], - } - return await self.async_step_confirm() - - return self.async_show_form( - step_id="user", - description_placeholders={ - "host": self.data.get(CONF_HOST, "Unknown"), - "port": self.data.get(CONF_PORT, "Unknown"), - }, - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str, - vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int, - } - ), - errors=errors, - ) - - async def async_step_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Attempt to link with the Konnected panel. - - Given a configured host, will ask the user to confirm and finalize - the connection. - """ - if user_input is None: - # abort and update an existing config entry if host info changes - await self.async_set_unique_id(self.data[CONF_ID]) - self._abort_if_unique_id_configured( - updates=self.data, reload_on_update=False - ) - return self.async_show_form( - step_id="confirm", - description_placeholders={ - "model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], - "id": self.unique_id, - "host": self.data[CONF_HOST], - "port": self.data[CONF_PORT], - }, - ) - - # Create access token, attach default options and create entry - self.data[CONF_DEFAULT_OPTIONS] = self.options - self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get( - CONF_ACCESS_TOKEN - ) or "".join(random.choices(f"{string.ascii_uppercase}{string.digits}", k=20)) - - return self.async_create_entry( - title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], - data=self.data, - ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlowHandler: - """Return the Options Flow.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for a Konnected Panel.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.model = config_entry.data[CONF_MODEL] - self.current_opt = ( - config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS] - ) - - # as config proceeds we'll build up new options and then replace what's in the config entry - self.new_opt: dict[str, Any] = {CONF_IO: {}} - self.active_cfg: str | None = None - self.io_cfg: dict[str, Any] = {} - self.current_states: list[dict[str, Any]] = [] - self.current_state = 1 - - @callback - def get_current_cfg(self, io_type, zone): - """Get the current zone config.""" - return next( - ( - cfg - for cfg in self.current_opt.get(io_type, []) - if cfg[CONF_ZONE] == zone - ), - {}, - ) - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - return await self.async_step_options_io() - - async def async_step_options_io( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure legacy panel IO or first half of pro IO.""" - errors: dict[str, str] = {} - current_io = self.current_opt.get(CONF_IO, {}) - - if user_input is not None: - # strip out disabled io and save for options cfg - for key, value in user_input.items(): - if value != CONF_IO_DIS: - self.new_opt[CONF_IO][key] = value - return await self.async_step_options_io_ext() - - if self.model == KONN_MODEL: - return self.async_show_form( - step_id="options_io", - data_schema=vol.Schema( - { - vol.Required( - "1", default=current_io.get("1", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "2", default=current_io.get("2", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "3", default=current_io.get("3", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "4", default=current_io.get("4", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "5", default=current_io.get("5", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "6", default=current_io.get("6", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "out", default=current_io.get("out", CONF_IO_DIS) - ): OPTIONS_IO_OUTPUT_ONLY, - } - ), - description_placeholders={ - "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.config_entry.data[CONF_HOST], - }, - errors=errors, - ) - - # configure the first half of the pro board io - if self.model == KONN_MODEL_PRO: - return self.async_show_form( - step_id="options_io", - data_schema=vol.Schema( - { - vol.Required( - "1", default=current_io.get("1", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "2", default=current_io.get("2", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "3", default=current_io.get("3", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "4", default=current_io.get("4", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "5", default=current_io.get("5", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "6", default=current_io.get("6", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "7", default=current_io.get("7", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - } - ), - description_placeholders={ - "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.config_entry.data[CONF_HOST], - }, - errors=errors, - ) - - return self.async_abort(reason="not_konn_panel") - - async def async_step_options_io_ext( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Allow the user to configure the extended IO for pro.""" - errors: dict[str, str] = {} - current_io = self.current_opt.get(CONF_IO, {}) - - if user_input is not None: - # strip out disabled io and save for options cfg - for key, value in user_input.items(): - if value != CONF_IO_DIS: - self.new_opt[CONF_IO].update({key: value}) - self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) - return await self.async_step_options_binary() - - if self.model == KONN_MODEL: - self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) - return await self.async_step_options_binary() - - if self.model == KONN_MODEL_PRO: - return self.async_show_form( - step_id="options_io_ext", - data_schema=vol.Schema( - { - vol.Required( - "8", default=current_io.get("8", CONF_IO_DIS) - ): OPTIONS_IO_ANY, - vol.Required( - "9", default=current_io.get("9", CONF_IO_DIS) - ): OPTIONS_IO_INPUT_ONLY, - vol.Required( - "10", default=current_io.get("10", CONF_IO_DIS) - ): OPTIONS_IO_INPUT_ONLY, - vol.Required( - "11", default=current_io.get("11", CONF_IO_DIS) - ): OPTIONS_IO_INPUT_ONLY, - vol.Required( - "12", default=current_io.get("12", CONF_IO_DIS) - ): OPTIONS_IO_INPUT_ONLY, - vol.Required( - "alarm1", default=current_io.get("alarm1", CONF_IO_DIS) - ): OPTIONS_IO_OUTPUT_ONLY, - vol.Required( - "out1", default=current_io.get("out1", CONF_IO_DIS) - ): OPTIONS_IO_OUTPUT_ONLY, - vol.Required( - "alarm2_out2", - default=current_io.get("alarm2_out2", CONF_IO_DIS), - ): OPTIONS_IO_OUTPUT_ONLY, - } - ), - description_placeholders={ - "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.config_entry.data[CONF_HOST], - }, - errors=errors, - ) - - return self.async_abort(reason="not_konn_panel") - - async def async_step_options_binary( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Allow the user to configure the IO options for binary sensors.""" - errors: dict[str, str] = {} - if user_input is not None and self.active_cfg is not None: - zone = {"zone": self.active_cfg} - zone.update(user_input) - self.new_opt[CONF_BINARY_SENSORS] = [ - *self.new_opt.get(CONF_BINARY_SENSORS, []), - zone, - ] - self.io_cfg.pop(self.active_cfg) - self.active_cfg = None - - if self.active_cfg: - current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) - return self.async_show_form( - step_id="options_binary", - data_schema=vol.Schema( - { - vol.Required( - CONF_TYPE, - default=current_cfg.get( - CONF_TYPE, BinarySensorDeviceClass.DOOR - ), - ): DEVICE_CLASSES_SCHEMA, - vol.Optional( - CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) - ): str, - vol.Optional( - CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False) - ): bool, - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper() - }, - errors=errors, - ) - - # find the next unconfigured binary sensor - for key, value in self.io_cfg.items(): - if value == CONF_IO_BIN: - self.active_cfg = key - current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) - return self.async_show_form( - step_id="options_binary", - data_schema=vol.Schema( - { - vol.Required( - CONF_TYPE, - default=current_cfg.get( - CONF_TYPE, BinarySensorDeviceClass.DOOR - ), - ): DEVICE_CLASSES_SCHEMA, - vol.Optional( - CONF_NAME, - default=current_cfg.get(CONF_NAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_INVERSE, - default=current_cfg.get(CONF_INVERSE, False), - ): bool, - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper() - }, - errors=errors, - ) - - return await self.async_step_options_digital() - - async def async_step_options_digital( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Allow the user to configure the IO options for digital sensors.""" - errors: dict[str, str] = {} - if user_input is not None and self.active_cfg is not None: - zone = {"zone": self.active_cfg} - zone.update(user_input) - self.new_opt[CONF_SENSORS] = [*self.new_opt.get(CONF_SENSORS, []), zone] - self.io_cfg.pop(self.active_cfg) - self.active_cfg = None - - if self.active_cfg: - current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) - return self.async_show_form( - step_id="options_digital", - data_schema=vol.Schema( - { - vol.Required( - CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") - ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), - vol.Optional( - CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) - ): str, - vol.Optional( - CONF_POLL_INTERVAL, - default=current_cfg.get(CONF_POLL_INTERVAL, 3), - ): vol.All(vol.Coerce(int), vol.Range(min=1)), - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper() - }, - errors=errors, - ) - - # find the next unconfigured digital sensor - for key, value in self.io_cfg.items(): - if value == CONF_IO_DIG: - self.active_cfg = key - current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) - return self.async_show_form( - step_id="options_digital", - data_schema=vol.Schema( - { - vol.Required( - CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") - ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), - vol.Optional( - CONF_NAME, - default=current_cfg.get(CONF_NAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_POLL_INTERVAL, - default=current_cfg.get(CONF_POLL_INTERVAL, 3), - ): vol.All(vol.Coerce(int), vol.Range(min=1)), - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper() - }, - errors=errors, - ) - - return await self.async_step_options_switch() - - async def async_step_options_switch( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Allow the user to configure the IO options for switches.""" - errors: dict[str, str] = {} - if user_input is not None and self.active_cfg is not None: - zone = {"zone": self.active_cfg} - zone.update(user_input) - del zone[CONF_MORE_STATES] - self.new_opt[CONF_SWITCHES] = [*self.new_opt.get(CONF_SWITCHES, []), zone] - - # iterate through multiple switch states - if self.current_states: - self.current_states.pop(0) - - # only go to next zone if all states are entered - self.current_state += 1 - if user_input[CONF_MORE_STATES] == CONF_NO: - self.io_cfg.pop(self.active_cfg) - self.active_cfg = None - - if self.active_cfg: - current_cfg = next(iter(self.current_states), {}) - return self.async_show_form( - step_id="options_switch", - data_schema=vol.Schema( - { - vol.Optional( - CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) - ): str, - vol.Optional( - CONF_ACTIVATION, - default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), - ): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])), - vol.Optional( - CONF_MOMENTARY, - default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional( - CONF_PAUSE, - default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional( - CONF_REPEAT, - default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=-1)), - vol.Required( - CONF_MORE_STATES, - default=CONF_YES - if len(self.current_states) > 1 - else CONF_NO, - ): vol.In([CONF_YES, CONF_NO]), - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper(), - "state": str(self.current_state), - }, - errors=errors, - ) - - # find the next unconfigured switch - for key, value in self.io_cfg.items(): - if value == CONF_IO_SWI: - self.active_cfg = key - self.current_states = [ - cfg - for cfg in self.current_opt.get(CONF_SWITCHES, []) - if cfg[CONF_ZONE] == self.active_cfg - ] - current_cfg = next(iter(self.current_states), {}) - self.current_state = 1 - return self.async_show_form( - step_id="options_switch", - data_schema=vol.Schema( - { - vol.Optional( - CONF_NAME, - default=current_cfg.get(CONF_NAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_ACTIVATION, - default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), - ): vol.In(["low", "high"]), - vol.Optional( - CONF_MOMENTARY, - default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional( - CONF_PAUSE, - default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=10)), - vol.Optional( - CONF_REPEAT, - default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), - ): vol.All(vol.Coerce(int), vol.Range(min=-1)), - vol.Required( - CONF_MORE_STATES, - default=CONF_YES - if len(self.current_states) > 1 - else CONF_NO, - ): vol.In([CONF_YES, CONF_NO]), - } - ), - description_placeholders={ - "zone": f"Zone {self.active_cfg}" - if len(self.active_cfg) < 3 - else self.active_cfg.upper(), - "state": str(self.current_state), - }, - errors=errors, - ) - - return await self.async_step_options_misc() - - async def async_step_options_misc( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Allow the user to configure the LED behavior.""" - errors = {} - if user_input is not None: - # config schema only does basic schema val so check url here - try: - if user_input[CONF_OVERRIDE_API_HOST]: - cv.url(user_input.get(CONF_API_HOST, "")) - else: - user_input[CONF_API_HOST] = "" - except vol.Invalid: - errors["base"] = "bad_host" - else: - # no need to store the override - can infer - del user_input[CONF_OVERRIDE_API_HOST] - self.new_opt.update(user_input) - return self.async_create_entry(title="", data=self.new_opt) - - return self.async_show_form( - step_id="options_misc", - data_schema=vol.Schema( - { - vol.Required( - CONF_DISCOVERY, - default=self.current_opt.get(CONF_DISCOVERY, True), - ): bool, - vol.Required( - CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True) - ): bool, - vol.Required( - CONF_OVERRIDE_API_HOST, - default=bool(self.current_opt.get(CONF_API_HOST)), - ): bool, - vol.Optional( - CONF_API_HOST, default=self.current_opt.get(CONF_API_HOST, "") - ): str, - } - ), - errors=errors, - ) diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index ffaa548003b..c21880b3f42 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -1,46 +1,3 @@ """Konnected constants.""" DOMAIN = "konnected" - -CONF_ACTIVATION = "activation" -CONF_API_HOST = "api_host" -CONF_DEFAULT_OPTIONS = "default_options" -CONF_MOMENTARY = "momentary" -CONF_PAUSE = "pause" -CONF_POLL_INTERVAL = "poll_interval" -CONF_PRECISION = "precision" -CONF_INVERSE = "inverse" -CONF_BLINK = "blink" -CONF_DHT_SENSORS = "dht_sensors" -CONF_DS18B20_SENSORS = "ds18b20_sensors" - -STATE_LOW = "low" -STATE_HIGH = "high" - -ZONES = [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - "alarm1", - "out1", - "alarm2_out2", - "out", -] - -# alarm panel pro only handles zones, -# alarm panel allows specifying pins via configuration.yaml -PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"} -ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} - -ENDPOINT_ROOT = "/api/konnected" -UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}" -SIGNAL_DS18B20_NEW = "konnected.ds18b20.new" diff --git a/homeassistant/components/konnected/errors.py b/homeassistant/components/konnected/errors.py deleted file mode 100644 index a377942a02f..00000000000 --- a/homeassistant/components/konnected/errors.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Errors for the Konnected component.""" - -from homeassistant.exceptions import HomeAssistantError - - -class KonnectedException(HomeAssistantError): - """Base class for Konnected exceptions.""" - - -class CannotConnect(KonnectedException): - """Unable to connect to the panel.""" diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py deleted file mode 100644 index 55fdc57bc46..00000000000 --- a/homeassistant/components/konnected/handlers.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Handle Konnected messages.""" - -import logging - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util import decorator - -from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW - -_LOGGER = logging.getLogger(__name__) -HANDLERS = decorator.Registry() # type: ignore[var-annotated] - - -@HANDLERS.register("state") -async def async_handle_state_update(hass, context, msg): - """Handle a binary sensor or switch state update.""" - _LOGGER.debug("[state handler] context: %s msg: %s", context, msg) - entity_id = context.get(ATTR_ENTITY_ID) - state = bool(int(msg.get(ATTR_STATE))) - if context.get(CONF_INVERSE): - state = not state - - async_dispatcher_send(hass, f"konnected.{entity_id}.update", state) - - -@HANDLERS.register("temp") -async def async_handle_temp_update(hass, context, msg): - """Handle a temperature sensor state update.""" - _LOGGER.debug("[temp handler] context: %s msg: %s", context, msg) - entity_id, temp = context.get(SensorDeviceClass.TEMPERATURE), msg.get("temp") - if entity_id: - async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp) - - -@HANDLERS.register("humi") -async def async_handle_humi_update(hass, context, msg): - """Handle a humidity sensor state update.""" - _LOGGER.debug("[humi handler] context: %s msg: %s", context, msg) - entity_id, humi = context.get(SensorDeviceClass.HUMIDITY), msg.get("humi") - if entity_id: - async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi) - - -@HANDLERS.register("addr") -async def async_handle_addr_update(hass, context, msg): - """Handle an addressable sensor update.""" - _LOGGER.debug("[addr handler] context: %s msg: %s", context, msg) - addr, temp = msg.get("addr"), msg.get("temp") - if entity_id := context.get(addr): - async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp) - else: - msg["device_id"] = context.get("device_id") - msg["temperature"] = temp - msg["addr"] = addr - async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg) diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json index 08e3a19a25b..aff1a3db7e4 100644 --- a/homeassistant/components/konnected/manifest.json +++ b/homeassistant/components/konnected/manifest.json @@ -1,17 +1,9 @@ { "domain": "konnected", "name": "Konnected.io (Legacy)", - "codeowners": ["@heythisisnate"], - "config_flow": true, - "dependencies": ["http"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/konnected", - "integration_type": "hub", + "integration_type": "system", "iot_class": "local_push", - "loggers": ["konnected"], - "requirements": ["konnected==1.2.0"], - "ssdp": [ - { - "manufacturer": "konnected.io" - } - ] + "requirements": [] } diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py deleted file mode 100644 index e2dfc6be06a..00000000000 --- a/homeassistant/components/konnected/panel.py +++ /dev/null @@ -1,397 +0,0 @@ -"""Support for Konnected devices.""" - -import asyncio -import logging - -import konnected - -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_STATE, - CONF_ACCESS_TOKEN, - CONF_BINARY_SENSORS, - CONF_DEVICES, - CONF_DISCOVERY, - CONF_HOST, - CONF_ID, - CONF_NAME, - CONF_PIN, - CONF_PORT, - CONF_REPEAT, - CONF_SENSORS, - CONF_SWITCHES, - CONF_TYPE, - CONF_ZONE, -) -from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.network import get_url - -from .const import ( - CONF_ACTIVATION, - CONF_API_HOST, - CONF_BLINK, - CONF_DEFAULT_OPTIONS, - CONF_DHT_SENSORS, - CONF_DS18B20_SENSORS, - CONF_INVERSE, - CONF_MOMENTARY, - CONF_PAUSE, - CONF_POLL_INTERVAL, - DOMAIN, - ENDPOINT_ROOT, - STATE_LOW, - ZONE_TO_PIN, -) -from .errors import CannotConnect - -_LOGGER = logging.getLogger(__name__) - -KONN_MODEL = "Konnected" -KONN_MODEL_PRO = "Konnected Pro" - -# Indicate how each unit is controlled (pin or zone) -KONN_API_VERSIONS = { - KONN_MODEL: CONF_PIN, - KONN_MODEL_PRO: CONF_ZONE, -} - - -class AlarmPanel: - """A representation of a Konnected alarm panel.""" - - def __init__(self, hass, config_entry): - """Initialize the Konnected device.""" - self.hass = hass - self.config_entry = config_entry - self.config = config_entry.data - self.options = config_entry.options or config_entry.data.get( - CONF_DEFAULT_OPTIONS, {} - ) - self.host = self.config.get(CONF_HOST) - self.port = self.config.get(CONF_PORT) - self.client = None - self.status = None - self.api_version = KONN_API_VERSIONS[KONN_MODEL] - self.connected = False - self.connect_attempts = 0 - self.cancel_connect_retry = None - - @property - def device_id(self): - """Device id is the chipId (pro) or MAC address as string with punctuation removed.""" - return self.config.get(CONF_ID) - - @property - def stored_configuration(self): - """Return the configuration stored in `hass.data` for this device.""" - return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) - - @property - def available(self): - """Return whether the device is available.""" - return self.connected - - def format_zone(self, zone, other_items=None): - """Get zone or pin based dict based on the client type.""" - payload = { - self.api_version: zone - if self.api_version == CONF_ZONE - else ZONE_TO_PIN[zone] - } - payload.update(other_items or {}) - return payload - - async def async_connect(self, now=None): - """Connect to and setup a Konnected device.""" - if self.connected: - return - - if self.cancel_connect_retry: - # cancel any pending connect attempt and try now - self.cancel_connect_retry() - - try: - self.client = konnected.Client( - host=self.host, - port=str(self.port), - websession=aiohttp_client.async_get_clientsession(self.hass), - ) - self.status = await self.client.get_status() - self.api_version = KONN_API_VERSIONS.get( - self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL] - ) - _LOGGER.debug( - "Connected to new %s device", self.status.get("model", "Konnected") - ) - _LOGGER.debug(self.status) - - await self.async_update_initial_states() - # brief delay to allow processing of recent status req - await asyncio.sleep(0.1) - await self.async_sync_device_config() - - except self.client.ClientError as err: - _LOGGER.warning("Exception trying to connect to panel: %s", err) - - # retry in a bit, never more than ~3 min - self.connect_attempts += 1 - self.cancel_connect_retry = async_call_later( - self.hass, 2 ** min(self.connect_attempts, 5) * 5, self.async_connect - ) - return - - self.connect_attempts = 0 - self.connected = True - _LOGGER.debug( - ( - "Set up Konnected device %s. Open http://%s:%s in a " - "web browser to view device status" - ), - self.device_id, - self.host, - self.port, - ) - - device_registry = dr.async_get(self.hass) - device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))}, - identifiers={(DOMAIN, self.device_id)}, - manufacturer="Konnected.io", - name=self.config_entry.title, - model=self.config_entry.title, - sw_version=self.status.get("swVersion"), - ) - - async def update_switch(self, zone, state, momentary=None, times=None, pause=None): - """Update the state of a switchable output.""" - try: - if self.client: - if self.api_version == CONF_ZONE: - return await self.client.put_zone( - zone, - state, - momentary, - times, - pause, - ) - - # device endpoint uses pin number instead of zone - return await self.client.put_device( - ZONE_TO_PIN[zone], - state, - momentary, - times, - pause, - ) - - except self.client.ClientError as err: - _LOGGER.warning("Exception trying to update panel: %s", err) - - raise CannotConnect - - async def async_save_data(self): - """Save the device configuration to `hass.data`.""" - binary_sensors = {} - for entity in self.options.get(CONF_BINARY_SENSORS) or []: - zone = entity[CONF_ZONE] - - binary_sensors[zone] = { - CONF_TYPE: entity[CONF_TYPE], - CONF_NAME: entity.get( - CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}" - ), - CONF_INVERSE: entity.get(CONF_INVERSE), - ATTR_STATE: None, - } - _LOGGER.debug( - "Set up binary_sensor %s (initial state: %s)", - binary_sensors[zone].get("name"), - binary_sensors[zone].get(ATTR_STATE), - ) - - actuators = [] - for entity in self.options.get(CONF_SWITCHES) or []: - zone = entity[CONF_ZONE] - - act = { - CONF_ZONE: zone, - CONF_NAME: entity.get( - CONF_NAME, - f"Konnected {self.device_id[6:]} Actuator {zone}", - ), - ATTR_STATE: None, - CONF_ACTIVATION: entity[CONF_ACTIVATION], - CONF_MOMENTARY: entity.get(CONF_MOMENTARY), - CONF_PAUSE: entity.get(CONF_PAUSE), - CONF_REPEAT: entity.get(CONF_REPEAT), - } - actuators.append(act) - _LOGGER.debug("Set up switch %s", act) - - sensors = [] - for entity in self.options.get(CONF_SENSORS) or []: - zone = entity[CONF_ZONE] - - sensor = { - CONF_ZONE: zone, - CONF_NAME: entity.get( - CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}" - ), - CONF_TYPE: entity[CONF_TYPE], - CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL), - } - sensors.append(sensor) - _LOGGER.debug( - "Set up %s sensor %s (initial state: %s)", - sensor.get(CONF_TYPE), - sensor.get(CONF_NAME), - sensor.get(ATTR_STATE), - ) - - device_data = { - CONF_BINARY_SENSORS: binary_sensors, - CONF_SENSORS: sensors, - CONF_SWITCHES: actuators, - CONF_BLINK: self.options.get(CONF_BLINK), - CONF_DISCOVERY: self.options.get(CONF_DISCOVERY), - CONF_HOST: self.host, - CONF_PORT: self.port, - "panel": self, - } - - if CONF_DEVICES not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][CONF_DEVICES] = {} - - _LOGGER.debug( - "Storing data in hass.data[%s][%s][%s]: %s", - DOMAIN, - CONF_DEVICES, - self.device_id, - device_data, - ) - self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data - - @callback - def async_binary_sensor_configuration(self): - """Return the configuration map for syncing binary sensors.""" - return [ - self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS] - ] - - @callback - def async_actuator_configuration(self): - """Return the configuration map for syncing actuators.""" - return [ - self.format_zone( - data[CONF_ZONE], - {"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)}, - ) - for data in self.stored_configuration[CONF_SWITCHES] - ] - - @callback - def async_dht_sensor_configuration(self): - """Return the configuration map for syncing DHT sensors.""" - return [ - self.format_zone( - sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} - ) - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "dht" - ] - - @callback - def async_ds18b20_sensor_configuration(self): - """Return the configuration map for syncing DS18B20 sensors.""" - return [ - self.format_zone( - sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} - ) - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "ds18b20" - ] - - async def async_update_initial_states(self): - """Update the initial state of each sensor from status poll.""" - for sensor_data in self.status.get("sensors"): - sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get( - sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {} - ) - entity_id = sensor_config.get(ATTR_ENTITY_ID) - - state = bool(sensor_data.get(ATTR_STATE)) - if sensor_config.get(CONF_INVERSE): - state = not state - - async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state) - - @callback - def async_desired_settings_payload(self): - """Return a dict representing the desired device configuration.""" - # keeping self.hass.data check for backwards compatibility - # newly configured integrations store this in the config entry - desired_api_host = self.options.get(CONF_API_HOST) or ( - self.hass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hass) - ) - desired_api_endpoint = desired_api_host + ENDPOINT_ROOT - - return { - "sensors": self.async_binary_sensor_configuration(), - "actuators": self.async_actuator_configuration(), - "dht_sensors": self.async_dht_sensor_configuration(), - "ds18b20_sensors": self.async_ds18b20_sensor_configuration(), - "auth_token": self.config.get(CONF_ACCESS_TOKEN), - "endpoint": desired_api_endpoint, - "blink": self.options.get(CONF_BLINK, True), - "discovery": self.options.get(CONF_DISCOVERY, True), - } - - @callback - def async_current_settings_payload(self): - """Return a dict of configuration currently stored on the device.""" - settings = self.status["settings"] or {} - - return { - "sensors": [ - {self.api_version: s[self.api_version]} - for s in self.status.get("sensors") - ], - "actuators": self.status.get("actuators"), - "dht_sensors": self.status.get(CONF_DHT_SENSORS), - "ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS), - "auth_token": settings.get("token"), - "endpoint": settings.get("endpoint"), - "blink": settings.get(CONF_BLINK), - "discovery": settings.get(CONF_DISCOVERY), - } - - async def async_sync_device_config(self): - """Sync the new zone configuration to the Konnected device if needed.""" - _LOGGER.debug( - "Device %s settings payload: %s", - self.device_id, - self.async_desired_settings_payload(), - ) - if ( - self.async_desired_settings_payload() - != self.async_current_settings_payload() - ): - _LOGGER.debug("Pushing settings to device %s", self.device_id) - await self.client.put_settings(**self.async_desired_settings_payload()) - - -async def get_status(hass, host, port): - """Get the status of a Konnected Panel.""" - client = konnected.Client( - host, str(port), aiohttp_client.async_get_clientsession(hass) - ) - try: - return await client.get_status() - - except client.ClientError as err: - _LOGGER.error("Exception trying to get panel status: %s", err) - raise CannotConnect from err diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py deleted file mode 100644 index 155e99a7002..00000000000 --- a/homeassistant/components/konnected/sensor.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Support for DHT and DS18B20 sensors attached to a Konnected device.""" - -from __future__ import annotations - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICES, - CONF_NAME, - CONF_SENSORS, - CONF_TYPE, - CONF_ZONE, - PERCENTAGE, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, SIGNAL_DS18B20_NEW - -SENSOR_TYPES: dict[str, SensorEntityDescription] = { - "temperature": SensorEntityDescription( - key="temperature", - name="Temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - "humidity": SensorEntityDescription( - key="humidity", - name="Humidity", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, - ), -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up sensors attached to a Konnected device from a config entry.""" - data = hass.data[DOMAIN] - device_id = config_entry.data["id"] - - # Initialize all DHT sensors. - dht_sensors = [ - sensor - for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS] - if sensor[CONF_TYPE] == "dht" - ] - entities = [ - KonnectedSensor(device_id, data=sensor_config, description=description) - for sensor_config in dht_sensors - for description in SENSOR_TYPES.values() - ] - - async_add_entities(entities) - - @callback - def async_add_ds18b20(attrs): - """Add new KonnectedSensor representing a ds18b20 sensor.""" - sensor_config = next( - ( - s - for s in data[CONF_DEVICES][device_id][CONF_SENSORS] - if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE) - ), - None, - ) - - async_add_entities( - [ - KonnectedSensor( - device_id, - sensor_config, - SENSOR_TYPES["temperature"], - addr=attrs.get("addr"), - initial_state=attrs.get("temp"), - ) - ], - True, - ) - - # DS18B20 sensors entities are initialized when they report for the first - # time. Set up a listener for that signal from the Konnected component. - async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20) - - -class KonnectedSensor(SensorEntity): - """Represents a Konnected DHT Sensor.""" - - def __init__( - self, - device_id, - data, - description: SensorEntityDescription, - addr=None, - initial_state=None, - ) -> None: - """Initialize the entity for a single sensor_type.""" - self.entity_description = description - self._addr = addr - self._data = data - self._zone_num = self._data.get(CONF_ZONE) - self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" - - # set initial state if known at initialization - self._attr_native_value = initial_state - if initial_state: - self._attr_native_value = round(float(initial_state), 1) - - # set entity name if given - if name := self._data.get(CONF_NAME): - name += f" {description.name}" - self._attr_name = name - - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) - - async def async_added_to_hass(self) -> None: - """Store entity_id and register state change callback.""" - entity_id_key = self._addr or self.entity_description.key - self._data[entity_id_key] = self.entity_id - async_dispatcher_connect( - self.hass, f"konnected.{self.entity_id}.update", self.async_set_state - ) - - @callback - def async_set_state(self, state): - """Update the sensor's state.""" - if self.entity_description.key == "humidity": - self._attr_native_value = int(float(state)) - else: - self._attr_native_value = round(float(state), 1) - self.async_write_ha_state() diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index dc3a0de40e4..6f54fbea11f 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -1,115 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "not_konn_panel": "Not a recognized Konnected.io device", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "step": { - "confirm": { - "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings.", - "title": "Konnected device ready" - }, - "import_confirm": { - "description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.", - "title": "Import Konnected device" - }, - "user": { - "data": { - "host": "[%key:common::config_flow::data::ip%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "description": "Please enter the host information for your Konnected panel." - } - } - }, "issues": { - "deprecated_firmware": { - "description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant.", - "title": "Konnected firmware is deprecated" - } - }, - "options": { - "abort": { - "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" - }, - "error": { - "bad_host": "Invalid custom API host URL" - }, - "step": { - "options_binary": { - "data": { - "inverse": "Invert the open/close state", - "name": "[%key:common::config_flow::data::name%]", - "type": "Binary sensor type" - }, - "description": "{zone} options", - "title": "Configure binary sensor" - }, - "options_digital": { - "data": { - "name": "[%key:common::config_flow::data::name%]", - "poll_interval": "Poll interval (minutes)", - "type": "Sensor type" - }, - "description": "[%key:component::konnected::options::step::options_binary::description%]", - "title": "Configure digital sensor" - }, - "options_io": { - "data": { - "1": "Zone 1", - "2": "Zone 2", - "3": "Zone 3", - "4": "Zone 4", - "5": "Zone 5", - "6": "Zone 6", - "7": "Zone 7", - "out": "OUT" - }, - "description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.", - "title": "Configure I/O" - }, - "options_io_ext": { - "data": { - "8": "Zone 8", - "9": "Zone 9", - "10": "Zone 10", - "11": "Zone 11", - "12": "Zone 12", - "alarm1": "ALARM1", - "alarm2_out2": "OUT2/ALARM2", - "out1": "OUT1" - }, - "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", - "title": "Configure extended I/O" - }, - "options_misc": { - "data": { - "api_host": "Custom API host URL", - "blink": "Blink panel LED on when sending state change", - "discovery": "Respond to discovery requests on your network", - "override_api_host": "Override default Home Assistant API host URL" - }, - "description": "Please select the desired behavior for your panel", - "title": "Configure misc" - }, - "options_switch": { - "data": { - "activation": "Output when on", - "momentary": "Pulse duration (ms)", - "more_states": "Configure additional states for this zone", - "name": "[%key:common::config_flow::data::name%]", - "pause": "Pause between pulses (ms)", - "repeat": "Times to repeat (-1=infinite)" - }, - "description": "{zone} options: state {state}", - "title": "Configure switchable output" - } + "integration_removed": { + "description": "The Konnected.io (Legacy) integration relied on Konnected's deprecated firmware and has been removed from Home Assistant. Konnected recommends migrating to their ESPHome based firmware and the corresponding Home Assistant integration by following the [migration guide]({kb_page_url}).\n\nTo resolve this issue, migrate your Konnected device(s) to the ESPHome based firmware, then remove any `konnected:` YAML configuration from your `configuration.yaml` file, and remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Konnected.io (Legacy) integration entries]({entries}).", + "title": "The Konnected.io (Legacy) integration has been removed" } } } diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py deleted file mode 100644 index 54f74f0d461..00000000000 --- a/homeassistant/components/konnected/switch.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Support for wired switches attached to a Konnected device.""" - -import logging -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_STATE, - CONF_DEVICES, - CONF_NAME, - CONF_REPEAT, - CONF_SWITCHES, - CONF_ZONE, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import ( - CONF_ACTIVATION, - CONF_MOMENTARY, - CONF_PAUSE, - DOMAIN, - STATE_HIGH, - STATE_LOW, -) - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up switches attached to a Konnected device from a config entry.""" - data = hass.data[DOMAIN] - device_id = config_entry.data["id"] - switches = [ - KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data) - for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES] - ] - async_add_entities(switches) - - -class KonnectedSwitch(SwitchEntity): - """Representation of a Konnected switch.""" - - def __init__(self, device_id, zone_num, data): - """Initialize the Konnected switch.""" - self._data = data - self._device_id = device_id - self._zone_num = zone_num - self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH) - self._momentary = self._data.get(CONF_MOMENTARY) - self._pause = self._data.get(CONF_PAUSE) - self._repeat = self._data.get(CONF_REPEAT) - self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE)) - self._attr_name = self._data.get(CONF_NAME) - self._attr_unique_id = ( - f"{device_id}-{self._zone_num}-{self._momentary}-" - f"{self._pause}-{self._repeat}" - ) - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) - - @property - def panel(self): - """Return the Konnected HTTP client.""" - device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id] - return device_data.get("panel") - - @property - def available(self) -> bool: - """Return whether the panel is available.""" - return self.panel.available - - async def async_turn_on(self, **kwargs: Any) -> None: - """Send a command to turn on the switch.""" - resp = await self.panel.update_switch( - self._zone_num, - int(self._activation == STATE_HIGH), - self._momentary, - self._repeat, - self._pause, - ) - - if resp.get(ATTR_STATE) is not None: - self._set_state(True) - - if self._momentary and resp.get(ATTR_STATE) != -1: - # Immediately set the state back off for momentary switches - self._set_state(False) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Send a command to turn off the switch.""" - resp = await self.panel.update_switch( - self._zone_num, int(self._activation == STATE_LOW) - ) - - if resp.get(ATTR_STATE) is not None: - self._set_state(self._boolean_state(resp.get(ATTR_STATE))) - - def _boolean_state(self, int_state: int | None) -> bool | None: - if int_state == 0: - return self._activation == STATE_LOW - if int_state == 1: - return self._activation == STATE_HIGH - return None - - def _set_state(self, state): - self._attr_is_on = state - self.async_write_ha_state() - _LOGGER.debug( - "Setting status of %s actuator zone %s to %s", - self._device_id, - self.name, - state, - ) - - @callback - def async_set_state(self, state): - """Update the switch state.""" - self._set_state(state) - - async def async_added_to_hass(self) -> None: - """Store entity_id and register state change callback.""" - self._data["entity_id"] = self.entity_id - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"konnected.{self.entity_id}.update", self.async_set_state - ) - ) diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index c3a49359fc4..69fa91d09d9 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -1,7 +1,5 @@ """Code to handle the Plenticore API.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Mapping from datetime import datetime, timedelta diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index a583770379c..0d9c5b87e2f 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Kostal Plenticore.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1324cf5cd07..e38e995d7e5 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -1,7 +1,5 @@ """Code to handle the Plenticore API.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 05da93f30ac..3609c21e069 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -1,7 +1,5 @@ """Platform for Kostal Plenticore numbers.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 86ffb63966d..c8056e99709 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -1,7 +1,5 @@ """Platform for Kostal Plenticore select widgets.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 317e6e03cef..3de7e86551c 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -1,7 +1,5 @@ """Platform for Kostal Plenticore sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -56,7 +54,6 @@ SENSOR_PROCESS_DATA = [ name="Solar Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), @@ -66,7 +63,6 @@ SENSOR_PROCESS_DATA = [ name="Grid Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), @@ -121,7 +117,6 @@ SENSOR_PROCESS_DATA = [ name="AC Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), @@ -572,7 +567,6 @@ SENSOR_PROCESS_DATA = [ name="Energy Yield Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - entity_registry_enabled_default=True, state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), @@ -762,7 +756,6 @@ SENSOR_PROCESS_DATA = [ name="Sum power of all PV DC inputs", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 85a5cdf8fe7..5a5759bc66a 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -1,7 +1,5 @@ """Platform for Kostal Plenticore switches.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -150,7 +148,8 @@ async def async_setup_entry( ) else: _LOGGER.debug( - "Skipping shadow management for DC string %d, not supported (Feature: %d)", + "Skipping shadow management for DC string %d," + " not supported (Feature: %d)", dc_string + 1, dc_string_feature, ) @@ -247,11 +246,13 @@ class PlenticoreShadowMgmtSwitch( ): """Representation of a Plenticore Switch for shadow management. - The shadow management switch can be controlled for each DC string separately. The DC string is - coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc. + The shadow management switch can be controlled for each + DC string separately. The DC string is coded as bit in a + single settings value, bit 0 for DC string 1, bit 1 for + DC string 2, etc. - Not all DC strings are available for shadown management, for example if one of them is used - for a battery. + Not all DC strings are available for shadow management, + for example if one of them is used for a battery. """ _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 065b647a971..c6e44f0b474 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -1,40 +1,37 @@ """The kraken integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DISPATCH_CONFIG_UPDATED, DOMAIN -from .coordinator import KrakenData +from .const import DISPATCH_CONFIG_UPDATED +from .coordinator import KrakenConfigEntry, KrakenData PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: KrakenConfigEntry) -> bool: """Set up kraken from a config entry.""" kraken_data = KrakenData(hass, entry) await kraken_data.async_setup() - hass.data[DOMAIN] = kraken_data + entry.runtime_data = kraken_data entry.async_on_unload(entry.add_update_listener(async_options_updated)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: KrakenConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_options_updated( + hass: HomeAssistant, config_entry: KrakenConfigEntry +) -> None: """Triggered by config entry options updates.""" - hass.data[DOMAIN].set_update_interval(config_entry.options[CONF_SCAN_INTERVAL]) + config_entry.runtime_data.set_update_interval( + config_entry.options[CONF_SCAN_INTERVAL] + ) async_dispatcher_send(hass, DISPATCH_CONFIG_UPDATED, hass, config_entry) diff --git a/homeassistant/components/kraken/config_flow.py b/homeassistant/components/kraken/config_flow.py index 54a817f0a50..42abd19381b 100644 --- a/homeassistant/components/kraken/config_flow.py +++ b/homeassistant/components/kraken/config_flow.py @@ -1,24 +1,18 @@ """Config flow for kraken integration.""" -from __future__ import annotations - from typing import Any import krakenex from pykrakenapi.pykrakenapi import KrakenAPI import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from .const import CONF_TRACKED_ASSET_PAIRS, DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import KrakenConfigEntry from .utils import get_tradable_asset_pairs @@ -30,7 +24,7 @@ class KrakenConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: KrakenConfigEntry, ) -> KrakenOptionsFlowHandler: """Get the options flow for this handler.""" return KrakenOptionsFlowHandler() @@ -66,7 +60,8 @@ class KrakenOptionsFlowHandler(OptionsFlow): ) tradable_asset_pairs_for_multi_select = {v: v for v in tradable_asset_pairs} - # Ensure that a previously selected tracked asset pair is still available in multiselect + # Ensure that a previously selected tracked asset pair + # is still available in multiselect # even if it is not tradable anymore tracked_asset_pairs = self.config_entry.options.get( CONF_TRACKED_ASSET_PAIRS, [] @@ -79,6 +74,8 @@ class KrakenOptionsFlowHandler(OptionsFlow): ) options = { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 9fbad46dd4b..4c0ce3f9e71 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -1,7 +1,5 @@ """Constants for the kraken integration.""" -from __future__ import annotations - from typing import TypedDict diff --git a/homeassistant/components/kraken/coordinator.py b/homeassistant/components/kraken/coordinator.py index c222e58ba15..be6743ee7a6 100644 --- a/homeassistant/components/kraken/coordinator.py +++ b/homeassistant/components/kraken/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the kraken integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -28,10 +26,13 @@ CALL_RATE_LIMIT_SLEEP = 1 _LOGGER = logging.getLogger(__name__) +type KrakenConfigEntry = ConfigEntry[KrakenData] + + class KrakenData: """Define an object to hold kraken data.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: KrakenConfigEntry) -> None: """Initialize.""" self._hass = hass self._config_entry = config_entry diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index f301a54ee07..39ab7b4f369 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -1,7 +1,5 @@ """The kraken integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -11,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -28,7 +25,7 @@ from .const import ( DOMAIN, KrakenResponse, ) -from .coordinator import KrakenData +from .coordinator import KrakenConfigEntry, KrakenData _LOGGER = logging.getLogger(__name__) @@ -138,7 +135,7 @@ SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KrakenConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kraken entities from a config_entry.""" @@ -149,7 +146,7 @@ async def async_setup_entry( entities.extend( [ KrakenSensor( - hass.data[DOMAIN], + config_entry.runtime_data, tracked_asset_pair, description, ) @@ -161,7 +158,9 @@ async def async_setup_entry( _async_add_kraken_sensors(config_entry.options[CONF_TRACKED_ASSET_PAIRS]) @callback - def async_update_sensors(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def async_update_sensors( + hass: HomeAssistant, config_entry: KrakenConfigEntry + ) -> None: """Add or remove sensors for configured tracked asset pairs.""" dev_reg = dr.async_get(hass) @@ -291,4 +290,5 @@ class KrakenSensor( def create_device_name(tracked_asset_pair: str) -> str: """Create the device name for a given tracked asset pair.""" - return f"{tracked_asset_pair.split('/', maxsplit=1)[0]} {tracked_asset_pair.split('/')[1]}" + parts = tracked_asset_pair.split("/", maxsplit=2) + return f"{parts[0]} {parts[1]}" diff --git a/homeassistant/components/kraken/utils.py b/homeassistant/components/kraken/utils.py index ec89d1b1584..4713fdedaa7 100644 --- a/homeassistant/components/kraken/utils.py +++ b/homeassistant/components/kraken/utils.py @@ -1,7 +1,5 @@ """Utility functions for the kraken integration.""" -from __future__ import annotations - from pykrakenapi.pykrakenapi import KrakenAPI diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index d6a45ed1ebe..7a3335c6c75 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -1,7 +1,5 @@ """Kuler Sky light platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index 0074c3a4344..5eff9acf3fd 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -1,7 +1,5 @@ """Support for KWB Easyfire.""" -from __future__ import annotations - from pykwb import kwb import voluptuous as vol diff --git a/homeassistant/components/labs/__init__.py b/homeassistant/components/labs/__init__.py index 00a9e9c241d..3c7a38c9aa5 100644 --- a/homeassistant/components/labs/__init__.py +++ b/homeassistant/components/labs/__init__.py @@ -5,8 +5,6 @@ Integrations can register lab preview features in their manifest.json which will in the Home Assistant Labs UI for users to enable or disable. """ -from __future__ import annotations - import logging from homeassistant.const import EVENT_LABS_UPDATED diff --git a/homeassistant/components/labs/const.py b/homeassistant/components/labs/const.py index 81eada9cf4c..ed35c79e1b7 100644 --- a/homeassistant/components/labs/const.py +++ b/homeassistant/components/labs/const.py @@ -1,7 +1,5 @@ """Constants for the Home Assistant Labs integration.""" -from __future__ import annotations - from homeassistant.util.hass_dict import HassKey from .models import LabsData diff --git a/homeassistant/components/labs/helpers.py b/homeassistant/components/labs/helpers.py index 2045487ec84..c42b9dcf5ed 100644 --- a/homeassistant/components/labs/helpers.py +++ b/homeassistant/components/labs/helpers.py @@ -1,7 +1,5 @@ """Helper functions for the Home Assistant Labs integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any diff --git a/homeassistant/components/labs/models.py b/homeassistant/components/labs/models.py index b3156df4281..82cdfefa104 100644 --- a/homeassistant/components/labs/models.py +++ b/homeassistant/components/labs/models.py @@ -1,7 +1,5 @@ """Data models for the Home Assistant Labs integration.""" -from __future__ import annotations - from dataclasses import dataclass, field from typing import TYPE_CHECKING, Self, TypedDict diff --git a/homeassistant/components/labs/websocket_api.py b/homeassistant/components/labs/websocket_api.py index 595d8e1d6b0..edf74d2ef2b 100644 --- a/homeassistant/components/labs/websocket_api.py +++ b/homeassistant/components/labs/websocket_api.py @@ -1,7 +1,5 @@ """Websocket API for the Home Assistant Labs integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index a5c3585eac1..09af8758277 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -1,7 +1,5 @@ """Support for LaCrosse sensor components.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import Any diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index 6cb5e93acfe..52c3cb80377 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -1,7 +1,5 @@ """The LaCrosse View integration.""" -from __future__ import annotations - import logging from lacrosse_view import LaCrosse, LoginError diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index 75a5c737034..a8d41e07118 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LaCrosse View integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index 8750d1867e6..297a89ad757 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,4 +1,7 @@ """Constants for the LaCrosse View integration.""" +from datetime import timedelta + DOMAIN = "lacrosse_view" SCAN_INTERVAL = 60 +STALE_DATA_THRESHOLD = timedelta(seconds=3600) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 3d5e3bf4ce0..a17926a250e 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for LaCrosse View.""" -from __future__ import annotations - from datetime import timedelta import logging from time import time @@ -13,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL, STALE_DATA_THRESHOLD _LOGGER = logging.getLogger(__name__) @@ -56,7 +54,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): """Get the data for LaCrosse View.""" now = int(time()) - if self.last_update < now - 59 * 60: # Get new token once in a hour + if now - self.last_update > 59 * 60: _LOGGER.debug("Refreshing token") self.last_update = now try: @@ -68,17 +66,18 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): _LOGGER.debug("Getting devices") try: self.devices = await self.api.get_devices( - location=Location(id=self.id, name=self.name), + location=Location(id=self.id, name=self.name) ) except HTTPError as error: raise UpdateFailed from error - # Fetch last hour of data for sensor in self.devices: try: - data = await self.api.get_sensor_status( + sensor.data = await self.api.get_sensor_status_filtered( sensor=sensor, tz=self.hass.config.time_zone, + stale_threshold=STALE_DATA_THRESHOLD, + previous_data=sensor.data, ) except HTTPError as error: error_data = error.args[1] if len(error.args) > 1 else None @@ -86,34 +85,14 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): isinstance(error_data, dict) and error_data.get("error") == "no_readings" ): - sensor.data = None _LOGGER.debug("No readings for %s", sensor.name) + sensor.data = None continue raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" + translation_domain=DOMAIN, + translation_key="update_error", ) from error - _LOGGER.debug("Got data: %s", data) - - if data_error := data.get("error"): - if data_error == "no_readings": - sensor.data = None - _LOGGER.debug("No readings for %s", sensor.name) - continue - _LOGGER.debug("Error: %s", data_error) - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" - ) - - current_data = data.get("data", {}).get("current") - if current_data is None: - sensor.data = None - _LOGGER.debug("No current data payload for %s", sensor.name) - continue - - sensor.data = current_data - - # Verify that we have permission to read the sensors for sensor in self.devices: if not sensor.permissions.get("read", False): raise ConfigEntryAuthFailed( diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py index 479533007c8..57e98eb7378 100644 --- a/homeassistant/components/lacrosse_view/diagnostics.py +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for LaCrosse View.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index fee97b9ed79..8290fd372ce 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], - "requirements": ["lacrosse-view==1.1.1"] + "requirements": ["lacrosse-view==1.1.2"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index d0221e22667..70656e9aced 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -1,7 +1,5 @@ """Sensor component for LaCrosse View.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, replace import logging @@ -146,7 +144,8 @@ SENSOR_DESCRIPTIONS = { suggested_display_precision=2, ), } -# map of API returned unit of measurement strings to their corresponding unit of measurement +# map of API returned unit of measurement strings to their +# corresponding unit of measurement UNIT_OF_MEASUREMENT_MAP = { "degrees_celsius": UnitOfTemperature.CELSIUS, "degrees_fahrenheit": UnitOfTemperature.FAHRENHEIT, @@ -185,7 +184,8 @@ async def async_setup_entry( _LOGGER.warning(message) continue - # if the API returns a different unit of measurement from the description, update it + # if the API returns a different unit of measurement + # from the description, update it if sensor.data is not None and sensor.data.get(field) is not None: native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( sensor.data[field].get("unit") diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 6ef4d397572..10a8db33b12 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -116,7 +116,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - entry.async_on_unload(bluetooth_client.disconnect) else: _LOGGER.info( - "Bluetooth device not found during lamarzocco setup, continuing with cloud only" + "Bluetooth device not found during" + " lamarzocco setup, continuing with" + " cloud only" ) async def _get_thing_settings() -> None: @@ -221,7 +223,9 @@ async def async_migrate_entry( if entry.version in (1, 2): _LOGGER.error( - "Migration from version 1 or 2 is no longer supported, please remove and re-add the integration" + "Migration from version 1 or 2 is no longer" + " supported, please remove and re-add" + " the integration" ) return False diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index e4673372d0a..a1e8a835440 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -153,5 +153,8 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): ), end=end_date, summary=f"Machine {self.coordinator.config_entry.title} on", - description="Machine is scheduled to turn on at the start time and off at the end time", + description=( + "Machine is scheduled to turn on at" + " the start time and off at the end time" + ), ) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 9e953d93044..f5469fcce27 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -1,7 +1,5 @@ """Config flow for La Marzocco integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 084d9107151..1aeb44c4908 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for La Marzocco API.""" -from __future__ import annotations - from abc import abstractmethod from asyncio import Task from collections.abc import Callable, Coroutine @@ -145,7 +143,8 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): # ensure token stays valid; does nothing if token is still valid await self.device.ensure_token_valid() - # Only skip websocket reconnection if it's currently connected and the task is still running + # Only skip websocket reconnection if it's currently + # connected and the task is still running if self.device.websocket.connected and not self.websocket_terminated: return diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 7743523e01d..6f86fc3a465 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for La Marzocco.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 1ea15e0072f..07ffb24400f 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.2.4"] + "requirements": ["pylamarzocco==2.2.5"] } diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index e1d87eeb14e..0ad5beeb62d 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -131,7 +131,9 @@ async def async_setup_entry( entities.extend( LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry) - for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules + for wake_up_sleep_entry in ( + coordinator.device.schedule.smart_wake_up_sleep.schedules + ) ) entities.append( diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 7b141665a4f..e40eb301b43 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -1,7 +1,5 @@ """Support for LaMetric buttons.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 8a7bd098d75..04889836ed7 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the LaMetric integration.""" -from __future__ import annotations - from collections.abc import Mapping from ipaddress import ip_address import logging diff --git a/homeassistant/components/lametric/coordinator.py b/homeassistant/components/lametric/coordinator.py index 54301506366..c7e68041a8e 100644 --- a/homeassistant/components/lametric/coordinator.py +++ b/homeassistant/components/lametric/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the LaMatric integration.""" -from __future__ import annotations - from demetriek import Device, LaMetricAuthenticationError, LaMetricDevice, LaMetricError from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py index 9df72ee40fa..1b1ed7c63a7 100644 --- a/homeassistant/components/lametric/diagnostics.py +++ b/homeassistant/components/lametric/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for LaMetric.""" -from __future__ import annotations - import json from typing import Any diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index f0c0d14e0e4..1abfcba40f7 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -1,7 +1,5 @@ """Base entity for the LaMetric integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, CONNECTION_NETWORK_MAC, diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 55b5ef1bb8b..c96d5a1c523 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -1,7 +1,5 @@ """Helpers for LaMetric.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index db453d2fc20..d23de94f250 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -1,7 +1,5 @@ """Support for LaMetric notifications.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from demetriek import ( diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index acd196d4b34..3f2ff70b899 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -1,7 +1,5 @@ """Support for LaMetric numbers.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 993ec7c909a..a0e5c634b54 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -1,7 +1,5 @@ """Support for LaMetric selects.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 309c8093204..325e8d4a190 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -1,7 +1,5 @@ """Support for LaMetric sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/lametric/services.py b/homeassistant/components/lametric/services.py index a3cd2b9aa7e..8a59dade157 100644 --- a/homeassistant/components/lametric/services.py +++ b/homeassistant/components/lametric/services.py @@ -1,7 +1,5 @@ """Support for LaMetric time services.""" -from __future__ import annotations - from demetriek import ( AlarmSound, Chart, diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 8e4fb611d3e..fb05fbe2c02 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -1,7 +1,5 @@ """Support for LaMetric switches.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 669de160811..149aca6bdbe 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -1,7 +1,5 @@ """The Landis+Gyr Heat Meter integration.""" -from __future__ import annotations - import logging from typing import Any @@ -47,7 +45,8 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - # Removing domain name and config entry id from entity unique id's, replacing it with device number + # Removing domain name and config entry id from entity + # unique id's, replacing it with device number if config_entry.version == 1: hass.config_entries.async_update_entry(config_entry, version=2) diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index f7288b8a0cd..4c044c1492a 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -1,13 +1,10 @@ """Config flow for Landis+Gyr Heat Meter integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any -import serial -from serial.tools import list_ports +import serialx import ultraheat_api import voluptuous as vol @@ -45,9 +42,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input[CONF_DEVICE] == CONF_MANUAL_PATH: return await self.async_step_setup_serial_manual_path() - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, user_input[CONF_DEVICE] - ) + dev_path = user_input[CONF_DEVICE] _LOGGER.debug("Using this path : %s", dev_path) try: @@ -108,7 +103,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (TimeoutError, serial.SerialException) as err: + except (OSError, TimeoutError, serialx.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err @@ -118,23 +113,19 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN): async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = await hass.async_add_executor_job(list_ports.comports) + ports = await usb.async_scan_serial_ports(hass) port_descriptions = {} for port in ports: - # this prevents an issue with usb_device_from_port - # not working for ports without vid on RPi - if port.vid: - usb_device = usb.usb_device_from_port(port) - dev_path = usb.get_serial_by_id(usb_device.device) + if isinstance(port, usb.USBDevice): human_name = usb.human_readable_device_name( - dev_path, - usb_device.serial_number, - usb_device.manufacturer, - usb_device.description, - usb_device.vid, - usb_device.pid, + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name return port_descriptions diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index bda19fd6fc3..286712d826a 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -3,7 +3,7 @@ import asyncio import logging -import serial +import serialx from ultraheat_api.response import HeatMeterResponse from ultraheat_api.service import HeatMeterService @@ -44,5 +44,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.SerialException) as err: + except (OSError, TimeoutError, serialx.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 7555099aa52..1161134c5de 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.7"] + "requirements": ["ultraheat-api==0.6.0"] } diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 9e9ef889500..c90e8986f83 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index c24148ab699..0c6c85bac60 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -1,7 +1,5 @@ """Lannouncer platform for notify component.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index 90bee0cf4e7..1f75f548b39 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -1,7 +1,5 @@ """The lastfm component.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 47c5b0e217e..1eab5308aaf 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LastFm.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lastfm/coordinator.py b/homeassistant/components/lastfm/coordinator.py index ca3c7eda508..5506cf86d3b 100644 --- a/homeassistant/components/lastfm/coordinator.py +++ b/homeassistant/components/lastfm/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the LastFM integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 0f4d22ba503..196a053204d 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -1,7 +1,5 @@ """Sensor for Last.fm account status.""" -from __future__ import annotations - import hashlib from typing import Any diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 9b29af194e7..8300c3c1276 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -1,32 +1,30 @@ """The launch_library component.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry, LaunchLibraryCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: LaunchLibraryConfigEntry +) -> bool: """Set up this integration using UI.""" coordinator = LaunchLibraryCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: LaunchLibraryConfigEntry +) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/launch_library/config_flow.py b/homeassistant/components/launch_library/config_flow.py index 37b80fbff8a..29c49f35c04 100644 --- a/homeassistant/components/launch_library/config_flow.py +++ b/homeassistant/components/launch_library/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure launch library component.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/launch_library/coordinator.py b/homeassistant/components/launch_library/coordinator.py index b88bc105630..234608d0eb6 100644 --- a/homeassistant/components/launch_library/coordinator.py +++ b/homeassistant/components/launch_library/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the launch_library integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TypedDict @@ -16,6 +14,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN +type LaunchLibraryConfigEntry = ConfigEntry[LaunchLibraryCoordinator] + + _LOGGER = logging.getLogger(__name__) @@ -29,12 +30,12 @@ class LaunchLibraryData(TypedDict): class LaunchLibraryCoordinator(DataUpdateCoordinator[LaunchLibraryData]): """Class to manage fetching Launch Library data.""" - config_entry: ConfigEntry + config_entry: LaunchLibraryConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/launch_library/diagnostics.py b/homeassistant/components/launch_library/diagnostics.py index d96d5fed7f5..2f42605acf1 100644 --- a/homeassistant/components/launch_library/diagnostics.py +++ b/homeassistant/components/launch_library/diagnostics.py @@ -1,25 +1,21 @@ """Diagnostics support for Launch Library.""" -from __future__ import annotations - from typing import Any from pylaunches.types import Event, Launch -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data if coordinator.data is None: return {} diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index e844744c834..455ccee555e 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -1,7 +1,5 @@ """Support for Launch Library sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -14,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import parse_datetime from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry, LaunchLibraryCoordinator DEFAULT_NEXT_LAUNCH_NAME = "Next launch" @@ -118,12 +115,12 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME) - coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data async_add_entities( LaunchLibrarySensor( diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index b45ca25bd2e..d074ff65e2b 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -1,7 +1,5 @@ """The laundrify integration.""" -from __future__ import annotations - import logging from laundrify_aio import LaundrifyAPI diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 0cfbaae6c20..5ba4a4982b4 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - import logging from laundrify_aio import LaundrifyDevice diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index 22988af3241..5a4c2b8d63f 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -1,7 +1,5 @@ """Config flow for laundrify integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index f8c3e0cd67d..5bf8e393bdb 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -1,7 +1,5 @@ """The lawn mower integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import final diff --git a/homeassistant/components/lawn_mower/conditions.yaml b/homeassistant/components/lawn_mower/conditions.yaml index e9f29941bc2..5fb1de71345 100644 --- a/homeassistant/components/lawn_mower/conditions.yaml +++ b/homeassistant/components/lawn_mower/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_docked: *condition_common is_encountering_an_error: *condition_common diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index 973d046979a..da56ee71cc8 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_docked": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is docked" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is encountering an error" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is mowing" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is paused" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is returning" @@ -62,21 +79,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "dock": { "description": "Returns a lawn mower to its dock.", @@ -98,6 +100,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower returned to dock" @@ -107,6 +112,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower encountered an error" @@ -116,6 +124,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower paused mowing" @@ -125,6 +136,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower started mowing" @@ -134,6 +148,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower started returning to dock" diff --git a/homeassistant/components/lawn_mower/triggers.yaml b/homeassistant/components/lawn_mower/triggers.yaml index bc3cb321cf8..f6a1d3bf6fd 100644 --- a/homeassistant/components/lawn_mower/triggers.yaml +++ b/homeassistant/components/lawn_mower/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: docked: *trigger_common errored: *trigger_common diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 1e08f36cf72..cc9e1d96763 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -1,7 +1,5 @@ """Support for LCN devices.""" -from __future__ import annotations - from functools import partial import logging from typing import cast @@ -172,7 +170,8 @@ async def async_migrate_entry( new_data[CONF_ENTITIES] = new_entities_data if config_entry.version < 3: - # update to 3.1 (remove resource parameter, add climate target lock value parameter) + # update to 3.1 (remove resource parameter, + # add climate target lock value parameter) for entity in new_data[CONF_ENTITIES]: entity.pop(CONF_RESOURCE, None) @@ -247,7 +246,7 @@ def async_host_event_received( ): _LOGGER.info('The connection to host "%s" has been lost', config_entry.title) hass.async_create_task(reload_config_entry()) - elif event == LcnEvent.BUS_DISCONNECTED: + elif event is LcnEvent.BUS_DISCONNECTED: _LOGGER.info( 'The connection to the LCN bus via host "%s" has been disconnected', config_entry.title, @@ -297,7 +296,7 @@ def _async_fire_access_control_event( if device is not None: event_data.update({CONF_DEVICE_ID: device.id}) - if inp.periphery == pypck.lcn_defs.AccessControlPeriphery.TRANSMITTER: + if inp.periphery is pypck.lcn_defs.AccessControlPeriphery.TRANSMITTER: event_data.update( { "level": inp.level, @@ -318,7 +317,7 @@ def _async_fire_send_keys_event( ) -> None: """Fire send_keys event.""" for table, action in enumerate(inp.actions): - if action == pypck.lcn_defs.SendKeyCommand.DONTSEND: + if action is pypck.lcn_defs.SendKeyCommand.DONTSEND: continue for key, selected in enumerate(inp.keys): diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index aa633adf100..f9a24948143 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -113,8 +113,10 @@ class LcnClimate(LcnEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - # Config schema only allows for: UnitOfTemperature.CELSIUS and UnitOfTemperature.FAHRENHEIT - if self.unit == pypck.lcn_defs.VarUnit.FAHRENHEIT: + # Config schema only allows for: + # UnitOfTemperature.CELSIUS and + # UnitOfTemperature.FAHRENHEIT + if self.unit is pypck.lcn_defs.VarUnit.FAHRENHEIT: return UnitOfTemperature.FAHRENHEIT return UnitOfTemperature.CELSIUS @@ -186,11 +188,11 @@ class LcnClimate(LcnEntity, ClimateEntity): if not isinstance(input_obj, pypck.inputs.ModStatusVar): return self._attr_available = True - if input_obj.get_var() == self.variable: + if input_obj.get_var() is self.variable: self._attr_current_temperature = float( input_obj.get_value().to_var_unit(self.unit) ) - elif input_obj.get_var() == self.setpoint: + elif input_obj.get_var() is self.setpoint: self._is_on = not input_obj.get_value().is_locked_regulator() if self._is_on: self._attr_target_temperature = float( diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index d4f211ad8ef..894eda67c0e 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the LCN integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index ea2c1e6d82b..b0af78a901a 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -191,7 +191,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): ) ) - if self.positioning_mode != pypck.lcn_defs.MotorPositioningMode.NONE: + if self.positioning_mode is not pypck.lcn_defs.MotorPositioningMode.NONE: self._attr_supported_features |= CoverEntityFeature.SET_POSITION self.motor = pypck.lcn_defs.MotorPort[config[CONF_DOMAIN_DATA][CONF_MOTOR]] @@ -267,7 +267,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): | None, ] ] = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)] - if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4: + if self.positioning_mode is pypck.lcn_defs.MotorPositioningMode.BS4: coros.append( self.device_connection.request_status_motor_position( self.motor, self.positioning_mode, SCAN_INTERVAL.seconds @@ -282,7 +282,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): self._attr_is_opening = input_obj.is_opening(self.motor.value) self._attr_is_closing = input_obj.is_closing(self.motor.value) - if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.NONE: + if self.positioning_mode is pypck.lcn_defs.MotorPositioningMode.NONE: self._attr_is_closed = input_obj.is_assumed_closed(self.motor.value) self.async_write_ha_state() elif ( diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index 42b5506110f..2421fa0b9f5 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for LCN.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index af756ddf27b..856bd1379b6 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -1,7 +1,5 @@ """Helpers for LCN component.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Iterable from copy import deepcopy @@ -204,7 +202,7 @@ def register_lcn_host_device(hass: HomeAssistant, config_entry: LcnConfigEntry) def register_lcn_address_devices( hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: - """Register LCN modules and groups defined in config_entry as devices in device registry. + """Register LCN modules and groups as devices. The name of all given device_connections is collected and the devices are updated. diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 5508f5571d0..0c4a9a4af6a 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "loggers": ["pypck"], "quality_scale": "silver", - "requirements": ["pypck==0.9.11", "lcn-frontend==0.2.7"] + "requirements": ["pypck==0.9.11", "lcn-frontend==0.2.9"] } diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 6b5c8bbfead..ce8a65647c9 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -144,7 +144,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): """Set sensor value when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusVar) - or input_obj.get_var() != self.variable + or input_obj.get_var() is not self.variable ): return self._attr_available = True diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 4672e244649..e56f83c86df 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -330,7 +330,7 @@ class SendKeys(LcnServiceCall): if (delay_time := service.data[CONF_TIME]) != 0: hit = pypck.lcn_defs.SendKeyCommand.HIT - if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit: + if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] is not hit: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_send_keys_action", diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 2a71080c643..ac53c9004c6 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -200,7 +200,7 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity): """Set switch state when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusVar) - or input_obj.get_var() != self.setpoint_variable + or input_obj.get_var() is not self.setpoint_variable ): return self._attr_available = True diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 76c800cd5ea..0559b93ed1e 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -1,7 +1,5 @@ """LCN Websocket API.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from functools import wraps from typing import Any, Final @@ -11,6 +9,7 @@ from pypck.device import DeviceConnection import voluptuous as vol from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.frontend import async_panel_exists from homeassistant.components.http import StaticPathConfig from homeassistant.components.websocket_api import ( ActiveConnection, @@ -76,7 +75,7 @@ async def register_panel_and_ws_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_add_entity) websocket_api.async_register_command(hass, websocket_delete_entity) - if DOMAIN not in hass.data.get("frontend_panels", {}): + if not async_panel_exists(hass, DOMAIN): await hass.http.async_register_static_paths( [ StaticPathConfig( diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 1a9f3cc57e6..0bc5374c7ec 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -10,11 +10,13 @@ from bleak_retry_connector import ( from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN from .coordinator import LD2410BLECoordinator from .models import LD2410BLEConfigEntry, LD2410BLEData @@ -34,7 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> ) or await get_device(address) if not ble_device: raise ConfigEntryNotReady( - f"Could not find LD2410B device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) ld2410_ble = LD2410BLE(ble_device) diff --git a/homeassistant/components/ld2410_ble/config_flow.py b/homeassistant/components/ld2410_ble/config_flow.py index 8211be44d7a..0759aa33492 100644 --- a/homeassistant/components/ld2410_ble/config_flow.py +++ b/homeassistant/components/ld2410_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LD2410BLE integration.""" -from __future__ import annotations - import logging from typing import Any @@ -9,6 +7,7 @@ from bluetooth_data_tools import human_readable_name from ld2410_ble import BLEAK_EXCEPTIONS, LD2410BLE import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -79,6 +78,7 @@ class Ld2410BleConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( @@ -99,7 +99,9 @@ class Ld2410BleConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_ADDRESS): vol.In( { - service_info.address: f"{service_info.name} ({service_info.address})" + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) for service_info in self._discovered_devices.values() } ), diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index f3d2f544faf..25bd5b10ca7 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,7 +1,5 @@ """Data coordinator for receiving LD2410B updates.""" -from __future__ import annotations - from datetime import datetime import logging import time diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index ee41418c76f..806d8edecb3 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.4", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.29.18", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/ld2410_ble/models.py b/homeassistant/components/ld2410_ble/models.py index 46dd226e303..4cb3fe91020 100644 --- a/homeassistant/components/ld2410_ble/models.py +++ b/homeassistant/components/ld2410_ble/models.py @@ -1,7 +1,5 @@ """The ld2410 ble integration models.""" -from __future__ import annotations - from dataclasses import dataclass from ld2410_ble import LD2410BLE diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index 87e173e4d15..8dd0c5f0398 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -23,7 +23,6 @@ MOVING_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( translation_key="moving_target_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, - entity_registry_visible_default=True, native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) @@ -33,7 +32,6 @@ STATIC_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( translation_key="static_target_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, - entity_registry_visible_default=True, native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) @@ -43,7 +41,6 @@ DETECTION_DISTANCE_DESCRIPTION = SensorEntityDescription( translation_key="detection_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, - entity_registry_visible_default=True, native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) @@ -51,9 +48,7 @@ DETECTION_DISTANCE_DESCRIPTION = SensorEntityDescription( MOVING_TARGET_ENERGY_DESCRIPTION = SensorEntityDescription( key="moving_target_energy", translation_key="moving_target_energy", - device_class=None, entity_registry_enabled_default=False, - entity_registry_visible_default=True, native_unit_of_measurement="Target Energy", state_class=SensorStateClass.MEASUREMENT, ) @@ -61,9 +56,7 @@ MOVING_TARGET_ENERGY_DESCRIPTION = SensorEntityDescription( STATIC_TARGET_ENERGY_DESCRIPTION = SensorEntityDescription( key="static_target_energy", translation_key="static_target_energy", - device_class=None, entity_registry_enabled_default=False, - entity_registry_visible_default=True, native_unit_of_measurement="Target Energy", state_class=SensorStateClass.MEASUREMENT, ) diff --git a/homeassistant/components/ld2410_ble/strings.json b/homeassistant/components/ld2410_ble/strings.json index 769cbb6a652..ad6b07b7581 100644 --- a/homeassistant/components/ld2410_ble/strings.json +++ b/homeassistant/components/ld2410_ble/strings.json @@ -97,5 +97,10 @@ "name": "Static target energy" } } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find LD2410B device with address {address}: {reason}" + } } } diff --git a/homeassistant/components/leaone/__init__.py b/homeassistant/components/leaone/__init__.py index 79ac349c69d..3d59aca202d 100644 --- a/homeassistant/components/leaone/__init__.py +++ b/homeassistant/components/leaone/__init__.py @@ -1,7 +1,5 @@ """The Leaone integration.""" -from __future__ import annotations - import logging from leaone_ble import LeaoneBluetoothDeviceData diff --git a/homeassistant/components/leaone/config_flow.py b/homeassistant/components/leaone/config_flow.py index 5e139e594b2..bf9cf8ee450 100644 --- a/homeassistant/components/leaone/config_flow.py +++ b/homeassistant/components/leaone/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Leaone integration.""" -from __future__ import annotations - from typing import Any from leaone_ble import LeaoneBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/leaone/device.py b/homeassistant/components/leaone/device.py index 0b95783dfd7..67151325960 100644 --- a/homeassistant/components/leaone/device.py +++ b/homeassistant/components/leaone/device.py @@ -1,7 +1,5 @@ """Support for Leaone devices.""" -from __future__ import annotations - from leaone_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py index db9264b7b89..5d7eda3db4a 100644 --- a/homeassistant/components/leaone/sensor.py +++ b/homeassistant/components/leaone/sensor.py @@ -1,7 +1,5 @@ """Support for Leaone sensors.""" -from __future__ import annotations - from leaone_ble import DeviceClass as LeaoneSensorDeviceClass, SensorUpdate, Units from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 82c67159a7b..a6f8bf56efd 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -1,18 +1,17 @@ """The LED BLE integration.""" -from __future__ import annotations - import asyncio from led_ble import LEDBLE from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DEVICE_TIMEOUT +from .const import DEVICE_TIMEOUT, DOMAIN from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData PLATFORMS: list[Platform] = [Platform.LIGHT] @@ -24,7 +23,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bo ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) if not ble_device: raise ConfigEntryNotReady( - f"Could not find LED BLE device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) led_ble = LEDBLE(ble_device) diff --git a/homeassistant/components/led_ble/config_flow.py b/homeassistant/components/led_ble/config_flow.py index 336d268b397..dc8e017d42a 100644 --- a/homeassistant/components/led_ble/config_flow.py +++ b/homeassistant/components/led_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LEDBLE integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/led_ble/coordinator.py b/homeassistant/components/led_ble/coordinator.py index c4bbf758167..7e028f757f8 100644 --- a/homeassistant/components/led_ble/coordinator.py +++ b/homeassistant/components/led_ble/coordinator.py @@ -1,7 +1,5 @@ """The LED BLE coordinator.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 8ffc31582f9..cd1f40ecdd0 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -1,7 +1,5 @@ """LED BLE integration light platform.""" -from __future__ import annotations - from typing import Any, cast from led_ble import LEDBLE diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 0fb5a3b3317..6489e7711e3 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -36,5 +36,5 @@ "documentation": "https://www.home-assistant.io/integrations/led_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.8"] + "requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"] } diff --git a/homeassistant/components/led_ble/strings.json b/homeassistant/components/led_ble/strings.json index 7d777781ab1..0cc44653ce2 100644 --- a/homeassistant/components/led_ble/strings.json +++ b/homeassistant/components/led_ble/strings.json @@ -18,5 +18,10 @@ } } } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find LED BLE device with address {address}: {reason}" + } } } diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index 0a6675237dd..30efdc0a827 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -1,7 +1,5 @@ """The Lektrico Charging Station integration.""" -from __future__ import annotations - from lektricowifi import Device from homeassistant.const import CONF_TYPE, Platform diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py index 0641749a2b9..f4457c91450 100644 --- a/homeassistant/components/lektrico/config_flow.py +++ b/homeassistant/components/lektrico/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Lektrico Charging Station.""" -from __future__ import annotations - from typing import Any from lektricowifi import Device, DeviceConnectionError diff --git a/homeassistant/components/lektrico/coordinator.py b/homeassistant/components/lektrico/coordinator.py index aa96cf49e07..26a36d276ab 100644 --- a/homeassistant/components/lektrico/coordinator.py +++ b/homeassistant/components/lektrico/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Lektrico Charging Station integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/lektrico/entity.py b/homeassistant/components/lektrico/entity.py index 1a5e08febe3..ae82f1c7d02 100644 --- a/homeassistant/components/lektrico/entity.py +++ b/homeassistant/components/lektrico/entity.py @@ -1,7 +1,5 @@ """Entity classes for the Lektrico integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py index 73e579569ca..2ad47591f05 100644 --- a/homeassistant/components/lektrico/sensor.py +++ b/homeassistant/components/lektrico/sensor.py @@ -1,7 +1,5 @@ """Support for Lektrico charging station sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 7e168792887..e48e1c9ced0 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -1,14 +1,12 @@ """The LetPot integration.""" -from __future__ import annotations - import asyncio from letpot.client import LetPotClient -from letpot.converters import CONVERTERS +from letpot.converters import GARDEN_CONVERTERS from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import AuthenticationInfo +from letpot.models import AuthenticationInfo, LetPotGardenStatus from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform from homeassistant.core import HomeAssistant @@ -73,10 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo device_client = LetPotDeviceClient(auth) - coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, entry, device, device_client) + coordinators: list[LetPotDeviceCoordinator[LetPotGardenStatus]] = [ + LetPotDeviceCoordinator[LetPotGardenStatus](hass, entry, device, device_client) for device in devices - if any(converter.supports_type(device.device_type) for converter in CONVERTERS) + if any( + converter.supports_type(device.device_type) + for converter in GARDEN_CONVERTERS + ) ] await asyncio.gather( diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py index 54b1499118a..c157fda60d1 100644 --- a/homeassistant/components/letpot/binary_sensor.py +++ b/homeassistant/components/letpot/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator, LetPotGardenStatus from .entity import LetPotEntity, LetPotEntityDescription # Coordinator is used to centralize the data updates @@ -22,16 +22,16 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class LetPotBinarySensorEntityDescription( +class LetPotBinarySensorEntityDescription[_DataT: LetPotDeviceStatus]( LetPotEntityDescription, BinarySensorEntityDescription ): """Describes a LetPot binary sensor entity.""" - is_on_fn: Callable[[LetPotDeviceStatus], bool] + is_on_fn: Callable[[_DataT], bool] -BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( - LetPotBinarySensorEntityDescription( +BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription[LetPotGardenStatus], ...] = ( + LetPotBinarySensorEntityDescription[LetPotGardenStatus]( key="low_nutrients", translation_key="low_nutrients", is_on_fn=lambda status: bool(status.errors.low_nutrients), @@ -42,7 +42,7 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( lambda coordinator: coordinator.data.errors.low_nutrients is not None ), ), - LetPotBinarySensorEntityDescription( + LetPotBinarySensorEntityDescription[LetPotGardenStatus]( key="low_water", translation_key="low_water", is_on_fn=lambda status: bool(status.errors.low_water), @@ -51,7 +51,7 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.PROBLEM, supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None, ), - LetPotBinarySensorEntityDescription( + LetPotBinarySensorEntityDescription[LetPotGardenStatus]( key="pump", translation_key="pump", is_on_fn=lambda status: status.pump_status == 1, @@ -65,7 +65,7 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( ) ), ), - LetPotBinarySensorEntityDescription( + LetPotBinarySensorEntityDescription[LetPotGardenStatus]( key="pump_error", translation_key="pump_error", is_on_fn=lambda status: bool(status.errors.pump_malfunction), @@ -76,7 +76,7 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( lambda coordinator: coordinator.data.errors.pump_malfunction is not None ), ), - LetPotBinarySensorEntityDescription( + LetPotBinarySensorEntityDescription[LetPotGardenStatus]( key="refill_error", translation_key="refill_error", is_on_fn=lambda status: bool(status.errors.refill_error), @@ -95,30 +95,36 @@ async def async_setup_entry( entry: LetPotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up LetPot binary sensor entities based on a config entry and device status/features.""" + """Set up LetPot binary sensor entities.""" coordinators = entry.runtime_data async_add_entities( - LetPotBinarySensorEntity(coordinator, description) + LetPotBinarySensorEntity[LetPotGardenStatus](coordinator, description) for description in BINARY_SENSORS for coordinator in coordinators if description.supported_fn(coordinator) ) -class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity): +class LetPotBinarySensorEntity[_DataT: LetPotDeviceStatus]( + LetPotEntity[_DataT], BinarySensorEntity +): """Defines a LetPot binary sensor entity.""" - entity_description: LetPotBinarySensorEntityDescription + entity_description: LetPotBinarySensorEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotBinarySensorEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotBinarySensorEntityDescription[_DataT], ) -> None: """Initialize LetPot binary sensor entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py index bc710cd6aef..3e48d3d137a 100644 --- a/homeassistant/components/letpot/config_flow.py +++ b/homeassistant/components/letpot/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the LetPot integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 0ef2c563f38..1d588523e46 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -1,14 +1,14 @@ """Coordinator for the LetPot integration.""" -from __future__ import annotations - import asyncio +from collections.abc import Callable from datetime import timedelta import logging +from typing import cast from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import LetPotDevice, LetPotDeviceStatus +from letpot.models import LetPotDevice, LetPotDeviceStatus, LetPotGardenStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,11 +19,13 @@ from .const import REQUEST_UPDATE_TIMEOUT _LOGGER = logging.getLogger(__name__) -type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator[LetPotGardenStatus]]] -class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): - """Class to handle data updates for a specific garden.""" +class LetPotDeviceCoordinator[_DataT: LetPotDeviceStatus]( + DataUpdateCoordinator[_DataT] +): + """Class to handle data updates for a specific device.""" config_entry: LetPotConfigEntry @@ -48,7 +50,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): self.device = device self.device_client = device_client - def _handle_status_update(self, status: LetPotDeviceStatus) -> None: + def _handle_status_update(self, status: _DataT) -> None: """Distribute status update to entities.""" self.async_set_updated_data(data=status) @@ -56,12 +58,13 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Set up subscription for coordinator.""" try: await self.device_client.subscribe( - self.device.serial_number, self._handle_status_update + self.device.serial_number, + cast(Callable[[LetPotDeviceStatus], None], self._handle_status_update), ) except LetPotAuthenticationException as exc: raise ConfigEntryAuthFailed from exc - async def _async_update_data(self) -> LetPotDeviceStatus: + async def _async_update_data(self) -> _DataT: """Request an update from the device and wait for a status update or timeout.""" try: async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index 11d6a132a18..02c6233e675 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any, Concatenate from letpot.exceptions import LetPotConnectionException, LetPotException +from letpot.models import LetPotDeviceStatus from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -22,12 +23,14 @@ class LetPotEntityDescription(EntityDescription): supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True -class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): +class LetPotEntity[_DataT: LetPotDeviceStatus]( + CoordinatorEntity[LetPotDeviceCoordinator[_DataT]] +): """Defines a base LetPot entity.""" _attr_has_entity_name = True - def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: + def __init__(self, coordinator: LetPotDeviceCoordinator[_DataT]) -> None: """Initialize a LetPot entity.""" super().__init__(coordinator) info = coordinator.device_client.device_info(coordinator.device.serial_number) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 5400814a47f..64477f08a6d 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["letpot"], "quality_scale": "silver", - "requirements": ["letpot==0.6.4"] + "requirements": ["letpot==0.7.0"] } diff --git a/homeassistant/components/letpot/number.py b/homeassistant/components/letpot/number.py index 1ab632e067e..a20a8a3a1b3 100644 --- a/homeassistant/components/letpot/number.py +++ b/homeassistant/components/letpot/number.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any from letpot.deviceclient import LetPotDeviceClient -from letpot.models import DeviceFeature +from letpot.models import DeviceFeature, LetPotDeviceStatus, LetPotGardenStatus from homeassistant.components.number import ( NumberEntity, @@ -25,16 +25,18 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription): +class LetPotNumberEntityDescription[_DataT: LetPotDeviceStatus]( + LetPotEntityDescription, NumberEntityDescription +): """Describes a LetPot number entity.""" - max_value_fn: Callable[[LetPotDeviceCoordinator], float] - value_fn: Callable[[LetPotDeviceCoordinator], float | None] + max_value_fn: Callable[[LetPotDeviceCoordinator[_DataT]], float] + value_fn: Callable[[LetPotDeviceCoordinator[_DataT]], float | None] set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]] -NUMBERS: tuple[LetPotNumberEntityDescription, ...] = ( - LetPotNumberEntityDescription( +NUMBERS: tuple[LetPotNumberEntityDescription[LetPotGardenStatus], ...] = ( + LetPotNumberEntityDescription[LetPotGardenStatus]( key="light_brightness_levels", translation_key="light_brightness", value_fn=( @@ -73,7 +75,7 @@ NUMBERS: tuple[LetPotNumberEntityDescription, ...] = ( mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, ), - LetPotNumberEntityDescription( + LetPotNumberEntityDescription[LetPotGardenStatus]( key="plant_days", translation_key="plant_days", native_unit_of_measurement=UnitOfTime.DAYS, @@ -96,30 +98,36 @@ async def async_setup_entry( entry: LetPotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up LetPot number entities based on a config entry and device status/features.""" + """Set up LetPot number entities.""" coordinators = entry.runtime_data async_add_entities( - LetPotNumberEntity(coordinator, description) + LetPotNumberEntity[LetPotGardenStatus](coordinator, description) for description in NUMBERS for coordinator in coordinators if description.supported_fn(coordinator) ) -class LetPotNumberEntity(LetPotEntity, NumberEntity): +class LetPotNumberEntity[_DataT: LetPotDeviceStatus]( + LetPotEntity[_DataT], NumberEntity +): """Defines a LetPot number entity.""" - entity_description: LetPotNumberEntityDescription + entity_description: LetPotNumberEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotNumberEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotNumberEntityDescription[_DataT], ) -> None: """Initialize LetPot number entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def native_max_value(self) -> float: diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 3931e7eabed..4d572906bdb 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/letpot/select.py b/homeassistant/components/letpot/select.py index 7508e80329e..969a55b228d 100644 --- a/homeassistant/components/letpot/select.py +++ b/homeassistant/components/letpot/select.py @@ -6,7 +6,13 @@ from enum import StrEnum from typing import Any from letpot.deviceclient import LetPotDeviceClient -from letpot.models import DeviceFeature, LightMode, TemperatureUnit +from letpot.models import ( + DeviceFeature, + LetPotDeviceStatus, + LetPotGardenStatus, + LightMode, + TemperatureUnit, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -29,7 +35,7 @@ class LightBrightnessLowHigh(StrEnum): def _get_brightness_low_high_value(coordinator: LetPotDeviceCoordinator) -> str | None: - """Return brightness as low/high for a device which only has a low and high value.""" + """Return brightness as low/high for a device with two levels.""" brightness = coordinator.data.light_brightness levels = coordinator.device_client.get_light_brightness_levels( coordinator.device.serial_number @@ -52,15 +58,17 @@ async def _set_brightness_low_high_value( @dataclass(frozen=True, kw_only=True) -class LetPotSelectEntityDescription(LetPotEntityDescription, SelectEntityDescription): +class LetPotSelectEntityDescription[_DataT: LetPotDeviceStatus]( + LetPotEntityDescription, SelectEntityDescription +): """Describes a LetPot select entity.""" - value_fn: Callable[[LetPotDeviceCoordinator], str | None] + value_fn: Callable[[LetPotDeviceCoordinator[_DataT]], str | None] set_value_fn: Callable[[LetPotDeviceClient, str, str], Coroutine[Any, Any, None]] -SELECTORS: tuple[LetPotSelectEntityDescription, ...] = ( - LetPotSelectEntityDescription( +SELECTORS: tuple[LetPotSelectEntityDescription[LetPotGardenStatus], ...] = ( + LetPotSelectEntityDescription[LetPotGardenStatus]( key="display_temperature_unit", translation_key="display_temperature_unit", options=[x.name.lower() for x in TemperatureUnit], @@ -86,7 +94,7 @@ SELECTORS: tuple[LetPotSelectEntityDescription, ...] = ( ), entity_category=EntityCategory.CONFIG, ), - LetPotSelectEntityDescription( + LetPotSelectEntityDescription[LetPotGardenStatus]( key="light_brightness_low_high", translation_key="light_brightness", options=[ @@ -105,7 +113,7 @@ SELECTORS: tuple[LetPotSelectEntityDescription, ...] = ( ), entity_category=EntityCategory.CONFIG, ), - LetPotSelectEntityDescription( + LetPotSelectEntityDescription[LetPotGardenStatus]( key="light_mode", translation_key="light_mode", options=[x.name.lower() for x in LightMode], @@ -131,30 +139,36 @@ async def async_setup_entry( entry: LetPotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up LetPot select entities based on a config entry and device status/features.""" + """Set up LetPot select entities.""" coordinators = entry.runtime_data async_add_entities( - LetPotSelectEntity(coordinator, description) + LetPotSelectEntity[LetPotGardenStatus](coordinator, description) for description in SELECTORS for coordinator in coordinators if description.supported_fn(coordinator) ) -class LetPotSelectEntity(LetPotEntity, SelectEntity): +class LetPotSelectEntity[_DataT: LetPotDeviceStatus]( + LetPotEntity[_DataT], SelectEntity +): """Defines a LetPot select entity.""" - entity_description: LetPotSelectEntityDescription + entity_description: LetPotSelectEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotSelectEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotSelectEntityDescription[_DataT], ) -> None: """Initialize LetPot select entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py index 4391a6d8b0e..1a550a65985 100644 --- a/homeassistant/components/letpot/sensor.py +++ b/homeassistant/components/letpot/sensor.py @@ -3,7 +3,12 @@ from collections.abc import Callable from dataclasses import dataclass -from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit +from letpot.models import ( + DeviceFeature, + LetPotDeviceStatus, + LetPotGardenStatus, + TemperatureUnit, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,15 +35,17 @@ LETPOT_TEMPERATURE_UNIT_HA_UNIT = { @dataclass(frozen=True, kw_only=True) -class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription): +class LetPotSensorEntityDescription[_DataT: LetPotDeviceStatus]( + LetPotEntityDescription, SensorEntityDescription +): """Describes a LetPot sensor entity.""" - native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None] - value_fn: Callable[[LetPotDeviceStatus], StateType] + native_unit_of_measurement_fn: Callable[[_DataT], str | None] + value_fn: Callable[[_DataT], StateType] -SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( - LetPotSensorEntityDescription( +SENSORS: tuple[LetPotSensorEntityDescription[LetPotGardenStatus], ...] = ( + LetPotSensorEntityDescription[LetPotGardenStatus]( key="temperature", value_fn=lambda status: status.temperature_value, native_unit_of_measurement_fn=( @@ -57,7 +64,7 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( ) ), ), - LetPotSensorEntityDescription( + LetPotSensorEntityDescription[LetPotGardenStatus]( key="water_level", translation_key="water_level", value_fn=lambda status: status.water_level, @@ -83,27 +90,33 @@ async def async_setup_entry( """Set up LetPot sensor entities based on a device features.""" coordinators = entry.runtime_data async_add_entities( - LetPotSensorEntity(coordinator, description) + LetPotSensorEntity[LetPotGardenStatus](coordinator, description) for description in SENSORS for coordinator in coordinators if description.supported_fn(coordinator) ) -class LetPotSensorEntity(LetPotEntity, SensorEntity): +class LetPotSensorEntity[_DataT: LetPotDeviceStatus]( + LetPotEntity[_DataT], SensorEntity +): """Defines a LetPot sensor entity.""" - entity_description: LetPotSensorEntityDescription + entity_description: LetPotSensorEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotSensorEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotSensorEntityDescription[_DataT], ) -> None: """Initialize LetPot sensor entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def native_unit_of_measurement(self) -> str | None: diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 119635b513c..6ae31867c43 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any from letpot.deviceclient import LetPotDeviceClient -from letpot.models import DeviceFeature, LetPotDeviceStatus +from letpot.models import DeviceFeature, LetPotDeviceStatus, LetPotGardenStatus from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -21,15 +21,17 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription): +class LetPotSwitchEntityDescription[_DataT: LetPotDeviceStatus]( + LetPotEntityDescription, SwitchEntityDescription +): """Describes a LetPot switch entity.""" - value_fn: Callable[[LetPotDeviceStatus], bool | None] + value_fn: Callable[[_DataT], bool | None] set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]] -SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( - LetPotSwitchEntityDescription( +SWITCHES: tuple[LetPotSwitchEntityDescription[LetPotGardenStatus], ...] = ( + LetPotSwitchEntityDescription[LetPotGardenStatus]( key="alarm_sound", translation_key="alarm_sound", value_fn=lambda status: status.system_sound, @@ -39,7 +41,7 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, supported_fn=lambda coordinator: coordinator.data.system_sound is not None, ), - LetPotSwitchEntityDescription( + LetPotSwitchEntityDescription[LetPotGardenStatus]( key="auto_mode", translation_key="auto_mode", value_fn=lambda status: status.water_mode == 1, @@ -58,7 +60,7 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( ) ), ), - LetPotSwitchEntityDescription( + LetPotSwitchEntityDescription[LetPotGardenStatus]( key="power", translation_key="power", value_fn=lambda status: status.system_on, @@ -67,7 +69,7 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( ), entity_category=EntityCategory.CONFIG, ), - LetPotSwitchEntityDescription( + LetPotSwitchEntityDescription[LetPotGardenStatus]( key="pump_cycling", translation_key="pump_cycling", value_fn=lambda status: status.pump_mode == 1, @@ -84,10 +86,10 @@ async def async_setup_entry( entry: LetPotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up LetPot switch entities based on a config entry and device status/features.""" + """Set up LetPot switch entities.""" coordinators = entry.runtime_data entities: list[SwitchEntity] = [ - LetPotSwitchEntity(coordinator, description) + LetPotSwitchEntity[LetPotGardenStatus](coordinator, description) for description in SWITCHES for coordinator in coordinators if description.supported_fn(coordinator) @@ -95,20 +97,26 @@ async def async_setup_entry( async_add_entities(entities) -class LetPotSwitchEntity(LetPotEntity, SwitchEntity): +class LetPotSwitchEntity[_DataT: LetPotDeviceStatus]( + LetPotEntity[_DataT], SwitchEntity +): """Defines a LetPot switch entity.""" - entity_description: LetPotSwitchEntityDescription + entity_description: LetPotSwitchEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotSwitchEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotSwitchEntityDescription[_DataT], ) -> None: """Initialize LetPot switch entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 87ce35f828d..14c0e43d958 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator, LetPotGardenStatus from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache @@ -22,15 +22,15 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotTimeEntityDescription(TimeEntityDescription): +class LetPotTimeEntityDescription[_DataT: LetPotDeviceStatus](TimeEntityDescription): """Describes a LetPot time entity.""" - value_fn: Callable[[LetPotDeviceStatus], time | None] + value_fn: Callable[[_DataT], time | None] set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]] -TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( - LetPotTimeEntityDescription( +TIME_SENSORS: tuple[LetPotTimeEntityDescription[LetPotGardenStatus], ...] = ( + LetPotTimeEntityDescription[LetPotGardenStatus]( key="light_schedule_end", translation_key="light_schedule_end", value_fn=lambda status: None if status is None else status.light_schedule_end, @@ -41,7 +41,7 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( ), entity_category=EntityCategory.CONFIG, ), - LetPotTimeEntityDescription( + LetPotTimeEntityDescription[LetPotGardenStatus]( key="light_schedule_start", translation_key="light_schedule_start", value_fn=lambda status: None if status is None else status.light_schedule_start, @@ -63,26 +63,30 @@ async def async_setup_entry( """Set up LetPot time entities based on a config entry.""" coordinators = entry.runtime_data async_add_entities( - LetPotTimeEntity(coordinator, description) + LetPotTimeEntity[LetPotGardenStatus](coordinator, description) for description in TIME_SENSORS for coordinator in coordinators ) -class LetPotTimeEntity(LetPotEntity, TimeEntity): +class LetPotTimeEntity[_DataT: LetPotDeviceStatus](LetPotEntity[_DataT], TimeEntity): """Defines a LetPot time entity.""" - entity_description: LetPotTimeEntityDescription + entity_description: LetPotTimeEntityDescription[_DataT] def __init__( self, - coordinator: LetPotDeviceCoordinator, - description: LetPotTimeEntityDescription, + coordinator: LetPotDeviceCoordinator[_DataT], + description: LetPotTimeEntityDescription[_DataT], ) -> None: """Initialize LetPot time entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}" + f"_{coordinator.device.serial_number}" + f"_{description.key}" + ) @property def native_value(self) -> time | None: diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index d8b6e51d239..8a2f67d1985 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -1,12 +1,10 @@ """LG IR Remote integration for Home Assistant.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/lg_infrared/button.py b/homeassistant/components/lg_infrared/button.py index 9c1482205e4..9aa8252656c 100644 --- a/homeassistant/components/lg_infrared/button.py +++ b/homeassistant/components/lg_infrared/button.py @@ -1,12 +1,11 @@ """Button platform for LG IR integration.""" -from __future__ import annotations - from dataclasses import dataclass from infrared_protocols.codes.lg.tv import LGTVCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -25,6 +24,9 @@ class LgIrButtonEntityDescription(ButtonEntityDescription): TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = ( + LgIrButtonEntityDescription( + key="power", translation_key="power", command_code=LGTVCode.POWER + ), LgIrButtonEntityDescription( key="power_on", translation_key="power_on", command_code=LGTVCode.POWER_ON ), @@ -118,7 +120,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LG IR buttons from config entry.""" - infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + device_type = entry.data[CONF_DEVICE_TYPE] if device_type == LGDeviceType.TV: async_add_entities( @@ -127,7 +131,7 @@ async def async_setup_entry( ) -class LgIrButton(LgIrEntity, ButtonEntity): +class LgIrButton(LgIrEntity, InfraredEmitterConsumerEntity, ButtonEntity): """LG IR button entity.""" entity_description: LgIrButtonEntityDescription @@ -139,9 +143,10 @@ class LgIrButton(LgIrEntity, ButtonEntity): description: LgIrButtonEntityDescription, ) -> None: """Initialize LG IR button.""" - super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key) + super().__init__(entry, unique_id_suffix=description.key) + self._infrared_emitter_entity_id = infrared_entity_id self.entity_description = description async def async_press(self) -> None: """Press the button.""" - await self._send_command(self.entity_description.command_code) + await self._send_command(self.entity_description.command_code.to_command()) diff --git a/homeassistant/components/lg_infrared/config_flow.py b/homeassistant/components/lg_infrared/config_flow.py index 3e49757cbb0..a4025984cdc 100644 --- a/homeassistant/components/lg_infrared/config_flow.py +++ b/homeassistant/components/lg_infrared/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.infrared import ( DOMAIN as INFRARED_DOMAIN, async_get_emitters, + async_get_receivers, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers import entity_registry as er @@ -18,7 +19,13 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType +from .const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, + LGDeviceType, +) DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = { LGDeviceType.TV: "TV", @@ -35,44 +42,60 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" emitter_entity_ids = async_get_emitters(self.hass) - if not emitter_entity_ids: - return self.async_abort(reason="no_emitters") + receiver_entity_ids = async_get_receivers(self.hass) + if not emitter_entity_ids and not receiver_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + errors: dict[str, str] = {} if user_input is not None: - entity_id = user_input[CONF_INFRARED_ENTITY_ID] - device_type = user_input[CONF_DEVICE_TYPE] + if entity_id := user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get( + CONF_INFRARED_RECEIVER_ENTITY_ID + ): + device_type = user_input[CONF_DEVICE_TYPE] - await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}") - self._abort_if_unique_id_configured() + await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}") + self._abort_if_unique_id_configured() - # Get entity name for the title - ent_reg = er.async_get(self.hass) - entry = ent_reg.async_get(entity_id) - entity_name = ( - entry.name or entry.original_name or entity_id if entry else entity_id - ) - device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)] - title = f"LG {device_type_name} via {entity_name}" + # Get entity name for the title + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(entity_id) + entity_name = ( + entry.name or entry.original_name or entity_id + if entry + else entity_id + ) + device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)] + title = f"LG {device_type_name} via {entity_name}" - return self.async_create_entry(title=title, data=user_input) + return self.async_create_entry(title=title, data=user_input) + + errors["base"] = "missing_infrared_entity" + + schema_dict: dict[vol.Marker, Any] = { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[device_type.value for device_type in LGDeviceType], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=receiver_entity_ids, + ) + ), + } return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_DEVICE_TYPE): SelectSelector( - SelectSelectorConfig( - options=[device_type.value for device_type in LGDeviceType], - translation_key=CONF_DEVICE_TYPE, - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( - EntitySelectorConfig( - domain=INFRARED_DOMAIN, - include_entities=emitter_entity_ids, - ) - ), - } - ), + data_schema=vol.Schema(schema_dict), + errors=errors, ) diff --git a/homeassistant/components/lg_infrared/const.py b/homeassistant/components/lg_infrared/const.py index 43958b763d4..5306d9cc70f 100644 --- a/homeassistant/components/lg_infrared/const.py +++ b/homeassistant/components/lg_infrared/const.py @@ -4,6 +4,7 @@ from enum import StrEnum DOMAIN = "lg_infrared" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" CONF_DEVICE_TYPE = "device_type" diff --git a/homeassistant/components/lg_infrared/entity.py b/homeassistant/components/lg_infrared/entity.py index 1d1a958b975..71a1cbb7284 100644 --- a/homeassistant/components/lg_infrared/entity.py +++ b/homeassistant/components/lg_infrared/entity.py @@ -1,76 +1,20 @@ """Common entity for LG IR integration.""" -import logging - -from infrared_protocols.codes.lg.tv import LGTVCode, make_command as make_lg_tv_command - -from homeassistant.components.infrared import async_send_command from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - class LgIrEntity(Entity): - """LG IR base entity.""" + """LG IR base entity providing common device info.""" _attr_has_entity_name = True - def __init__( - self, entry: ConfigEntry, infrared_entity_id: str, unique_id_suffix: str - ) -> None: + def __init__(self, entry: ConfigEntry, unique_id_suffix: str) -> None: """Initialize LG IR entity.""" - self._infrared_entity_id = infrared_entity_id self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG" ) - - async def async_added_to_hass(self) -> None: - """Subscribe to infrared entity state changes.""" - await super().async_added_to_hass() - - @callback - def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: - """Handle infrared entity state changes.""" - new_state = event.data["new_state"] - ir_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if ir_available != self.available: - _LOGGER.info( - "Infrared entity %s used by %s is %s", - self._infrared_entity_id, - self.entity_id, - "available" if ir_available else "unavailable", - ) - - self._attr_available = ir_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._infrared_entity_id], _async_ir_state_changed - ) - ) - - # Set initial availability based on current infrared entity state - ir_state = self.hass.states.get(self._infrared_entity_id) - self._attr_available = ( - ir_state is not None and ir_state.state != STATE_UNAVAILABLE - ) - - async def _send_command(self, code: LGTVCode) -> None: - """Send an IR command using the LG protocol.""" - await async_send_command( - self.hass, - self._infrared_entity_id, - make_lg_tv_command(code), - context=self._context, - ) diff --git a/homeassistant/components/lg_infrared/event.py b/homeassistant/components/lg_infrared/event.py new file mode 100644 index 00000000000..1deabd6c62a --- /dev/null +++ b/homeassistant/components/lg_infrared/event.py @@ -0,0 +1,131 @@ +"""Event platform for LG IR integration.""" + +import logging + +from infrared_protocols.codes.lg.tv import LG_ADDRESS, LGTVCode +from infrared_protocols.commands.nec import NECCommand + +from homeassistant.components.event import EventEntity +from homeassistant.components.infrared import ( + InfraredReceivedSignal, + InfraredReceiverConsumerEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_RECEIVER_ENTITY_ID, LGDeviceType +from .entity import LgIrEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +_COMMAND_CODE_TO_EVENT_TYPE: dict[LGTVCode, str] = { + LGTVCode.ASPECT: "aspect", + LGTVCode.BACK: "back", + LGTVCode.BLUE: "blue", + LGTVCode.CHANNEL_DOWN: "channel_down", + LGTVCode.CHANNEL_UP: "channel_up", + LGTVCode.EXIT: "exit", + LGTVCode.EZ_ADJUST: "ez_adjust", + LGTVCode.FAST_FORWARD: "fast_forward", + LGTVCode.GREEN: "green", + LGTVCode.GUIDE: "guide", + LGTVCode.HDMI_1: "hdmi_1", + LGTVCode.HDMI_2: "hdmi_2", + LGTVCode.HDMI_3: "hdmi_3", + LGTVCode.HDMI_4: "hdmi_4", + LGTVCode.HOME: "home", + LGTVCode.INFO: "info", + LGTVCode.INPUT: "input", + LGTVCode.IN_START: "in_start", + LGTVCode.LIST: "list", + LGTVCode.MENU: "menu", + LGTVCode.MUTE: "mute", + LGTVCode.NAV_DOWN: "down", + LGTVCode.NAV_LEFT: "left", + LGTVCode.NAV_RIGHT: "right", + LGTVCode.NAV_UP: "up", + LGTVCode.NUM_0: "num_0", + LGTVCode.NUM_1: "num_1", + LGTVCode.NUM_2: "num_2", + LGTVCode.NUM_3: "num_3", + LGTVCode.NUM_4: "num_4", + LGTVCode.NUM_5: "num_5", + LGTVCode.NUM_6: "num_6", + LGTVCode.NUM_7: "num_7", + LGTVCode.NUM_8: "num_8", + LGTVCode.NUM_9: "num_9", + LGTVCode.OK: "ok", + LGTVCode.PAUSE: "pause", + LGTVCode.PLAY: "play", + LGTVCode.POWER: "power", + LGTVCode.POWER_OFF: "power_off", + LGTVCode.POWER_ON: "power_on", + LGTVCode.RED: "red", + LGTVCode.REWIND: "rewind", + LGTVCode.SAP: "sap", + LGTVCode.SETTINGS: "settings", + LGTVCode.STOP: "stop", + LGTVCode.SUBTITLE: "subtitle", + LGTVCode.TEXT: "text", + LGTVCode.VOLUME_DOWN: "volume_down", + LGTVCode.VOLUME_UP: "volume_up", + LGTVCode.YELLOW: "yellow", +} +_EVENT_TYPE_UNKNOWN = "unknown" +_EVENT_TYPES: list[str] = [*_COMMAND_CODE_TO_EVENT_TYPE.values(), _EVENT_TYPE_UNKNOWN] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LG IR event entity from config entry.""" + if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.TV: + return + if not (receiver_entity_id := entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID)): + return + async_add_entities([LgIrReceivedCommandEvent(entry, receiver_entity_id)]) + + +class LgIrReceivedCommandEvent(LgIrEntity, InfraredReceiverConsumerEntity, EventEntity): + """Event entity that fires when an LG TV IR command is received.""" + + _attr_translation_key = "received_command" + _attr_event_types = _EVENT_TYPES + + def __init__(self, entry: ConfigEntry, receiver_entity_id: str) -> None: + """Initialize the event entity.""" + super().__init__(entry, unique_id_suffix="received_command") + self._infrared_receiver_entity_id = receiver_entity_id + + @callback + def _handle_signal(self, signal: InfraredReceivedSignal) -> None: + """Handle a received IR signal.""" + nec_command = NECCommand.from_raw_timings(signal.timings) + if nec_command is None: + return + + if nec_command.address != LG_ADDRESS: + return + + try: + command_code = LGTVCode(nec_command.command) + except ValueError: + # Ensure that a future change to the LGTVCode enum doesn't break + # this and shows as unknown. + event_type = _EVENT_TYPE_UNKNOWN + else: + event_type = _COMMAND_CODE_TO_EVENT_TYPE.get( + command_code, _EVENT_TYPE_UNKNOWN + ) + + _LOGGER.debug( + "Received LG TV IR command: %s (0x%02X)", event_type, nec_command.command + ) + + self._trigger_event(event_type) + self.async_write_ha_state() diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json new file mode 100644 index 00000000000..896d1eb4e99 --- /dev/null +++ b/homeassistant/components/lg_infrared/icons.json @@ -0,0 +1,93 @@ +{ + "entity": { + "button": { + "back": { + "default": "mdi:keyboard-backspace" + }, + "down": { + "default": "mdi:arrow-down" + }, + "exit": { + "default": "mdi:exit-to-app" + }, + "guide": { + "default": "mdi:television-guide" + }, + "hdmi_1": { + "default": "mdi:video-input-hdmi" + }, + "hdmi_2": { + "default": "mdi:video-input-hdmi" + }, + "hdmi_3": { + "default": "mdi:video-input-hdmi" + }, + "hdmi_4": { + "default": "mdi:video-input-hdmi" + }, + "home": { + "default": "mdi:home" + }, + "info": { + "default": "mdi:information-outline" + }, + "input": { + "default": "mdi:import" + }, + "left": { + "default": "mdi:arrow-left" + }, + "menu": { + "default": "mdi:menu" + }, + "num_0": { + "default": "mdi:numeric-0" + }, + "num_1": { + "default": "mdi:numeric-1" + }, + "num_2": { + "default": "mdi:numeric-2" + }, + "num_3": { + "default": "mdi:numeric-3" + }, + "num_4": { + "default": "mdi:numeric-4" + }, + "num_5": { + "default": "mdi:numeric-5" + }, + "num_6": { + "default": "mdi:numeric-6" + }, + "num_7": { + "default": "mdi:numeric-7" + }, + "num_8": { + "default": "mdi:numeric-8" + }, + "num_9": { + "default": "mdi:numeric-9" + }, + "ok": { + "default": "mdi:check" + }, + "power": { + "default": "mdi:power" + }, + "power_off": { + "default": "mdi:power-off" + }, + "power_on": { + "default": "mdi:power-on" + }, + "right": { + "default": "mdi:arrow-right" + }, + "up": { + "default": "mdi:arrow-up" + } + } + } +} diff --git a/homeassistant/components/lg_infrared/manifest.json b/homeassistant/components/lg_infrared/manifest.json index 27c5110740b..b95bc613f3b 100644 --- a/homeassistant/components/lg_infrared/manifest.json +++ b/homeassistant/components/lg_infrared/manifest.json @@ -1,7 +1,7 @@ { "domain": "lg_infrared", "name": "LG Infrared", - "codeowners": ["@home-assistant/core"], + "codeowners": ["@abmantis"], "config_flow": true, "dependencies": ["infrared"], "documentation": "https://www.home-assistant.io/integrations/lg_infrared", diff --git a/homeassistant/components/lg_infrared/media_player.py b/homeassistant/components/lg_infrared/media_player.py index 4985a0394b8..dc65f27778b 100644 --- a/homeassistant/components/lg_infrared/media_player.py +++ b/homeassistant/components/lg_infrared/media_player.py @@ -1,9 +1,8 @@ """Media player platform for LG IR integration.""" -from __future__ import annotations - from infrared_protocols.codes.lg.tv import LGTVCode +from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, @@ -26,13 +25,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LG IR media player from config entry.""" - infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + device_type = entry.data[CONF_DEVICE_TYPE] if device_type == LGDeviceType.TV: async_add_entities([LgIrTvMediaPlayer(entry, infrared_entity_id)]) -class LgIrTvMediaPlayer(LgIrEntity, MediaPlayerEntity): +class LgIrTvMediaPlayer(LgIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity): """LG IR media player entity.""" _attr_name = None @@ -52,45 +53,46 @@ class LgIrTvMediaPlayer(LgIrEntity, MediaPlayerEntity): def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None: """Initialize LG IR media player.""" - super().__init__(entry, infrared_entity_id, unique_id_suffix="media_player") + super().__init__(entry, unique_id_suffix="media_player") + self._infrared_emitter_entity_id = infrared_entity_id self._attr_state = MediaPlayerState.ON async def async_turn_on(self) -> None: """Turn on the TV.""" - await self._send_command(LGTVCode.POWER) + await self._send_command(LGTVCode.POWER_ON.to_command()) async def async_turn_off(self) -> None: """Turn off the TV.""" - await self._send_command(LGTVCode.POWER) + await self._send_command(LGTVCode.POWER_OFF.to_command()) async def async_volume_up(self) -> None: """Send volume up command.""" - await self._send_command(LGTVCode.VOLUME_UP) + await self._send_command(LGTVCode.VOLUME_UP.to_command()) async def async_volume_down(self) -> None: """Send volume down command.""" - await self._send_command(LGTVCode.VOLUME_DOWN) + await self._send_command(LGTVCode.VOLUME_DOWN.to_command()) async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" - await self._send_command(LGTVCode.MUTE) + await self._send_command(LGTVCode.MUTE.to_command()) async def async_media_next_track(self) -> None: """Send channel up command.""" - await self._send_command(LGTVCode.CHANNEL_UP) + await self._send_command(LGTVCode.CHANNEL_UP.to_command()) async def async_media_previous_track(self) -> None: """Send channel down command.""" - await self._send_command(LGTVCode.CHANNEL_DOWN) + await self._send_command(LGTVCode.CHANNEL_DOWN.to_command()) async def async_media_play(self) -> None: """Send play command.""" - await self._send_command(LGTVCode.PLAY) + await self._send_command(LGTVCode.PLAY.to_command()) async def async_media_pause(self) -> None: """Send pause command.""" - await self._send_command(LGTVCode.PAUSE) + await self._send_command(LGTVCode.PAUSE.to_command()) async def async_media_stop(self) -> None: """Send stop command.""" - await self._send_command(LGTVCode.STOP) + await self._send_command(LGTVCode.STOP.to_command()) diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index 0c81b3ea0f5..cd659da8f7a 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -1,20 +1,25 @@ { "config": { "abort": { - "already_configured": "This LG device has already been configured with this transmitter.", - "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + "already_configured": "This LG device has already been configured with this infrared entity.", + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" + }, + "error": { + "missing_infrared_entity": "Select an infrared emitter or receiver." }, "step": { "user": { "data": { - "device_type": "Device type", - "infrared_entity_id": "Infrared transmitter" + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]", + "infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]" }, "data_description": { "device_type": "The type of LG device to control.", - "infrared_entity_id": "The infrared transmitter entity to use for sending commands." + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]", + "infrared_receiver_entity_id": "The infrared receiver entity to use for receiving signals." }, - "description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.", + "description": "Select the device type and at least one infrared emitter or receiver to use with your LG device.", "title": "Set up LG IR Remote" } } @@ -22,95 +27,161 @@ "entity": { "button": { "back": { - "name": "Back" + "name": "[%key:common::entity::button::back::name%]" }, "down": { - "name": "Down" + "name": "[%key:common::entity::button::down::name%]" }, "exit": { - "name": "Exit" + "name": "[%key:common::entity::button::exit::name%]" }, "guide": { - "name": "Guide" + "name": "[%key:common::entity::button::guide::name%]" }, "hdmi_1": { - "name": "HDMI 1" + "name": "[%key:common::entity::button::hdmi_1::name%]" }, "hdmi_2": { - "name": "HDMI 2" + "name": "[%key:common::entity::button::hdmi_2::name%]" }, "hdmi_3": { - "name": "HDMI 3" + "name": "[%key:common::entity::button::hdmi_3::name%]" }, "hdmi_4": { - "name": "HDMI 4" + "name": "[%key:common::entity::button::hdmi_4::name%]" }, "home": { - "name": "Home" + "name": "[%key:common::entity::button::home::name%]" }, "info": { - "name": "Info" + "name": "[%key:common::entity::button::info::name%]" }, "input": { - "name": "Input" + "name": "[%key:common::entity::button::input::name%]" }, "left": { - "name": "Left" + "name": "[%key:common::entity::button::left::name%]" }, "menu": { - "name": "Menu" + "name": "[%key:common::entity::button::menu::name%]" }, "num_0": { - "name": "Number 0" + "name": "[%key:common::entity::button::num_0::name%]" }, "num_1": { - "name": "Number 1" + "name": "[%key:common::entity::button::num_1::name%]" }, "num_2": { - "name": "Number 2" + "name": "[%key:common::entity::button::num_2::name%]" }, "num_3": { - "name": "Number 3" + "name": "[%key:common::entity::button::num_3::name%]" }, "num_4": { - "name": "Number 4" + "name": "[%key:common::entity::button::num_4::name%]" }, "num_5": { - "name": "Number 5" + "name": "[%key:common::entity::button::num_5::name%]" }, "num_6": { - "name": "Number 6" + "name": "[%key:common::entity::button::num_6::name%]" }, "num_7": { - "name": "Number 7" + "name": "[%key:common::entity::button::num_7::name%]" }, "num_8": { - "name": "Number 8" + "name": "[%key:common::entity::button::num_8::name%]" }, "num_9": { - "name": "Number 9" + "name": "[%key:common::entity::button::num_9::name%]" }, "ok": { - "name": "OK" + "name": "[%key:common::entity::button::ok::name%]" + }, + "power": { + "name": "[%key:common::entity::button::power::name%]" }, "power_off": { - "name": "Power off" + "name": "[%key:common::entity::button::power_off::name%]" }, "power_on": { - "name": "Power on" + "name": "[%key:common::entity::button::power_on::name%]" }, "right": { - "name": "Right" + "name": "[%key:common::entity::button::right::name%]" }, "up": { - "name": "Up" + "name": "[%key:common::entity::button::up::name%]" + } + }, + "event": { + "received_command": { + "name": "Received command", + "state_attributes": { + "event_type": { + "state": { + "aspect": "Aspect", + "back": "[%key:common::entity::button::back::name%]", + "blue": "Blue", + "channel_down": "Channel down", + "channel_up": "Channel up", + "down": "[%key:common::entity::button::down::name%]", + "exit": "[%key:common::entity::button::exit::name%]", + "ez_adjust": "EZ adjust", + "fast_forward": "Fast forward", + "green": "Green", + "guide": "[%key:common::entity::button::guide::name%]", + "hdmi_1": "[%key:common::entity::button::hdmi_1::name%]", + "hdmi_2": "[%key:common::entity::button::hdmi_2::name%]", + "hdmi_3": "[%key:common::entity::button::hdmi_3::name%]", + "hdmi_4": "[%key:common::entity::button::hdmi_4::name%]", + "home": "[%key:common::entity::button::home::name%]", + "in_start": "In start", + "info": "[%key:common::entity::button::info::name%]", + "input": "[%key:common::entity::button::input::name%]", + "left": "[%key:common::entity::button::left::name%]", + "list": "List", + "menu": "[%key:common::entity::button::menu::name%]", + "mute": "Mute", + "num_0": "[%key:common::entity::button::num_0::name%]", + "num_1": "[%key:common::entity::button::num_1::name%]", + "num_2": "[%key:common::entity::button::num_2::name%]", + "num_3": "[%key:common::entity::button::num_3::name%]", + "num_4": "[%key:common::entity::button::num_4::name%]", + "num_5": "[%key:common::entity::button::num_5::name%]", + "num_6": "[%key:common::entity::button::num_6::name%]", + "num_7": "[%key:common::entity::button::num_7::name%]", + "num_8": "[%key:common::entity::button::num_8::name%]", + "num_9": "[%key:common::entity::button::num_9::name%]", + "ok": "[%key:common::entity::button::ok::name%]", + "pause": "Pause", + "play": "Play", + "power": "[%key:common::entity::button::power::name%]", + "power_off": "[%key:common::entity::button::power_off::name%]", + "power_on": "[%key:common::entity::button::power_on::name%]", + "red": "Red", + "rewind": "Rewind", + "right": "[%key:common::entity::button::right::name%]", + "sap": "SAP", + "settings": "Settings", + "stop": "Stop", + "subtitle": "Subtitle", + "text": "Text", + "unknown": "Unknown", + "up": "[%key:common::entity::button::up::name%]", + "volume_down": "Volume down", + "volume_up": "Volume up", + "yellow": "Yellow" + } + } + } } } }, "selector": { "device_type": { "options": { - "tv": "TV" + "tv": "[%key:common::generic::tv%]" } } } diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index c2509889760..d97464d9a9c 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN -PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER, Platform.REMOTE] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/lg_netcast/config_flow.py b/homeassistant/components/lg_netcast/config_flow.py index d5e28f3c057..94519475d2f 100644 --- a/homeassistant/components/lg_netcast/config_flow.py +++ b/homeassistant/components/lg_netcast/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the LG Netcast TV integration.""" -from __future__ import annotations - import contextlib from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py index c4f48fee431..647f097f57a 100644 --- a/homeassistant/components/lg_netcast/device_trigger.py +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for LG Netcast.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 9383c0e6bd1..24dea12c9f4 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -1,7 +1,5 @@ """Support for LG TV running on NetCast 3 or 4.""" -from __future__ import annotations - from datetime import datetime from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/lg_netcast/remote.py b/homeassistant/components/lg_netcast/remote.py new file mode 100644 index 00000000000..8d1d0214e5d --- /dev/null +++ b/homeassistant/components/lg_netcast/remote.py @@ -0,0 +1,92 @@ +"""Remote control support for LG Netcast TV.""" + +from collections.abc import Iterable +import time +from typing import TYPE_CHECKING, Any + +from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError +from requests import RequestException + +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + RemoteEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LgNetCastConfigEntry +from .const import ATTR_MANUFACTURER, DOMAIN + +VALID_COMMANDS: frozenset[str] = frozenset( + k + for k in vars(LG_COMMAND) + if not k.startswith("_") and isinstance(getattr(LG_COMMAND, k), int) +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LgNetCastConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LG Netcast Remote from a config entry.""" + client = config_entry.runtime_data + unique_id = config_entry.unique_id + if TYPE_CHECKING: + assert unique_id is not None + + async_add_entities([LgNetCastRemote(client, unique_id)]) + + +class LgNetCastRemote(RemoteEntity): + """Device that sends commands to an LG Netcast TV.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, client: LgNetCastClient, unique_id: str) -> None: + """Initialize the LG Netcast remote.""" + self._client = client + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + ) + + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to the TV.""" + num_repeats = kwargs[ATTR_NUM_REPEATS] + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + commands: list[int] = [] + for cmd in command: + if cmd not in VALID_COMMANDS: + raise ServiceValidationError(f"Unknown command: {cmd!r}") + commands.append(getattr(LG_COMMAND, cmd)) + for i in range(num_repeats): + try: + with self._client as client: + if i > 0: + time.sleep(delay_secs) + for j, lg_command in enumerate(commands): + if j > 0: + time.sleep(delay_secs) + client.send_command(lg_command) + except LgNetCastError, RequestException: + self._attr_is_on = False + self.schedule_update_ha_state() + return + + def turn_on(self, **kwargs: Any) -> None: + """Turn on is handled via a separate turn_on trigger.""" + raise NotImplementedError( + "Turning on the TV is not supported by the LG Netcast remote entity" + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off the TV.""" + self.send_command(["POWER"], **{ATTR_NUM_REPEATS: 1}) diff --git a/homeassistant/components/lg_netcast/trigger.py b/homeassistant/components/lg_netcast/trigger.py index 8dfbe309e03..6a4dc8d393f 100644 --- a/homeassistant/components/lg_netcast/trigger.py +++ b/homeassistant/components/lg_netcast/trigger.py @@ -1,7 +1,5 @@ """LG Netcast TV trigger dispatcher.""" -from __future__ import annotations - from typing import cast from homeassistant.const import CONF_PLATFORM diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py index 250cba887c1..f32473c5281 100644 --- a/homeassistant/components/lg_soundbar/__init__.py +++ b/homeassistant/components/lg_soundbar/__init__.py @@ -19,7 +19,8 @@ async def async_setup_entry( ) -> bool: """Set up platform from a ConfigEntry.""" hass.data.setdefault(DOMAIN, {}) - # Verify the device is reachable with the given config before setting up the platform + # Verify the device is reachable with the given + # config before setting up the platform try: await hass.async_add_executor_job( test_connect, entry.data[CONF_HOST], entry.data[CONF_PORT] diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index f440e0ba4ad..75753eabfb8 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -1,7 +1,5 @@ """Support for LG soundbars.""" -from __future__ import annotations - from typing import Any import temescal @@ -41,7 +39,9 @@ class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" _attr_should_poll = False - _attr_state = MediaPlayerState.OFF + # Default to ON to ensure compatibility with models + # that don't send a powerstatus message + _attr_state = MediaPlayerState.ON _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE @@ -166,9 +166,11 @@ class LGDevice(MediaPlayerEntity): # Ask device for current play info when stream type changed. self._device.get_play() if data["i_stream_type"] == 0: - # If the stream type is 0 (aka the soundbar is used as an actual soundbar) - # the last track info should be cleared and the state should only be on or off, - # as all playing/paused are not applicable in this mode + # If the stream type is 0 (aka the soundbar + # is used as an actual soundbar) the last + # track info should be cleared and the + # state should only be on or off, as all + # playing/paused are not applicable self._attr_media_image_url = None self._attr_media_artist = None self._attr_media_title = None diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 25e0d4afb8b..ad0bfdbfc7a 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -1,7 +1,5 @@ """Support for LG ThinQ Connect device.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, field import logging diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index 61b600037a7..a60d8e4e8a5 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary sensor entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 4f84ef6fe2b..c77ab30fe63 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -1,7 +1,5 @@ """Support for climate entities.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/lg_thinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py index 3bbcf3cd226..471b296e695 100644 --- a/homeassistant/components/lg_thinq/config_flow.py +++ b/homeassistant/components/lg_thinq/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LG ThinQ.""" -from __future__ import annotations - import logging from typing import Any import uuid diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 0a51b856131..ebdea490279 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the LG ThinQ device.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import time import logging diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 3c41b3e8fac..0f7f1a35ee8 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -1,7 +1,5 @@ """Base class for ThinQ entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any @@ -46,7 +44,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=COMPANY, - model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})", + model=( + f"{coordinator.api.device.model_name}" + f" ({self.coordinator.api.device.device_type})" + ), name=coordinator.device_name, ) self._attr_unique_id = ( @@ -104,9 +105,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): except ThinQAPIException as exc: if on_fail_method: on_fail_method() - raise ServiceValidationError( - exc.message, translation_domain=DOMAIN, translation_key=exc.code - ) from exc + raise ServiceValidationError(exc.message) from exc except ValueError as exc: if on_fail_method: on_fail_method() diff --git a/homeassistant/components/lg_thinq/event.py b/homeassistant/components/lg_thinq/event.py index f9baadf7a05..d44c2e6056a 100644 --- a/homeassistant/components/lg_thinq/event.py +++ b/homeassistant/components/lg_thinq/event.py @@ -1,7 +1,5 @@ """Support for event entity.""" -from __future__ import annotations - import logging from thinqconnect import DeviceType diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index 7d20be68b01..c94d329022a 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -1,7 +1,5 @@ """Support for fan entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any @@ -20,6 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, ) from . import ThinqConfigEntry @@ -35,6 +35,11 @@ class ThinQFanEntityDescription(FanEntityDescription): preset_modes: list[str] | None = None +HOOD_FAN_DESC = FanEntityDescription( + key=ThinQProperty.FAN_SPEED, + translation_key=ThinQProperty.FAN_SPEED, +) + DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = { DeviceType.CEILING_FAN: ( ThinQFanEntityDescription( @@ -54,6 +59,8 @@ DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = { ), } +HOOD_DEVICE_TYPES: set[DeviceType] = {DeviceType.HOOD, DeviceType.MICROWAVE_OVEN} + ORDERED_NAMED_FAN_SPEEDS = ["low", "mid", "high", "turbo", "power"] _LOGGER = logging.getLogger(__name__) @@ -65,11 +72,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for fan platform.""" - entities: list[ThinQFanEntity] = [] + entities: list[ThinQFanEntity | ThinQHoodFanEntity] = [] for coordinator in entry.runtime_data.coordinators.values(): - if ( - descriptions := DEVICE_TYPE_FAN_MAP.get(coordinator.api.device.device_type) - ) is not None: + device_type = coordinator.api.device.device_type + + # Handle hood-type devices with numeric fan speed + if device_type in HOOD_DEVICE_TYPES: + entities.extend( + ThinQHoodFanEntity(coordinator, HOOD_FAN_DESC, property_id) + for property_id in coordinator.api.get_active_idx( + HOOD_FAN_DESC.key, ActiveMode.READ_WRITE + ) + ) + # Handle other fan devices with named speeds + elif (descriptions := DEVICE_TYPE_FAN_MAP.get(device_type)) is not None: for description in descriptions: entities.extend( ThinQFanEntity(coordinator, description, property_id) @@ -165,13 +181,9 @@ class ThinQFanEntity(ThinQEntity, FanEntity): if percentage == 0: await self.async_turn_off() return - try: - value = percentage_to_ordered_list_item( - self._ordered_named_fan_speeds, percentage - ) - except ValueError: - _LOGGER.exception("Failed to async_set_percentage") - return + value = percentage_to_ordered_list_item( + self._ordered_named_fan_speeds, percentage + ) _LOGGER.debug( "[%s:%s] async_set_percentage. percentage=%s, value=%s", @@ -212,3 +224,112 @@ class ThinQFanEntity(ThinQEntity, FanEntity): await self.async_call_api( self.coordinator.api.async_turn_off(self._operation_id) ) + + +class ThinQHoodFanEntity(ThinQEntity, FanEntity): + """Represent a thinq hood fan platform. + + Hood fans use numeric speed values (e.g., 0=off, 1=low, 2=high) + rather than named speed presets. + """ + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: FanEntityDescription, + property_id: str, + ) -> None: + """Initialize hood fan platform.""" + super().__init__(coordinator, entity_description, property_id) + + self._min_speed: int = int(self.data.min) + self._max_speed: int = int(self.data.max) + + # Speed count is the number of non-zero speeds + self._attr_speed_count = self._max_speed - self._min_speed + + @property + def _speed_range(self) -> tuple[int, int]: + """Return the speed range excluding off (0).""" + return (self._min_speed + 1, self._max_speed) + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + + # Get current speed value + current_speed = self.data.value + if current_speed is None or current_speed == self._min_speed: + self._attr_is_on = False + self._attr_percentage = 0 + else: + self._attr_is_on = True + self._attr_percentage = ranged_value_to_percentage( + self._speed_range, current_speed + ) + + _LOGGER.debug( + "[%s:%s] update status: is_on=%s, percentage=%s, speed=%s, min=%s, max=%s", + self.coordinator.device_name, + self.property_id, + self.is_on, + self.percentage, + current_speed, + self._min_speed, + self._max_speed, + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + speed = round(percentage_to_ranged_value(self._speed_range, percentage)) + + _LOGGER.debug( + "[%s:%s] async_set_percentage: percentage=%s -> speed=%s", + self.coordinator.device_name, + self.property_id, + percentage, + speed, + ) + await self.async_call_api(self.coordinator.api.post(self.property_id, speed)) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + await self.async_set_percentage(percentage) + return + + # Default to lowest non-zero speed + speed = self._min_speed + 1 + _LOGGER.debug( + "[%s:%s] async_turn_on: speed=%s", + self.coordinator.device_name, + self.property_id, + speed, + ) + await self.async_call_api(self.coordinator.api.post(self.property_id, speed)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + _LOGGER.debug( + "[%s:%s] async_turn_off", + self.coordinator.device_name, + self.property_id, + ) + await self.async_call_api( + self.coordinator.api.post(self.property_id, self._min_speed) + ) diff --git a/homeassistant/components/lg_thinq/humidifier.py b/homeassistant/components/lg_thinq/humidifier.py index 37c14c055b8..8f3fbb6a142 100644 --- a/homeassistant/components/lg_thinq/humidifier.py +++ b/homeassistant/components/lg_thinq/humidifier.py @@ -1,7 +1,5 @@ """Support for humidifier entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index fcc44ac10f6..631b61dbd79 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.11"] + "requirements": ["thinqconnect==1.0.12"] } diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 1eebf5fe863..539437dd14c 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -1,7 +1,5 @@ """Support for LG ThinQ Connect API.""" -from __future__ import annotations - import asyncio from datetime import datetime import json diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index ac8991d6bb5..bc2ea221f5c 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -1,30 +1,38 @@ """Support for number entities.""" -from __future__ import annotations - import logging from thinqconnect import DeviceType from thinqconnect.devices.const import Property as ThinQProperty from thinqconnect.integration import ActiveMode, TimerProperty +from homeassistant.components.automation import automations_with_entity from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import ThinqConfigEntry +from .const import DOMAIN from .entity import ThinQEntity NUMBER_DESC: dict[ThinQProperty, NumberEntityDescription] = { ThinQProperty.FAN_SPEED: NumberEntityDescription( key=ThinQProperty.FAN_SPEED, translation_key=ThinQProperty.FAN_SPEED, + entity_registry_enabled_default=False, ), ThinQProperty.LAMP_BRIGHTNESS: NumberEntityDescription( key=ThinQProperty.LAMP_BRIGHTNESS, @@ -128,9 +136,71 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = ), } +DEPRECATED_FAN_SPEED_DEVICE_TYPES: set[DeviceType] = { + DeviceType.HOOD, + DeviceType.MICROWAVE_OVEN, +} + _LOGGER = logging.getLogger(__name__) +def _check_deprecated_fan_speed_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + unique_id: str, +) -> bool: + """Check if a deprecated fan speed number entity should be created. + + Returns True if the entity exists and is enabled (should still be created). + """ + if not ( + entity_id := entity_registry.async_get_entity_id("number", DOMAIN, unique_id) + ): + return False + + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + return False + + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, f"deprecated_fan_speed_number_{entity_id}") + return False + + translation_key = "deprecated_fan_speed_number" + placeholders: dict[str, str] = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + } + + automation_entities = automations_with_entity(hass, entity_id) + script_entities = scripts_with_entity(hass, entity_id) + if automation_entities or script_entities: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join( + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automation_entities), + ("script", script_entities), + ) + for eid in entities + if (item := entity_registry.async_get(eid)) + ) + + async_create_issue( + hass, + DOMAIN, + f"deprecated_fan_speed_number_{entity_id}", + breaks_in_ha_version="2026.12.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + data={"entity_id": entity_id, **placeholders}, + ) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, @@ -138,18 +208,27 @@ async def async_setup_entry( ) -> None: """Set up an entry for number platform.""" entities: list[ThinQNumberEntity] = [] + entity_registry = er.async_get(hass) for coordinator in entry.runtime_data.coordinators.values(): - if ( - descriptions := DEVICE_TYPE_NUMBER_MAP.get( - coordinator.api.device.device_type - ) - ) is not None: - for description in descriptions: - entities.extend( + descriptions = DEVICE_TYPE_NUMBER_MAP.get(coordinator.api.device.device_type) + if descriptions is None: + continue + for description in descriptions: + for property_id in coordinator.api.get_active_idx( + description.key, ActiveMode.READ_WRITE + ): + if ( + description.key == ThinQProperty.FAN_SPEED + and coordinator.api.device.device_type + in DEPRECATED_FAN_SPEED_DEVICE_TYPES + ): + unique_id = f"{coordinator.unique_id}_{property_id}" + if not _check_deprecated_fan_speed_entity( + hass, entity_registry, unique_id + ): + continue + entities.append( ThinQNumberEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx( - description.key, ActiveMode.READ_WRITE - ) ) if entities: diff --git a/homeassistant/components/lg_thinq/repairs.py b/homeassistant/components/lg_thinq/repairs.py new file mode 100644 index 00000000000..c41c2dc051e --- /dev/null +++ b/homeassistant/components/lg_thinq/repairs.py @@ -0,0 +1,56 @@ +"""Repairs for LG ThinQ integration.""" + +import voluptuous as vol + +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +class DeprecatedFanSpeedRepairFlow(RepairsFlow): + """Handler for deprecated fan speed number entity fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entity_id = data["entity_id"] + self._placeholders = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + entity_registry = er.async_get(self.hass) + if entity_registry.async_get(self.entity_id): + entity_registry.async_update_entity( + self.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=self._placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str], +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("deprecated_fan_speed_number_"): + return DeprecatedFanSpeedRepairFlow(data) + return ConfirmRepairFlow() diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 80dcc4a40da..2efbaf95e3e 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -1,7 +1,5 @@ """Support for select entities.""" -from __future__ import annotations - import logging from thinqconnect import DeviceType diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 23eac6f3e76..80b1e930db4 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -1,7 +1,5 @@ """Support for sensor entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, time, timedelta @@ -520,10 +518,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], - SensorEntityDescription( - key=ThinQProperty.TARGET_TEMPERATURE, - translation_key=ThinQProperty.TARGET_TEMPERATURE, - ), ), DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],), DeviceType.OVEN: ( @@ -594,6 +588,17 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = } +ENUM_TEMPERATURE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { + DeviceType.KIMCHI_REFRIGERATOR: ( + SensorEntityDescription( + key=ThinQProperty.TARGET_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key=ThinQProperty.TARGET_TEMPERATURE, + ), + ), +} + + @dataclass(frozen=True, kw_only=True) class ThinQEnergySensorEntityDescription(SensorEntityDescription): """Describes ThinQ energy sensor entity.""" @@ -641,7 +646,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for sensor platform.""" - entities: list[ThinQSensorEntity | ThinQEnergySensorEntity] = [] + entities: list[ + ThinQSensorEntity | ThinQEnergySensorEntity | ThinQEnumTempSensorEntity + ] = [] for coordinator in entry.runtime_data.coordinators.values(): if ( descriptions := DEVICE_TYPE_SENSOR_MAP.get( @@ -663,6 +670,21 @@ async def async_setup_entry( ), ) ) + + if ( + descriptions := ENUM_TEMPERATURE_SENSOR_MAP.get( + coordinator.api.device.device_type + ) + ) is not None: + for description in descriptions: + entities.extend( + ThinQEnumTempSensorEntity(coordinator, description, property_id) + for property_id in coordinator.api.get_active_idx( + description.key, + ActiveMode.READ_ONLY, + ) + ) + for energy_description in ENERGY_USAGE_SENSORS: entities.extend( ThinQEnergySensorEntity( @@ -742,7 +764,9 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): if self.entity_description.key == TimerProperty.RUNNING else (local_now + event_data) ) - # The remain_time may change during the wash/dry operation depending on various reasons. + # The remain_time may change during the + # wash/dry operation depending on + # various reasons. # If there is a diff of more than 60sec, the new timestamp is used if ( parse_native_value := dt_util.parse_datetime( @@ -828,7 +852,8 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity): ) next_update = local_now + self.entity_description.update_interval if self.coordinator.update_energy_at_time_of_day is not None: - # calculate next_update time by combining tomorrow and update_energy_at_time_of_day + # calculate next_update time by combining tomorrow + # and update_energy_at_time_of_day next_update = datetime.combine( (next_update).date(), self.coordinator.update_energy_at_time_of_day, @@ -862,3 +887,40 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity): self.async_update, next_update, ) + + +class ThinQEnumTempSensorEntity(ThinQEntity, SensorEntity): + """Represent a thinq sensor platform.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: SensorEntityDescription, + property_id: str, + ) -> None: + """Initialize a sensor entity.""" + super().__init__(coordinator, entity_description, property_id) + + if self.data.options: + # some kimchi refrigerator's target temperature + # have data in the form of string with enum + # options. + # Set options to display the correct value in the UI. + self._attr_options = self.data.options + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_native_unit_of_measurement = None + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + self._attr_native_value = self.data.value + + _LOGGER.debug( + "[%s:%s] update status: %s -> %s, options:%s, unit:%s", + self.coordinator.device_name, + self.property_id, + self.data.value, + self.native_value, + self.options, + self.native_unit_of_measurement, + ) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 9d2d7df2863..3fff029fa73 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -199,6 +199,11 @@ } } }, + "fan": { + "fan_speed": { + "name": "Hood" + } + }, "humidifier": { "dehumidifier": { "state_attributes": { @@ -1154,5 +1159,29 @@ "failed_to_connect_mqtt": { "message": "Failed to connect MQTT: {error}" } + }, + "issues": { + "deprecated_fan_speed_number": { + "fix_flow": { + "step": { + "confirm": { + "description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nPlease update your dashboards and templates to use the new fan entity.\n\nAfter updating, click **Submit** to disable the number entity and fix this issue.", + "title": "Fan speed number entity deprecated" + } + } + }, + "title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]" + }, + "deprecated_fan_speed_number_scripts": { + "fix_flow": { + "step": { + "confirm": { + "description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nThe entity was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new fan entity.\n\nAfter updating, click **Submit** to disable the number entity and fix this issue.", + "title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]" + } + } + }, + "title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]" + } } } diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index 8ba680ca93d..a85074cae2c 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -1,7 +1,5 @@ """Support for switch entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 933af8734ec..2fe772abe45 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -1,7 +1,5 @@ """Support for vacuum entities.""" -from __future__ import annotations - from enum import StrEnum import logging from typing import Any diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py index 5a5c8d024b6..8921ab550ca 100644 --- a/homeassistant/components/lg_thinq/water_heater.py +++ b/homeassistant/components/lg_thinq/water_heater.py @@ -1,7 +1,5 @@ """Support for waterheater entities.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lg_tv_rs232/__init__.py b/homeassistant/components/lg_tv_rs232/__init__.py new file mode 100644 index 00000000000..5a5c79ad847 --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/__init__.py @@ -0,0 +1,52 @@ +"""The LG TV RS-232 integration.""" + +from lg_rs232_tv import LGTV, TVState + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_SET_ID, LOGGER, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool: + """Set up LG TV RS-232 from a config entry.""" + port = entry.data[CONF_DEVICE] + tv = LGTV(port, set_id=entry.data[CONF_SET_ID]) + + try: + await tv.connect() + await tv.query(QUERY_ATTRIBUTES) + except (ConnectionError, OSError, TimeoutError) as err: + if tv.connected: + await tv.disconnect() + raise ConfigEntryNotReady(f"Error connecting to LG TV: {err}") from err + + entry.runtime_data = tv + + @callback + def _on_disconnect(state: TVState | None) -> None: + # Only reload if the entry is still loaded. During entry removal, + # disconnect() fires this callback but the entry is already gone. + if state is None and entry.state is ConfigEntryState.LOADED: + LOGGER.warning("LG TV disconnected, reloading config entry") + hass.config_entries.async_schedule_reload(entry.entry_id) + + entry.async_on_unload(tv.subscribe(_on_disconnect)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + await entry.runtime_data.disconnect() + + return unload_ok diff --git a/homeassistant/components/lg_tv_rs232/config_flow.py b/homeassistant/components/lg_tv_rs232/config_flow.py new file mode 100644 index 00000000000..1f27690aa83 --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/config_flow.py @@ -0,0 +1,102 @@ +"""Config flow for the LG TV RS-232 integration.""" + +from typing import Any + +from lg_rs232_tv import DEFAULT_SET_ID, LGTV, TVNotRespondingError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SerialPortSelector, +) + +from .const import CONF_SET_ID, DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE): SerialPortSelector(), + vol.Required(CONF_SET_ID, default=DEFAULT_SET_ID): NumberSelector( + NumberSelectorConfig(min=1, max=99, mode=NumberSelectorMode.BOX) + ), + } +) + +# Outcome of _async_attempt_connect that means the serial port works but no LG +# TV answered it; this routes the user to the troubleshooting step. +RESULT_NO_TV = "no_tv" + + +async def _async_attempt_connect(port: str, set_id: int) -> str | None: + """Attempt to connect to the TV at the given port. + + Returns None on success, otherwise an outcome key: "cannot_connect" when + the serial port could not be opened, RESULT_NO_TV when the port works but + no LG TV responded to it, or "unknown" for an unexpected error. + """ + tv = LGTV(port, set_id=set_id) + + try: + await tv.connect() + except TVNotRespondingError: + # The port was opened but no LG TV responded to the power query. + return RESULT_NO_TV + except ValueError, ConnectionError, OSError, TimeoutError: + # The serial port itself could not be opened. + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + else: + await tv.disconnect() + return None + + +class LGTVRS232ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LG TV RS-232.""" + + VERSION = 1 + + _user_input: dict[str, Any] | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + port = user_input[CONF_DEVICE] + set_id = int(user_input[CONF_SET_ID]) + + self._async_abort_entries_match({CONF_DEVICE: port, CONF_SET_ID: set_id}) + error = await _async_attempt_connect(port, set_id) + if error is None: + return self.async_create_entry( + title="LG TV", + data={CONF_DEVICE: port, CONF_SET_ID: set_id}, + ) + if error == RESULT_NO_TV: + self._user_input = user_input + return await self.async_step_troubleshoot() + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or self._user_input or {} + ), + errors=errors, + ) + + async def async_step_troubleshoot( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Guide the user to enable RS-232 control after a failed connection.""" + if user_input is not None: + return await self.async_step_user() + + return self.async_show_form(step_id="troubleshoot") diff --git a/homeassistant/components/lg_tv_rs232/const.py b/homeassistant/components/lg_tv_rs232/const.py new file mode 100644 index 00000000000..5ffdccd8cbc --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/const.py @@ -0,0 +1,18 @@ +"""Constants for the LG TV RS-232 integration.""" + +import logging + +from lg_rs232_tv import LGTV + +from homeassistant.config_entries import ConfigEntry + +LOGGER = logging.getLogger(__package__) +DOMAIN = "lg_tv_rs232" + +CONF_SET_ID = "set_id" + +# TVState attributes the integration polls for; the TV is not asked for +# attributes the media player entity does not use. +QUERY_ATTRIBUTES = ("power", "input_source", "volume", "volume_mute", "balance") + +type LGTVRS232ConfigEntry = ConfigEntry[LGTV] diff --git a/homeassistant/components/lg_tv_rs232/manifest.json b/homeassistant/components/lg_tv_rs232/manifest.json new file mode 100644 index 00000000000..0cfa766f051 --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "lg_tv_rs232", + "name": "LG TV via Serial", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/lg_tv_rs232", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["lg_rs232_tv"], + "quality_scale": "silver", + "requirements": ["lg-rs232-tv==1.2.0"] +} diff --git a/homeassistant/components/lg_tv_rs232/media_player.py b/homeassistant/components/lg_tv_rs232/media_player.py new file mode 100644 index 00000000000..924f24c9f57 --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/media_player.py @@ -0,0 +1,186 @@ +"""Media player platform for the LG TV RS-232 integration.""" + +from collections.abc import Callable, Coroutine +from datetime import timedelta +from functools import wraps +from typing import Any + +from lg_rs232_tv import MAX_VOLUME, CommandRejected, InputSource, PowerState, TVState + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry + +# LG TVs do not push state over RS-232, so the entity is polled. +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 1 + +INPUT_SOURCE_LG_TO_HA: dict[InputSource, str] = { + InputSource.DTV_ANTENNA: "dtv_antenna", + InputSource.DTV_CABLE: "dtv_cable", + InputSource.ANALOG_ANTENNA: "analog_antenna", + InputSource.ANALOG_CABLE: "analog_cable", + InputSource.AV1: "av1", + InputSource.AV2: "av2", + InputSource.COMPONENT1: "component1", + InputSource.COMPONENT2: "component2", + InputSource.COMPONENT3: "component3", + InputSource.RGB_PC: "rgb_pc", + InputSource.HDMI1: "hdmi1", + InputSource.HDMI2: "hdmi2", + InputSource.HDMI3: "hdmi3", + InputSource.HDMI4: "hdmi4", +} +INPUT_SOURCE_HA_TO_LG: dict[str, InputSource] = { + value: key for key, value in INPUT_SOURCE_LG_TO_HA.items() +} + +_BASE_SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE +) + + +def catch_command_errors[**_P]( + func: Callable[_P, Coroutine[Any, Any, None]], +) -> Callable[_P, Coroutine[Any, Any, None]]: + """Translate LG library errors raised by an action into HomeAssistantError.""" + + @wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(*args, **kwargs) + except CommandRejected as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_rejected", + translation_placeholders={"error": str(err)}, + ) from err + except (ConnectionError, OSError, TimeoutError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"error": str(err)}, + ) from err + + return wrapper + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LGTVRS232ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LG TV RS-232 media player.""" + async_add_entities([LGTVRS232MediaPlayer(config_entry)]) + + +class LGTVRS232MediaPlayer(MediaPlayerEntity): + """Representation of an LG TV controlled over RS-232.""" + + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "tv" + _attr_source_list = sorted(INPUT_SOURCE_LG_TO_HA.values()) + + def __init__(self, config_entry: LGTVRS232ConfigEntry) -> None: + """Initialize the media player.""" + self._tv = config_entry.runtime_data + self._attr_unique_id = config_entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="LG", + ) + self._async_update_from_state(self._tv.state) + + async def async_added_to_hass(self) -> None: + """Subscribe to TV state updates.""" + self.async_on_remove(self._tv.subscribe(self._async_on_state_update)) + + async def async_update(self) -> None: + """Poll the TV for its current state.""" + await self._tv.query(QUERY_ATTRIBUTES) + + @callback + def _async_on_state_update(self, state: TVState | None) -> None: + """Handle a state update from the TV.""" + if state is None: + self._attr_available = False + else: + self._attr_available = True + self._async_update_from_state(state) + self.async_write_ha_state() + + @callback + def _async_update_from_state(self, state: TVState) -> None: + """Update entity attributes from a TV state snapshot.""" + if state.power is None: + self._attr_state = None + else: + self._attr_state = ( + MediaPlayerState.ON + if state.power is PowerState.ON + else MediaPlayerState.OFF + ) + + source = state.input_source + self._attr_source = INPUT_SOURCE_LG_TO_HA.get(source) if source else None + + # The TV only answers the balance query when its own speaker is the + # active audio output. When audio is routed elsewhere (e.g. optical), + # the TV's volume does not reflect what the user hears, so neither the + # volume controls nor the volume attributes are exposed. + features = _BASE_SUPPORTED_FEATURES + if state.balance is None: + self._attr_volume_level = None + self._attr_is_volume_muted = None + else: + features |= ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + self._attr_volume_level = ( + None if state.volume is None else state.volume / MAX_VOLUME + ) + self._attr_is_volume_muted = state.volume_mute + self._attr_supported_features = features + + @catch_command_errors + async def async_turn_on(self) -> None: + """Turn the TV on.""" + await self._tv.power_on() + + @catch_command_errors + async def async_turn_off(self) -> None: + """Turn the TV off.""" + await self._tv.power_off() + + @catch_command_errors + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self._tv.set_volume(round(volume * MAX_VOLUME)) + + @catch_command_errors + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute the TV.""" + if mute: + await self._tv.mute_on() + else: + await self._tv.mute_off() + + @catch_command_errors + async def async_select_source(self, source: str) -> None: + """Select an input source.""" + await self._tv.select_input_source(INPUT_SOURCE_HA_TO_LG[source]) diff --git a/homeassistant/components/lg_tv_rs232/quality_scale.yaml b/homeassistant/components/lg_tv_rs232/quality_scale.yaml new file mode 100644 index 00000000000..fa9b4db65fe --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: The integration has no options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Serial devices are configured manually; there is no discovery. + discovery: + status: exempt + comment: RS-232 serial connections cannot be discovered. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration does not create dynamic devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The integration only provides a single primary entity. + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: The media player entity uses its device class for its icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: The integration has no user-actionable issues to repair. + stale-devices: + status: exempt + comment: The integration does not create devices that can become stale. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/lg_tv_rs232/strings.json b/homeassistant/components/lg_tv_rs232/strings.json new file mode 100644 index 00000000000..2a2e030f8b0 --- /dev/null +++ b/homeassistant/components/lg_tv_rs232/strings.json @@ -0,0 +1,61 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "troubleshoot": { + "description": "Home Assistant could not communicate with the LG TV over the serial port.\n\nThe most common cause is that **RS-232C Control** is not enabled on the TV. On most LG models this setting is in a hidden service menu (often called **InStart**); the exact location varies by model, so check your TV's documentation.\n\nAlso make sure that:\n- The TV is powered on.\n- The serial cable is a null-modem (cross-over) cable and is fully seated. LG's RS-232 jack is recessed, so push the plug in until it clicks.\n- The correct serial port was selected.\n\nSelect **Submit** to try again.", + "title": "Connection failed" + }, + "user": { + "data": { + "device": "[%key:common::config_flow::data::port%]", + "set_id": "Set ID" + }, + "data_description": { + "device": "Serial port path to connect to. The TV must be powered on for the initial connection.", + "set_id": "The set ID configured on the TV. Leave this at 1 unless you have multiple TVs daisy-chained on the same RS-232 bus." + } + } + } + }, + "entity": { + "media_player": { + "tv": { + "state_attributes": { + "source": { + "state": { + "analog_antenna": "Analog (antenna)", + "analog_cable": "Analog (cable)", + "av1": "AV 1", + "av2": "AV 2", + "component1": "Component 1", + "component2": "Component 2", + "component3": "Component 3", + "dtv_antenna": "Digital TV (antenna)", + "dtv_cable": "Digital TV (cable)", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "rgb_pc": "RGB PC" + } + } + } + } + } + }, + "exceptions": { + "command_failed": { + "message": "Failed to send the command to the TV: {error}" + }, + "command_rejected": { + "message": "The TV rejected the command: {error}" + } + } +} diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py index 5f4b5035352..dae6a54a695 100644 --- a/homeassistant/components/libre_hardware_monitor/__init__.py +++ b/homeassistant/components/libre_hardware_monitor/__init__.py @@ -1,7 +1,5 @@ """The LibreHardwareMonitor integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/libre_hardware_monitor/config_flow.py b/homeassistant/components/libre_hardware_monitor/config_flow.py index 0568d8f9f01..6f12fa7563e 100644 --- a/homeassistant/components/libre_hardware_monitor/config_flow.py +++ b/homeassistant/components/libre_hardware_monitor/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LibreHardwareMonitor.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -86,7 +84,11 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "no_devices" else: return self.async_create_entry( - title=f"{computer_name} ({user_input[CONF_HOST]}:{user_input[CONF_PORT]})", + title=( + f"{computer_name}" + f" ({user_input[CONF_HOST]}" + f":{user_input[CONF_PORT]})" + ), data=user_input, ) @@ -134,7 +136,8 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN): entry=reauth_entry, # type: ignore[arg-type] data_updates=user_input, ) - # the initial connection was unauthorized, now we can create the config entry + # the initial connection was unauthorized, + # now we can create the config entry return self.async_create_entry( title=f"{computer_name} ({self._host}:{self._port})", data=data, diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index 7c24fb753c1..1561e9346d9 100644 --- a/homeassistant/components/libre_hardware_monitor/coordinator.py +++ b/homeassistant/components/libre_hardware_monitor/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for LibreHardwareMonitor integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -83,7 +81,8 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor except LibreHardwareMonitorNoDevicesError as err: raise UpdateFailed("No sensor data available, will retry") from err - # Check whether user has upgraded LHM from a deprecated version while the integration is running + # Check whether user has upgraded LHM from a deprecated + # version while the integration is running if self._is_deprecated_version and not lhm_data.is_deprecated_version: # Clear deprecation issue ir.async_delete_issue(self.hass, DOMAIN, f"deprecated_api_{self._entry_id}") @@ -102,7 +101,8 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor scheduled: bool = False, raise_on_entry_error: bool = False, ) -> None: - # we don't expect the computer to be online 24/7 so we don't want to log a connection loss as an error + # we don't expect the computer to be online 24/7 so + # we don't want to log a connection loss as an error await super()._async_refresh( False, raise_on_auth_failed, scheduled, raise_on_entry_error ) @@ -110,7 +110,7 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor async def _async_handle_changes_in_devices( self, detected_devices: dict[DeviceId, DeviceName] ) -> None: - """Handle device changes by deleting devices from / adding devices to Home Assistant.""" + """Handle device changes in the device registry.""" previous_device_ids = set(self._previous_devices.keys()) detected_device_ids = set(detected_devices.keys()) diff --git a/homeassistant/components/libre_hardware_monitor/diagnostics.py b/homeassistant/components/libre_hardware_monitor/diagnostics.py index 96bf2aaab78..c9cd4094146 100644 --- a/homeassistant/components/libre_hardware_monitor/diagnostics.py +++ b/homeassistant/components/libre_hardware_monitor/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Libre Hardware Monitor.""" -from __future__ import annotations - from dataclasses import asdict, replace from typing import Any diff --git a/homeassistant/components/libre_hardware_monitor/sensor.py b/homeassistant/components/libre_hardware_monitor/sensor.py index a48fb6d4de6..e7773214f84 100644 --- a/homeassistant/components/libre_hardware_monitor/sensor.py +++ b/homeassistant/components/libre_hardware_monitor/sensor.py @@ -1,7 +1,5 @@ """Support for LibreHardwareMonitor Sensor Platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lichess/__init__.py b/homeassistant/components/lichess/__init__.py index 2e76d6ed2b1..a3253d9b887 100644 --- a/homeassistant/components/lichess/__init__.py +++ b/homeassistant/components/lichess/__init__.py @@ -1,7 +1,5 @@ """The Lichess integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lichess/config_flow.py b/homeassistant/components/lichess/config_flow.py index 3cc71b389e2..a12348bf65b 100644 --- a/homeassistant/components/lichess/config_flow.py +++ b/homeassistant/components/lichess/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Lichess integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lichess/icons.json b/homeassistant/components/lichess/icons.json index 6ea1bda81d8..ea9693b9d18 100644 --- a/homeassistant/components/lichess/icons.json +++ b/homeassistant/components/lichess/icons.json @@ -1,6 +1,18 @@ { "entity": { "sensor": { + "antichess_games": { + "default": "mdi:chess-pawn" + }, + "antichess_rating": { + "default": "mdi:chart-line" + }, + "atomic_games": { + "default": "mdi:chess-pawn" + }, + "atomic_rating": { + "default": "mdi:chart-line" + }, "blitz_games": { "default": "mdi:chess-pawn" }, @@ -13,17 +25,71 @@ "bullet_rating": { "default": "mdi:chart-line" }, + "chess960_games": { + "default": "mdi:chess-pawn" + }, + "chess960_rating": { + "default": "mdi:chart-line" + }, "classical_games": { "default": "mdi:chess-pawn" }, "classical_rating": { "default": "mdi:chart-line" }, + "correspondence_games": { + "default": "mdi:chess-pawn" + }, + "correspondence_rating": { + "default": "mdi:chart-line" + }, + "crazyhouse_games": { + "default": "mdi:chess-pawn" + }, + "crazyhouse_rating": { + "default": "mdi:chart-line" + }, + "horde_games": { + "default": "mdi:chess-pawn" + }, + "horde_rating": { + "default": "mdi:chart-line" + }, + "king_of_the_hill_games": { + "default": "mdi:chess-pawn" + }, + "king_of_the_hill_rating": { + "default": "mdi:chart-line" + }, + "puzzle_games": { + "default": "mdi:puzzle" + }, + "puzzle_rating": { + "default": "mdi:chart-line" + }, + "racing_kings_games": { + "default": "mdi:chess-pawn" + }, + "racing_kings_rating": { + "default": "mdi:chart-line" + }, "rapid_games": { "default": "mdi:chess-pawn" }, "rapid_rating": { "default": "mdi:chart-line" + }, + "three_check_games": { + "default": "mdi:chess-pawn" + }, + "three_check_rating": { + "default": "mdi:chart-line" + }, + "ultra_bullet_games": { + "default": "mdi:chess-pawn" + }, + "ultra_bullet_rating": { + "default": "mdi:chart-line" } } } diff --git a/homeassistant/components/lichess/manifest.json b/homeassistant/components/lichess/manifest.json index a461e8b3a11..a002e3ce00f 100644 --- a/homeassistant/components/lichess/manifest.json +++ b/homeassistant/components/lichess/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["aiolichess==1.2.0"] + "requirements": ["aiolichess==1.3.0"] } diff --git a/homeassistant/components/lichess/sensor.py b/homeassistant/components/lichess/sensor.py index 8e57d2dd59a..44df7e298fe 100644 --- a/homeassistant/components/lichess/sensor.py +++ b/homeassistant/components/lichess/sensor.py @@ -79,6 +79,171 @@ SENSORS: tuple[LichessEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda state: state.classical_games, ), + LichessEntityDescription( + key="ultra_bullet_rating", + translation_key="ultra_bullet_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.ultra_bullet_rating, + ), + LichessEntityDescription( + key="ultra_bullet_games", + translation_key="ultra_bullet_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.ultra_bullet_games, + ), + LichessEntityDescription( + key="correspondence_rating", + translation_key="correspondence_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.correspondence_rating, + ), + LichessEntityDescription( + key="correspondence_games", + translation_key="correspondence_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.correspondence_games, + ), + LichessEntityDescription( + key="chess960_rating", + translation_key="chess960_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.chess960_rating, + ), + LichessEntityDescription( + key="chess960_games", + translation_key="chess960_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.chess960_games, + ), + LichessEntityDescription( + key="crazyhouse_rating", + translation_key="crazyhouse_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.crazyhouse_rating, + ), + LichessEntityDescription( + key="crazyhouse_games", + translation_key="crazyhouse_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.crazyhouse_games, + ), + LichessEntityDescription( + key="antichess_rating", + translation_key="antichess_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.antichess_rating, + ), + LichessEntityDescription( + key="antichess_games", + translation_key="antichess_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.antichess_games, + ), + LichessEntityDescription( + key="atomic_rating", + translation_key="atomic_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.atomic_rating, + ), + LichessEntityDescription( + key="atomic_games", + translation_key="atomic_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.atomic_games, + ), + LichessEntityDescription( + key="horde_rating", + translation_key="horde_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.horde_rating, + ), + LichessEntityDescription( + key="horde_games", + translation_key="horde_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.horde_games, + ), + LichessEntityDescription( + key="king_of_the_hill_rating", + translation_key="king_of_the_hill_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.king_of_the_hill_rating, + ), + LichessEntityDescription( + key="king_of_the_hill_games", + translation_key="king_of_the_hill_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.king_of_the_hill_games, + ), + LichessEntityDescription( + key="racing_kings_rating", + translation_key="racing_kings_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.racing_kings_rating, + ), + LichessEntityDescription( + key="racing_kings_games", + translation_key="racing_kings_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.racing_kings_games, + ), + LichessEntityDescription( + key="three_check_rating", + translation_key="three_check_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.three_check_rating, + ), + LichessEntityDescription( + key="three_check_games", + translation_key="three_check_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.three_check_games, + ), + LichessEntityDescription( + key="puzzle_rating", + translation_key="puzzle_rating", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda state: state.puzzle_rating, + ), + LichessEntityDescription( + key="puzzle_games", + translation_key="puzzle_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda state: state.puzzle_games, + ), ) diff --git a/homeassistant/components/lichess/strings.json b/homeassistant/components/lichess/strings.json index 024d41e61d0..73c30f364fe 100644 --- a/homeassistant/components/lichess/strings.json +++ b/homeassistant/components/lichess/strings.json @@ -21,6 +21,20 @@ }, "entity": { "sensor": { + "antichess_games": { + "name": "Antichess games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "antichess_rating": { + "name": "Antichess rating" + }, + "atomic_games": { + "name": "Atomic games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "atomic_rating": { + "name": "Atomic rating" + }, "blitz_games": { "name": "Blitz games", "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" @@ -35,6 +49,13 @@ "bullet_rating": { "name": "Bullet rating" }, + "chess960_games": { + "name": "Chess960 games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "chess960_rating": { + "name": "Chess960 rating" + }, "classical_games": { "name": "Classical games", "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" @@ -42,12 +63,68 @@ "classical_rating": { "name": "Classical rating" }, + "correspondence_games": { + "name": "Correspondence games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "correspondence_rating": { + "name": "Correspondence rating" + }, + "crazyhouse_games": { + "name": "Crazyhouse games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "crazyhouse_rating": { + "name": "Crazyhouse rating" + }, + "horde_games": { + "name": "Horde games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "horde_rating": { + "name": "Horde rating" + }, + "king_of_the_hill_games": { + "name": "King of the Hill games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "king_of_the_hill_rating": { + "name": "King of the Hill rating" + }, + "puzzle_games": { + "name": "Puzzle games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "puzzle_rating": { + "name": "Puzzle rating" + }, + "racing_kings_games": { + "name": "Racing Kings games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "racing_kings_rating": { + "name": "Racing Kings rating" + }, "rapid_games": { "name": "Rapid games", "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" }, "rapid_rating": { "name": "Rapid rating" + }, + "three_check_games": { + "name": "Three-check games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "three_check_rating": { + "name": "Three-check rating" + }, + "ultra_bullet_games": { + "name": "UltraBullet games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "ultra_bullet_rating": { + "name": "UltraBullet rating" } } } diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index e3a5cf250b2..46d15279a8d 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -1,7 +1,5 @@ """The Lidarr component.""" -from __future__ import annotations - from dataclasses import fields from aiopyarr.lidarr_client import LidarrClient diff --git a/homeassistant/components/lidarr/config_flow.py b/homeassistant/components/lidarr/config_flow.py index dda24c0a7e2..35ad46d1fe4 100644 --- a/homeassistant/components/lidarr/config_flow.py +++ b/homeassistant/components/lidarr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Lidarr.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py index 801d07fdc7d..72199db75ba 100644 --- a/homeassistant/components/lidarr/coordinator.py +++ b/homeassistant/components/lidarr/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Lidarr integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import timedelta @@ -35,7 +33,7 @@ T = TypeVar("T", bound=list[LidarrRootFolder] | LidarrQueue | str | LidarrAlbum type LidarrConfigEntry = ConfigEntry[LidarrData] -class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): +class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): # noqa: UP046 """Data update coordinator for the Lidarr integration.""" config_entry: LidarrConfigEntry diff --git a/homeassistant/components/lidarr/entity.py b/homeassistant/components/lidarr/entity.py index a707f7850fb..c2a340fcf83 100644 --- a/homeassistant/components/lidarr/entity.py +++ b/homeassistant/components/lidarr/entity.py @@ -1,7 +1,5 @@ """The Lidarr component.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 313804677b5..41becfab658 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -1,7 +1,5 @@ """Support for Lidarr.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from typing import Any, Generic @@ -49,7 +47,7 @@ def get_modified_description( @dataclasses.dataclass(frozen=True) -class LidarrSensorEntityDescriptionMixIn(Generic[T]): +class LidarrSensorEntityDescriptionMixIn(Generic[T]): # noqa: UP046 """Mixin for required keys.""" value_fn: Callable[[T, str], str | int] @@ -57,7 +55,9 @@ class LidarrSensorEntityDescriptionMixIn(Generic[T]): @dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescription( - SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] + SensorEntityDescription, + LidarrSensorEntityDescriptionMixIn[T], + Generic[T], # noqa: UP046 ): """Class to describe a Lidarr sensor.""" diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 79be6ec8175..1fa5231a8db 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -1,7 +1,5 @@ """The Liebherr integration.""" -from __future__ import annotations - import asyncio from datetime import datetime import logging @@ -46,8 +44,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> try: devices = await client.get_devices() except LiebherrAuthenticationError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed("Invalid API key") from err except LiebherrConnectionError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err # Create a coordinator for each device (may be empty if no devices) diff --git a/homeassistant/components/liebherr/config_flow.py b/homeassistant/components/liebherr/config_flow.py index 8aa1f562893..afb6fe7fb3c 100644 --- a/homeassistant/components/liebherr/config_flow.py +++ b/homeassistant/components/liebherr/config_flow.py @@ -1,12 +1,10 @@ """Config flow for the liebherr integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any -from pyliebherrhomeapi import LiebherrClient +from pyliebherrhomeapi import Device, LiebherrClient from pyliebherrhomeapi.exceptions import ( LiebherrAuthenticationError, LiebherrConnectionError, @@ -31,10 +29,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for liebherr.""" - async def _validate_api_key(self, api_key: str) -> tuple[list, dict[str, str]]: + async def _validate_api_key( + self, api_key: str + ) -> tuple[list[Device], dict[str, str]]: """Validate the API key and return devices and errors.""" errors: dict[str, str] = {} - devices: list = [] + devices: list[Device] = [] client = LiebherrClient( api_key=api_key, session=async_get_clientsession(self.hass), diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index 1364149f2c5..6dd746a21e6 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Liebherr integration.""" -from __future__ import annotations - from dataclasses import dataclass, field import logging @@ -60,8 +58,10 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: await self.client.get_device(self.device_id) except LiebherrAuthenticationError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed("Invalid API key") from err except LiebherrConnectionError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady( f"Failed to connect to device {self.device_id}: {err}" ) from err @@ -71,12 +71,15 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: return await self.client.get_device_state(self.device_id) except LiebherrAuthenticationError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed("API key is no longer valid") from err except LiebherrTimeoutError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( f"Timeout communicating with device {self.device_id}" ) from err except LiebherrConnectionError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( f"Error communicating with device {self.device_id}" ) from err diff --git a/homeassistant/components/liebherr/diagnostics.py b/homeassistant/components/liebherr/diagnostics.py index a86b52aac91..0533247058e 100644 --- a/homeassistant/components/liebherr/diagnostics.py +++ b/homeassistant/components/liebherr/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Liebherr.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/liebherr/entity.py b/homeassistant/components/liebherr/entity.py index eb343491dce..5a4fc8fcc0b 100644 --- a/homeassistant/components/liebherr/entity.py +++ b/homeassistant/components/liebherr/entity.py @@ -1,7 +1,5 @@ """Base entity for Liebherr integration.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine from typing import Any diff --git a/homeassistant/components/liebherr/icons.json b/homeassistant/components/liebherr/icons.json index 0aa3f37c7e2..f94cfab3a58 100644 --- a/homeassistant/components/liebherr/icons.json +++ b/homeassistant/components/liebherr/icons.json @@ -28,25 +28,25 @@ "ice_maker": { "default": "mdi:cube-outline", "state": { - "off": "mdi:cube-outline-off" + "off": "mdi:cube-off-outline" } }, "ice_maker_bottom_zone": { "default": "mdi:cube-outline", "state": { - "off": "mdi:cube-outline-off" + "off": "mdi:cube-off-outline" } }, "ice_maker_middle_zone": { "default": "mdi:cube-outline", "state": { - "off": "mdi:cube-outline-off" + "off": "mdi:cube-off-outline" } }, "ice_maker_top_zone": { "default": "mdi:cube-outline", "state": { - "off": "mdi:cube-outline-off" + "off": "mdi:cube-off-outline" } } }, diff --git a/homeassistant/components/liebherr/light.py b/homeassistant/components/liebherr/light.py index f952e04c7aa..88b80a63200 100644 --- a/homeassistant/components/liebherr/light.py +++ b/homeassistant/components/liebherr/light.py @@ -1,12 +1,13 @@ """Light platform for Liebherr integration.""" -from __future__ import annotations - import math from typing import TYPE_CHECKING, Any from pyliebherrhomeapi import PresentationLightControl -from pyliebherrhomeapi.const import CONTROL_PRESENTATION_LIGHT +from pyliebherrhomeapi.const import ( + CONTROL_PRESENTATION_LIGHT, + DEFAULT_PRESENTATION_LIGHT_MAX_BRIGHTNESS, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -17,8 +18,6 @@ from .const import DOMAIN from .coordinator import LiebherrConfigEntry, LiebherrCoordinator from .entity import LiebherrEntity -DEFAULT_MAX_BRIGHTNESS_LEVEL = 5 - PARALLEL_UPDATES = 1 @@ -108,7 +107,7 @@ class LiebherrPresentationLight(LiebherrEntity, LightEntity): control = self._light_control if TYPE_CHECKING: assert control is not None - max_level = control.max or DEFAULT_MAX_BRIGHTNESS_LEVEL + max_level = control.max or DEFAULT_PRESENTATION_LIGHT_MAX_BRIGHTNESS if ATTR_BRIGHTNESS in kwargs: target = max(1, round(kwargs[ATTR_BRIGHTNESS] * max_level / 255)) diff --git a/homeassistant/components/liebherr/manifest.json b/homeassistant/components/liebherr/manifest.json index 9130562f3d8..97ae7558bd5 100644 --- a/homeassistant/components/liebherr/manifest.json +++ b/homeassistant/components/liebherr/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyliebherrhomeapi"], - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["pyliebherrhomeapi==0.4.1"], "zeroconf": [ { diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index 46a44e23d08..aac2a629844 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -1,7 +1,5 @@ """Number platform for Liebherr integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 712bedd1c2a..5639ae68962 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -73,4 +73,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/liebherr/select.py b/homeassistant/components/liebherr/select.py index c637eb01a8f..c0572436fac 100644 --- a/homeassistant/components/liebherr/select.py +++ b/homeassistant/components/liebherr/select.py @@ -1,7 +1,5 @@ """Select platform for Liebherr integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/liebherr/sensor.py b/homeassistant/components/liebherr/sensor.py index 1f4fb09dc49..9123fcc122c 100644 --- a/homeassistant/components/liebherr/sensor.py +++ b/homeassistant/components/liebherr/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Liebherr integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py index aba8da3f418..e3505d4075c 100644 --- a/homeassistant/components/liebherr/switch.py +++ b/homeassistant/components/liebherr/switch.py @@ -1,7 +1,5 @@ """Switch platform for Liebherr integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 60c1ac753e6..8f87e4b853a 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -1,7 +1,5 @@ """Life360 integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index ea9f33d9f45..838919820be 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Life360 integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigFlow from . import DOMAIN diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 99a8adb0182..057a0e5fd51 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -1,7 +1,5 @@ """Support for LIFX.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from datetime import datetime, timedelta @@ -224,7 +222,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}" + f"Unexpected device found at {host};" + f" expected {entry.unique_id}, found {serial}" ) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 478a4d306e2..7d4a714632e 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor entities for LIFX integration.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 758d7ab6435..6cf1e7f0c91 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -1,7 +1,5 @@ """Button entity for LIFX devices..""" -from __future__ import annotations - from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index ee55a7589e2..d3c3e492141 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -1,7 +1,5 @@ """Config flow flow LIFX.""" -from __future__ import annotations - import socket from typing import Any, Self diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 2959f958aab..d5161f02429 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -1,7 +1,5 @@ """Const for LIFX.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index c96f53d8f77..bd8325a7a9a 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for lifx.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import timedelta @@ -280,7 +278,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): callb: Callable[[Message, dict[str, Any] | None], None], get_color_zones_args: dict[str, Any], ) -> None: - """Capture the callback and make sure resp_set_multizonemultizone is called before.""" + """Capture the callback for resp_set_multizone.""" def _wrapped_callback( bulb: Light, @@ -325,7 +323,8 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): ) ) if self.is_128zone_matrix: - # For 128-zone ceiling devices, we need another get64 request for the next set of zones + # For 128-zone ceiling devices, we need another + # get64 request for the next set of zones calls.append( partial( self.device.get64, @@ -382,7 +381,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if update_rssi: # We always send the rssi request second - self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5)) + self._rssi = floor(10 * log10(responses[1].signal) + 0.5) if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py index 64e7390b210..8e0bf59495d 100644 --- a/homeassistant/components/lifx/diagnostics.py +++ b/homeassistant/components/lifx/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for LIFX.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/lifx/discovery.py b/homeassistant/components/lifx/discovery.py index 81c2d44de87..d14dbce14c6 100644 --- a/homeassistant/components/lifx/discovery.py +++ b/homeassistant/components/lifx/discovery.py @@ -1,7 +1,5 @@ """The lifx integration discovery.""" -from __future__ import annotations - import asyncio from collections.abc import Collection, Iterable diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 279bcb86594..436af9e6365 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -1,7 +1,5 @@ """Support for LIFX lights.""" -from __future__ import annotations - from aiolifx import products from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 69f7580a054..ef0d3d346e0 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -1,7 +1,5 @@ """Support for LIFX lights.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta from typing import Any @@ -232,10 +230,7 @@ class LIFXLight(LIFXEntity, LightEntity): ) bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) - if ATTR_TRANSITION in kwargs: - fade = int(kwargs[ATTR_TRANSITION] * 1000) - else: - fade = 0 + fade = int(kwargs.get(ATTR_TRANSITION, 0) * 1000) if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: brightness = self.brightness if self.is_on and self.brightness else 0 @@ -312,12 +307,40 @@ class LIFXLight(LIFXEntity, LightEntity): duration: int = 0, ) -> None: """Send a color change to the bulb.""" - merged_hsbk = merge_hsbk(self.bulb.color, hsbk) try: - await self.coordinator.async_set_color(merged_hsbk, duration) + await self.transform(hsbk, kwargs=kwargs, duration=duration / 1000) except TimeoutError as ex: raise HomeAssistantError(f"Timeout setting color for {self.name}") from ex + async def transform( + self, + hsbk: list[float | int | None], + kwargs: dict[str, Any] | None = None, + duration: float = 0, + rapid: bool = False, + ) -> None: + """Transform the bulb using a waveform optional message.""" + set_hue = hsbk[HSBK_HUE] is not None + set_saturation = hsbk[HSBK_SATURATION] is not None + set_brightness = hsbk[HSBK_BRIGHTNESS] is not None + set_kelvin = hsbk[HSBK_KELVIN] is not None + color = merge_hsbk(self.bulb.color, hsbk) + + msg = { + "transient": False, + "color": color, + "cycles": 1, + "skew_ratio": 0, + "waveform": 0, + "period": round(duration * 1000), + "set_hue": set_hue, + "set_saturation": set_saturation, + "set_brightness": set_brightness, + "set_kelvin": set_kelvin, + } + + await self.coordinator.async_set_waveform_optional(msg, rapid) + async def get_color( self, ) -> None: @@ -402,16 +425,19 @@ class LIFXMultiZone(LIFXColor): SERVICE_EFFECT_STOP, ] - async def set_color( + async def transform( self, hsbk: list[float | int | None], - kwargs: dict[str, Any], - duration: int = 0, + kwargs: dict[str, Any] | None = None, + duration: float = 0, + rapid: bool = False, ) -> None: - """Send a color change to the bulb.""" + """Transform the bulb color, including per-zone updates.""" bulb = self.bulb color_zones = bulb.color_zones num_zones = self.coordinator.get_number_of_zones() + zone_kwargs = kwargs or {} + duration_ms = round(duration * 1000) # Zone brightness is not reported when powered off if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None: @@ -420,7 +446,7 @@ class LIFXMultiZone(LIFXColor): await self.update_color_zones() await self.set_power(False) - if (zones := kwargs.get(ATTR_ZONES)) is None: + if (zones := zone_kwargs.get(ATTR_ZONES)) is None: # Fast track: setting all zones to the same brightness and color # can be treated as a single-zone bulb. first_zone = color_zones[0] @@ -435,7 +461,9 @@ class LIFXMultiZone(LIFXColor): if ( all_zones_have_same_brightness or hsbk[HSBK_BRIGHTNESS] is not None ) and (all_zones_are_the_same or hsbk[HSBK_KELVIN] is not None): - await super().set_color(hsbk, kwargs, duration) + await super().transform( + hsbk, kwargs=zone_kwargs, duration=duration, rapid=rapid + ) return zones = list(range(num_zones)) @@ -448,7 +476,7 @@ class LIFXMultiZone(LIFXColor): apply = 1 if (index == len(zones) - 1) else 0 try: await self.coordinator.async_set_color_zones( - zone, zone, zone_hsbk, duration, apply + zone, zone, zone_hsbk, duration_ms, apply ) except TimeoutError as ex: raise HomeAssistantError( @@ -474,16 +502,21 @@ class LIFXMultiZone(LIFXColor): class LIFXExtendedMultiZone(LIFXMultiZone): """Representation of a LIFX device that supports extended multizone messages.""" - async def set_color( - self, hsbk: list[float | int | None], kwargs: dict[str, Any], duration: int = 0 + async def transform( + self, + hsbk: list[float | int | None], + kwargs: dict[str, Any] | None = None, + duration: float = 0, + rapid: bool = False, ) -> None: """Set colors on all zones of the device.""" + zone_kwargs = kwargs or {} # trigger an update of all zone values before merging new values await self.coordinator.async_get_extended_color_zones() color_zones = self.bulb.color_zones - if (zones := kwargs.get(ATTR_ZONES)) is None: + if (zones := zone_kwargs.get(ATTR_ZONES)) is None: # merge the incoming hsbk across all zones for index, zone in enumerate(color_zones): color_zones[index] = merge_hsbk(zone, hsbk) @@ -496,7 +529,7 @@ class LIFXExtendedMultiZone(LIFXMultiZone): # send the updated color zones list to the device try: await self.coordinator.async_set_extended_color_zones( - color_zones, duration=duration + color_zones, duration=round(duration * 1000) ) except TimeoutError as ex: raise HomeAssistantError( diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 0cb5e7f56c7..590e7479c48 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -1,7 +1,5 @@ """Support for LIFX lights.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import timedelta diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index b558d782707..de2c999ee06 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -53,8 +53,8 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.2.1", + "aiolifx==1.2.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==1.0.2" + "aiolifx-themes==1.0.4" ] } diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 1e8855e40db..60aded43fb9 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -1,7 +1,5 @@ """Migrate lifx devices to their own config entry.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 0913d7a1662..a755ab6970d 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -1,7 +1,5 @@ """Select sensor entities for LIFX integration.""" -from __future__ import annotations - from aiolifx_themes.themes import ThemeLibrary from homeassistant.components.select import SelectEntity, SelectEntityDescription diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 8a9877dc468..f095bfbbec5 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -1,7 +1,5 @@ """Sensors for LIFX lights.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.components.sensor import ( diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 4dc498a5ee4..9333f77497b 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -1,7 +1,5 @@ """Support for LIFX.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from functools import partial diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index b3c36b051f4..20f7db824c5 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -1,7 +1,5 @@ """Support for LIFX Cloud scenes.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging @@ -93,5 +91,6 @@ class LifxCloudScene(Scene): async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) + # pylint: disable-next=home-assistant-action-swallowed-exception except TimeoutError, aiohttp.ClientError: _LOGGER.exception("Error on %s", url) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index de1f9841a50..1016a7f0eaf 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with lights.""" -from __future__ import annotations - from collections.abc import Iterable import csv import dataclasses @@ -26,7 +24,6 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.loader import bind_hass from homeassistant.util import color as color_util from .const import ( # noqa: F401 @@ -223,7 +220,6 @@ LIGHT_TURN_OFF_SCHEMA: VolDictType = { _LOGGER = logging.getLogger(__name__) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the lights are on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) @@ -245,7 +241,7 @@ def preprocess_turn_on_alternatives( if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None: try: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name)) except ValueError: _LOGGER.warning("Got unknown color %s, falling back to white", color_name) params[ATTR_RGB_COLOR] = (255, 255, 255) diff --git a/homeassistant/components/light/condition.py b/homeassistant/components/light/condition.py index 57593bbc218..88c795db54a 100644 --- a/homeassistant/components/light/condition.py +++ b/homeassistant/components/light/condition.py @@ -26,7 +26,7 @@ class BrightnessCondition(EntityNumericalConditionBase): _valid_unit = "%" def _get_tracked_value(self, entity_state: State) -> Any: - """Get the brightness value converted from uint8 (0-255) to percentage (0-100).""" + """Get brightness converted from uint8 (0-255) to percentage.""" raw = super()._get_tracked_value(entity_state) if raw is None: return None diff --git a/homeassistant/components/light/conditions.yaml b/homeassistant/components/light/conditions.yaml index 229707d6c89..fdcb5f3650b 100644 --- a/homeassistant/components/light/conditions.yaml +++ b/homeassistant/components/light/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: .brightness_threshold_entity: &brightness_threshold_entity - domain: input_number @@ -34,6 +36,7 @@ is_brightness: target: *condition_light_target fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/light/const.py b/homeassistant/components/light/const.py index d27750a950d..a2544b6981c 100644 --- a/homeassistant/components/light/const.py +++ b/homeassistant/components/light/const.py @@ -1,7 +1,5 @@ """Provides constants for lights.""" -from __future__ import annotations - from datetime import timedelta from enum import IntFlag, StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 56bf7485e68..d71cbac2c4f 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for lights.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index 6dc702f8551..8a54a0bd22e 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -1,7 +1,5 @@ """Provides device conditions for lights.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index 1f6bfdbe6e9..2f0e2edfc70 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -1,7 +1,5 @@ """Provides device trigger for lights.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 250e1f5b2c1..67f51fc314f 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -1,7 +1,5 @@ """Intents for the light integration.""" -from __future__ import annotations - import logging import voluptuous as vol @@ -38,7 +36,11 @@ async def async_setup_intents(hass: HomeAssistant) -> None: ), "brightness": intent.IntentSlotInfo( service_data_name=ATTR_BRIGHTNESS_PCT, - description="The brightness percentage of the light between 0 and 100, where 0 is off and 100 is fully lit", + description=( + "The brightness percentage of the" + " light between 0 and 100, where 0" + " is off and 100 is fully lit" + ), value_schema=vol.All(vol.Coerce(int), vol.Range(0, 100)), ), }, diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 271fbcaa148..663c8d7f704 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Light state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable, Mapping import logging diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index aa83d47841c..0edff570fd9 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -227,7 +227,7 @@ turn_on: selector: state: attribute: effect - advanced_fields: + additional_fields: collapsed: true fields: rgbw_color: &rgbw_color @@ -298,7 +298,7 @@ turn_off: domain: light fields: transition: *transition - advanced_fields: + additional_fields: collapsed: true fields: flash: *flash @@ -313,7 +313,7 @@ toggle: color_temp_kelvin: *color_temp_kelvin brightness_pct: *brightness_pct effect: *effect - advanced_fields: + additional_fields: collapsed: true fields: rgbw_color: *rgbw_color diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 773b7a6b898..afe9255e882 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Light state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 69356bb4ad8..31cead6ae57 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", @@ -34,8 +35,9 @@ "field_white_name": "White", "field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.", "field_xy_color_name": "XY-color", - "section_advanced_fields_name": "Advanced options", + "section_additional_fields_name": "Additional options", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -45,6 +47,9 @@ "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::light::common::condition_threshold_name%]" } @@ -56,6 +61,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is off" @@ -65,6 +73,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is on" @@ -309,12 +320,6 @@ "yellowgreen": "Yellow green" } }, - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "flash": { "options": { "long": "Long", @@ -326,13 +331,6 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -398,8 +396,8 @@ }, "name": "Toggle light", "sections": { - "advanced_fields": { - "name": "[%key:component::light::common::section_advanced_fields_name%]" + "additional_fields": { + "name": "[%key:component::light::common::section_additional_fields_name%]" } } }, @@ -417,8 +415,8 @@ }, "name": "Turn off light", "sections": { - "advanced_fields": { - "name": "[%key:component::light::common::section_advanced_fields_name%]" + "additional_fields": { + "name": "[%key:component::light::common::section_additional_fields_name%]" } } }, @@ -492,8 +490,8 @@ }, "name": "Turn on light", "sections": { - "advanced_fields": { - "name": "[%key:component::light::common::section_advanced_fields_name%]" + "additional_fields": { + "name": "[%key:component::light::common::section_additional_fields_name%]" } } } @@ -515,6 +513,9 @@ "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::light::common::trigger_threshold_name%]" } @@ -526,6 +527,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" } }, "name": "Light turned off" @@ -535,6 +539,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" } }, "name": "Light turned on" diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml index eed93d0d536..69325df4853 100644 --- a/homeassistant/components/light/triggers.yaml +++ b/homeassistant/components/light/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .brightness_threshold_entity: &brightness_threshold_entity - domain: input_number @@ -46,6 +47,7 @@ brightness_crossed_threshold: target: *trigger_light_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index ef2a69c9f4f..443c28e0d0f 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -93,7 +93,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: lwlink.set_trv_proxy(proxy_ip, proxy_port) _LOGGER.warning( - "Proxy no longer required, remove `proxy_ip` from config to use builtin listener" + "Proxy no longer required, remove" + " `proxy_ip` from config to use" + " builtin listener" ) for platform in PLATFORMS: diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 136486f2492..b6aba480a64 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -1,7 +1,5 @@ """Support for LightwaveRF TRVs.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( @@ -63,7 +61,8 @@ class LightwaveTrv(ClimateEntity): self._lwlink = lwlink self._serial = serial self._attr_unique_id = f"{serial}-trv" - # inhibit is used to prevent race condition on update. If non zero, skip next update cycle. + # inhibit is used to prevent race condition on update. + # If non zero, skip next update cycle. self._inhibit = 0 def update(self) -> None: diff --git a/homeassistant/components/lightwave/light.py b/homeassistant/components/lightwave/light.py index fb007b321ab..9ab61f6a061 100644 --- a/homeassistant/components/lightwave/light.py +++ b/homeassistant/components/lightwave/light.py @@ -1,7 +1,5 @@ """Support for LightwaveRF lights.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity diff --git a/homeassistant/components/lightwave/sensor.py b/homeassistant/components/lightwave/sensor.py index 05dd04dd3cd..52c349f855e 100644 --- a/homeassistant/components/lightwave/sensor.py +++ b/homeassistant/components/lightwave/sensor.py @@ -1,7 +1,5 @@ """Support for LightwaveRF TRV - Associated Battery.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/lightwave/switch.py b/homeassistant/components/lightwave/switch.py index ca146ca881c..3ae05fc05ac 100644 --- a/homeassistant/components/lightwave/switch.py +++ b/homeassistant/components/lightwave/switch.py @@ -1,7 +1,5 @@ """Support for LightwaveRF switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 4e28f166269..dabeb906d06 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -1,7 +1,5 @@ """Support for LimitlessLED bulbs.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, Concatenate, cast diff --git a/homeassistant/components/linkplay/__init__.py b/homeassistant/components/linkplay/__init__.py index 98481feb9ff..12ed37dea89 100644 --- a/homeassistant/components/linkplay/__init__.py +++ b/homeassistant/components/linkplay/__init__.py @@ -1,4 +1,5 @@ """Support for LinkPlay devices.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from dataclasses import dataclass diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py index 8865cf00aa5..15b17d49099 100644 --- a/homeassistant/components/linkplay/button.py +++ b/homeassistant/components/linkplay/button.py @@ -1,7 +1,5 @@ """Support for LinkPlay buttons.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging diff --git a/homeassistant/components/linkplay/diagnostics.py b/homeassistant/components/linkplay/diagnostics.py index cfc1346aff4..143f6c56fd6 100644 --- a/homeassistant/components/linkplay/diagnostics.py +++ b/homeassistant/components/linkplay/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Linkplay.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 0bfb34af42c..f2e244dfa55 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity): ) self._attr_device_info = dr.DeviceInfo( - configuration_url=bridge.endpoint, + configuration_url=str(bridge.endpoint), connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 702aa0c7629..9cd498029cb 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -1,6 +1,5 @@ """Support for LinkPlay media players.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta import logging @@ -224,7 +223,8 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): return await media_source.async_browse_media( self.hass, media_content_id, - # This allows filtering content. In this case it will only show audio sources. + # This allows filtering content. In this case it + # will only show audio sources. content_filter=lambda item: item.media_content_type.startswith("audio/"), ) diff --git a/homeassistant/components/linkplay/select.py b/homeassistant/components/linkplay/select.py index d11b0540663..7ff68059216 100644 --- a/homeassistant/components/linkplay/select.py +++ b/homeassistant/components/linkplay/select.py @@ -1,7 +1,5 @@ """Support for LinkPlay select.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass import logging diff --git a/homeassistant/components/linkplay/services.py b/homeassistant/components/linkplay/services.py index bccb31148e4..9acafdd494e 100644 --- a/homeassistant/components/linkplay/services.py +++ b/homeassistant/components/linkplay/services.py @@ -1,7 +1,5 @@ """Support for LinkPlay media players.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 63d04a3afc4..9d880cb4c39 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -1,4 +1,5 @@ """Utilities for the LinkPlay component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from aiohttp import ClientSession from linkplay.utils import async_create_unverified_client_session diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index dd97a0dd033..7cf365d7464 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -1,7 +1,5 @@ """Support for Linksys Smart Wifi routers.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index 93bdef4a1f4..dcf606e5daa 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the state of Linode Nodes.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index 74d2099a844..934ec52b2cf 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -1,7 +1,5 @@ """Support for interacting with Linode nodes.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index e5f7370eb5f..2d3695ee42d 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -1,7 +1,5 @@ """Details about the built-in battery.""" -from __future__ import annotations - import logging import os from typing import Any @@ -14,7 +12,13 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_NAME, + ATTR_SERIAL_NUMBER, + CONF_NAME, + PERCENTAGE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,7 +34,6 @@ ATTR_CYCLE_COUNT = "cycle_count" ATTR_ENERGY_FULL = "energy_full" ATTR_ENERGY_FULL_DESIGN = "energy_full_design" ATTR_ENERGY_NOW = "energy_now" -ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL_NAME = "model_name" ATTR_POWER_NOW = "power_now" ATTR_STATUS = "status" diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 84667d6c94d..0c30aa4b73e 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -9,12 +9,14 @@ from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS + +type LiteJetConfigEntry = ConfigEntry[pylitejet.LiteJet] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> bool: """Set up LiteJet via a config entry.""" port = entry.data[CONF_PORT] @@ -38,19 +40,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) ) - hass.data[DOMAIN] = system + entry.runtime_data = system await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> bool: """Unload a LiteJet config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await hass.data[DOMAIN].close() - hass.data.pop(DOMAIN) + await entry.runtime_data.close() return unload_ok diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index aeae8f52144..723d79535fe 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -1,23 +1,17 @@ """Config flow for the LiteJet lighting system.""" -from __future__ import annotations - from typing import Any import pylitejet from serial import SerialException import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PORT from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from . import LiteJetConfigEntry from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -77,7 +71,7 @@ class LiteJetConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, ) -> LiteJetOptionsFlow: """Get the options flow for this handler.""" return LiteJetOptionsFlow() diff --git a/homeassistant/components/litejet/diagnostics.py b/homeassistant/components/litejet/diagnostics.py index 7a10f4d6754..e010d1ea13f 100644 --- a/homeassistant/components/litejet/diagnostics.py +++ b/homeassistant/components/litejet/diagnostics.py @@ -2,19 +2,16 @@ from typing import Any -from pylitejet import LiteJet - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import LiteJetConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LiteJetConfigEntry ) -> dict[str, Any]: """Return diagnostics for LiteJet config entry.""" - system: LiteJet = hass.data[DOMAIN] + system = entry.runtime_data return { "model": system.model_name, "loads": list(system.loads()), diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 95870927072..6d848648d46 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -1,7 +1,5 @@ """Support for LiteJet lights.""" -from __future__ import annotations - from typing import Any from pylitejet import LiteJet, LiteJetError @@ -13,12 +11,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import CONF_DEFAULT_TRANSITION, DOMAIN ATTR_NUMBER = "number" @@ -26,12 +24,12 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for index in system.loads(): @@ -52,7 +50,7 @@ class LiteJetLight(LightEntity): _attr_name = None def __init__( - self, config_entry: ConfigEntry, system: LiteJet, index: int, name: str + self, config_entry: LiteJetConfigEntry, system: LiteJet, index: int, name: str ) -> None: """Initialize a LiteJet light.""" self._config_entry = config_entry diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index dd96b5accb6..657c882e74d 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -6,12 +6,12 @@ from typing import Any from pylitejet import LiteJet, LiteJetError from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -21,12 +21,12 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for i in system.scenes(): diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 1b46ba360c3..e1468347e47 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -5,12 +5,12 @@ from typing import Any from pylitejet import LiteJet, LiteJetError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import DOMAIN ATTR_NUMBER = "number" @@ -18,12 +18,12 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for i in system.button_switches(): diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index a35bf6fb65e..786855fb655 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -1,12 +1,9 @@ """Trigger an automation when a LiteJet switch is released.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime from typing import cast -from pylitejet import LiteJet import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -109,7 +106,7 @@ async def async_attach_trigger( ): hass.add_job(call_action) - system: LiteJet = hass.data[DOMAIN] + system = hass.config_entries.async_loaded_entries(DOMAIN)[0].runtime_data system.on_switch_pressed(number, pressed) system.on_switch_released(number, released) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 1a9fda45c28..350c5f4fc06 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -1,7 +1,5 @@ """The Litter-Robot integration.""" -from __future__ import annotations - import itertools import logging diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 4dc64b08fec..587e01a291f 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -1,12 +1,10 @@ """Support for Litter-Robot binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from pylitterbot import LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,15 +23,19 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, Generic[_WhiskerEntityT] + BinarySensorEntityDescription, + Generic[_WhiskerEntityT], # noqa: UP046 ): """A class that describes robot binary sensor entities.""" is_on_fn: Callable[[_WhiskerEntityT], bool] -BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { - LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check +BINARY_SENSOR_MAP: dict[ + type[Robot] | tuple[type[Robot], ...], + tuple[RobotBinarySensorEntityDescription, ...], +] = { + LitterRobot: ( RobotBinarySensorEntityDescription[LitterRobot]( key="sleeping", translation_key="sleeping", @@ -58,14 +60,14 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . is_on_fn=lambda robot: not robot.is_hopper_removed, ), ), - Robot: ( # type: ignore[type-abstract] # only used for isinstance check - RobotBinarySensorEntityDescription[Robot]( + (FeederRobot, LitterRobot3, LitterRobot4): ( + RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4]( key="power_status", translation_key="power_status", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - is_on_fn=lambda robot: robot.power_status == "AC", + is_on_fn=lambda robot: robot.power_type == "AC", ), ), } diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index b1b44bc58a7..1675145766f 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -1,7 +1,5 @@ """Support for Litter-Robot button.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic @@ -20,7 +18,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]): +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]): # noqa: UP046 """A class that describes robot button entities.""" press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]] diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py index 98fe97e74b2..3de7afac67f 100644 --- a/homeassistant/components/litterrobot/config_flow.py +++ b/homeassistant/components/litterrobot/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Litter-Robot integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index 46005c34120..dd4c9695823 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -1,7 +1,5 @@ """The Litter-Robot coordinator.""" -from __future__ import annotations - from collections.abc import Generator from datetime import timedelta import logging diff --git a/homeassistant/components/litterrobot/diagnostics.py b/homeassistant/components/litterrobot/diagnostics.py index 4cdd8cb1a8c..5d41c627b9b 100644 --- a/homeassistant/components/litterrobot/diagnostics.py +++ b/homeassistant/components/litterrobot/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Litter-Robot.""" -from __future__ import annotations - from typing import Any from pylitterbot.utils import REDACT_FIELDS diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 34478da837a..8d28877c553 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -1,7 +1,5 @@ """Litter-Robot entities for common data and methods.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from typing import Any, Concatenate, Generic, TypeVar @@ -61,7 +59,8 @@ def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo: class LitterRobotEntity( - CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_WhiskerEntityT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], + Generic[_WhiskerEntityT], # noqa: UP046 ): """Generic Litter-Robot entity representing common data and methods.""" diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 2ed1e72704e..04440098585 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "platinum", - "requirements": ["pylitterbot==2025.2.0"] + "requirements": ["pylitterbot==2025.4.0"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index a32f353ae8d..83b83bb5c2f 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -1,7 +1,5 @@ """Support for Litter-Robot selects.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic, TypeVar @@ -24,7 +22,8 @@ _CastTypeT = TypeVar("_CastTypeT", int, float, str) @dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, Generic[_WhiskerEntityT, _CastTypeT] + SelectEntityDescription, + Generic[_WhiskerEntityT, _CastTypeT], # noqa: UP046 ): """A class that describes robot select entities.""" @@ -146,7 +145,7 @@ async def async_setup_entry( class LitterRobotSelectEntity( LitterRobotEntity[_WhiskerEntityT], SelectEntity, - Generic[_WhiskerEntityT, _CastTypeT], + Generic[_WhiskerEntityT, _CastTypeT], # noqa: UP046 ): """Litter-Robot Select.""" diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 51bfecfbf25..61f5115167f 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -1,7 +1,5 @@ """Support for Litter-Robot sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -38,7 +36,7 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str @dataclass(frozen=True, kw_only=True) -class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]): +class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]): # noqa: UP046 """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None diff --git a/homeassistant/components/litterrobot/services.py b/homeassistant/components/litterrobot/services.py index 2e6b2c8665c..2e39ddadb34 100644 --- a/homeassistant/components/litterrobot/services.py +++ b/homeassistant/components/litterrobot/services.py @@ -1,7 +1,5 @@ """Litter-Robot services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 02eb37864f8..ca4c7a0378c 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -1,7 +1,5 @@ """Support for Litter-Robot switches.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic @@ -20,7 +18,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): # noqa: UP046 """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index fa630625dcd..651a6ec0150 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -1,7 +1,5 @@ """Support for Litter-Robot time.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import datetime, time @@ -22,7 +20,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]): +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]): # noqa: UP046 """A class that describes robot time entities.""" value_fn: Callable[[_WhiskerEntityT], time | None] diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index b94034a0e44..f77459f2bbc 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -1,7 +1,5 @@ """Support for Litter-Robot updates.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index bfd98dddac6..d187cae2776 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -1,7 +1,5 @@ """Support for Litter-Robot "Vacuum".""" -from __future__ import annotations - from datetime import time from typing import Any diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index befbe6858ef..3b97fbf8f90 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -1,7 +1,5 @@ """The Livisi Smart Home integration.""" -from __future__ import annotations - from typing import Final from aiohttp import ClientConnectorError diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py index ea61e7741b8..25e602d3c06 100644 --- a/homeassistant/components/livisi/binary_sensor.py +++ b/homeassistant/components/livisi/binary_sensor.py @@ -1,7 +1,5 @@ """Code to handle a Livisi Binary Sensor.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 05539043d74..4a9abaf8811 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -1,7 +1,5 @@ """Code to handle a Livisi Virtual Climate Control.""" -from __future__ import annotations - from typing import Any from livisi.const import CAPABILITY_CONFIG diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py index ce14c0e44e9..fbd0787111a 100644 --- a/homeassistant/components/livisi/config_flow.py +++ b/homeassistant/components/livisi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Livisi Home Assistant.""" -from __future__ import annotations - from contextlib import suppress from typing import Any diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 1339ae7d68c..5e4a1df4851 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -1,7 +1,5 @@ """Code to manage fetching LIVISI data API.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 79af35c1f8c..48148376046 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -1,7 +1,5 @@ """Code to handle a Livisi switches.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -48,7 +46,8 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): # For livisi climate entities, the device should have the room name from # the livisi setup, as each livisi room gets exactly one VRCC device. The entity - # name will always be some localized value of "Climate", so the full element name + # name will always be some localized value of + # "Climate", so the full element name # in homeassistent will be in the form of "Bedroom Climate" if use_room_as_device_name and room_name is not None: self._attr_name = name diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index e053923f551..2fb9567f5a6 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -1,7 +1,5 @@ """Code to handle a Livisi switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/llamalab_automate/notify.py b/homeassistant/components/llamalab_automate/notify.py index 94693d3faa0..d6f930269cb 100644 --- a/homeassistant/components/llamalab_automate/notify.py +++ b/homeassistant/components/llamalab_automate/notify.py @@ -1,7 +1,5 @@ """LlamaLab Automate notification service.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index f95e27d31c2..23c0c6a62d1 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -1,7 +1,5 @@ """The Local Calendar integration.""" -from __future__ import annotations - from pathlib import Path from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 3b6d6070f5a..f17f586a067 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for a Local Calendar.""" -from __future__ import annotations - import asyncio from datetime import date, datetime, timedelta import logging @@ -197,6 +195,12 @@ def _parse_event(event: dict[str, Any]) -> Event: and value.tzinfo is not None ): event[key] = dt_util.as_local(value).replace(tzinfo=None) + # UNTIL in the rrule must be floating (timezone-naive) to match the + # floating dtstart used by the ical library. Strip tzinfo from UNTIL + # if present, converting to local time first. + if (rrule_obj := event.get(EVENT_RRULE)) and isinstance(rrule_obj, Recur): + if isinstance(rrule_obj.until, datetime) and rrule_obj.until.tzinfo is not None: + rrule_obj.until = dt_util.as_local(rrule_obj.until).replace(tzinfo=None) try: return Event(**event) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index f5b3220fb8c..c3d0b73a1fc 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Local Calendar integration.""" -from __future__ import annotations - import logging from pathlib import Path import shutil diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index f1d441af848..4499abe22c0 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==13.2.2"] + "requirements": ["ical==13.2.5"] } diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 4544f69dbee..7afbeb684e6 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -1,7 +1,5 @@ """Camera that loads a picture from a local file.""" -from __future__ import annotations - import logging import mimetypes @@ -69,6 +67,7 @@ class LocalFile(Camera): try: with open(self._file_path, "rb") as file: return file.read() + # pylint: disable-next=home-assistant-action-swallowed-exception except FileNotFoundError: _LOGGER.warning( "Could not read camera %s image from file: %s", diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py index 206e4c2a7c8..506abb04a3c 100644 --- a/homeassistant/components/local_file/config_flow.py +++ b/homeassistant/components/local_file/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Local file.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast @@ -44,6 +42,8 @@ DATA_SCHEMA_OPTIONS = vol.Schema( ) DATA_SCHEMA_SETUP = vol.Schema( { + # Approved exemption: user names the local file camera + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), } ).extend(DATA_SCHEMA_OPTIONS.schema) diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index d35b4e653c1..7c85b1780b4 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -22,6 +22,9 @@ "exceptions": { "file_path_not_accessible": { "message": "Path {file_path} is not accessible" + }, + "not_readable_path": { + "message": "Path {file_path} is not readable" } }, "options": { diff --git a/homeassistant/components/local_ip/config_flow.py b/homeassistant/components/local_ip/config_flow.py index 6bf9f865489..224bc504854 100644 --- a/homeassistant/components/local_ip/config_flow.py +++ b/homeassistant/components/local_ip/config_flow.py @@ -1,7 +1,5 @@ """Config flow for local_ip.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py index 4b8f02736bf..d4eaa0de3fc 100644 --- a/homeassistant/components/local_todo/__init__.py +++ b/homeassistant/components/local_todo/__init__.py @@ -1,7 +1,5 @@ """The Local To-do integration.""" -from __future__ import annotations - from pathlib import Path from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/local_todo/config_flow.py b/homeassistant/components/local_todo/config_flow.py index a79a62c647b..578aeaf1503 100644 --- a/homeassistant/components/local_todo/config_flow.py +++ b/homeassistant/components/local_todo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Local To-do integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 7bd47eb1d8c..5baa79cd85e 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==13.2.2"] + "requirements": ["ical==13.2.5"] } diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 4154f343f42..27256d5f16d 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -1,7 +1,5 @@ """Support for Locative.""" -from __future__ import annotations - from http import HTTPStatus import logging @@ -113,6 +111,8 @@ async def handle_webhook( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" if DOMAIN not in hass.data: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} webhook.async_register( hass, DOMAIN, "Locative", entry.data[CONF_WEBHOOK_ID], handle_webhook diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 9663efdd76e..7fc091f9124 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -1,4 +1,5 @@ """Support for the Locative platform.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index dcb2ed794e7..8cc089f869d 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,7 +1,5 @@ """Component to interface with locks that can be controlled remotely.""" -from __future__ import annotations - from datetime import timedelta from enum import IntFlag import functools as ft diff --git a/homeassistant/components/lock/conditions.yaml b/homeassistant/components/lock/conditions.yaml index 4bc0ef437a3..8952c15faa5 100644 --- a/homeassistant/components/lock/conditions.yaml +++ b/homeassistant/components/lock/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_jammed: *condition_common is_locked: *condition_common diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index a396849f049..f1f45bc7ed4 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Lock.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index c104abd82a4..d3b28d9c801 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -1,7 +1,5 @@ """Provides device automations for Lock.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 06e4e5b6431..34576a4ee1e 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Lock.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index 252528c9985..10e808f6dc7 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Lock state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/lock/significant_change.py b/homeassistant/components/lock/significant_change.py index 138f2393257..84ac6f51b46 100644 --- a/homeassistant/components/lock/significant_change.py +++ b/homeassistant/components/lock/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Lock state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index b53a2f92cf3..87d2928077e 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_jammed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is jammed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is locked" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is open" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is unlocked" @@ -92,21 +106,6 @@ "message": "The code for {entity_id} doesn't match pattern {code_format}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "lock": { "description": "Locks a lock.", @@ -146,6 +145,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock jammed" @@ -155,6 +157,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock locked" @@ -164,6 +169,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock opened" @@ -173,6 +181,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock unlocked" diff --git a/homeassistant/components/lock/triggers.yaml b/homeassistant/components/lock/triggers.yaml index 72b0fc5f476..aeaed51cab9 100644 --- a/homeassistant/components/lock/triggers.yaml +++ b/homeassistant/components/lock/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: jammed: *trigger_common locked: *trigger_common diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index de2ff570f0c..db828d1e834 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -1,7 +1,5 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any @@ -30,7 +28,6 @@ from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.event_type import EventType from . import rest_api, websocket_api @@ -62,7 +59,6 @@ LOG_MESSAGE_SCHEMA = vol.Schema( ) -@bind_hass def log_entry( hass: HomeAssistant, name: str, @@ -76,7 +72,6 @@ def log_entry( @callback -@bind_hass def async_log_entry( hass: HomeAssistant, name: str, diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index 282580bdc95..e550b4302d4 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -1,7 +1,5 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -11,9 +9,9 @@ from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY # Domains that are always continuous # # These are hard coded here to avoid importing -# the entire counter and proximity integrations +# the entire counter, image, and proximity integrations # to get the name of the domain. -ALWAYS_CONTINUOUS_DOMAINS = {"counter", "proximity"} +ALWAYS_CONTINUOUS_DOMAINS = {"counter", "image", "proximity"} # Domains that are continuous if there is a UOM set on the entity CONDITIONALLY_CONTINUOUS_DOMAINS = {SENSOR_DOMAIN} diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 238e6a0dda8..c5a80af2eff 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -1,8 +1,6 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - -from collections.abc import Callable, Mapping +from collections.abc import Callable, Collection, Mapping from typing import Any from homeassistant.components.sensor import ATTR_STATE_CLASS, NON_NUMERIC_DEVICE_CLASSES @@ -11,7 +9,9 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, ATTR_ENTITY_ID, + ATTR_SERVICE_DATA, ATTR_UNIT_OF_MEASUREMENT, + EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, ) @@ -73,12 +73,12 @@ def _async_config_entries_for_ids( def async_determine_event_types( hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None -) -> tuple[EventType[Any] | str, ...]: +) -> set[EventType[Any] | str]: """Reduce the event types based on the entity ids and device ids.""" logbook_config: LogbookConfig = hass.data[DOMAIN] external_events = logbook_config.external_events if not entity_ids and not device_ids: - return (*BUILT_IN_EVENTS, *external_events) + return {*BUILT_IN_EVENTS, *external_events} interested_domains: set[str] = set() for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids): @@ -91,23 +91,35 @@ def async_determine_event_types( # to add them since we have historically included # them when matching only on entities # - intrested_event_types: set[EventType[Any] | str] = { + interested_event_types: set[EventType[Any] | str] = { external_event for external_event, domain_call in external_events.items() if domain_call[0] in interested_domains } | AUTOMATION_EVENTS if entity_ids: # We also allow entity_ids to be recorded via manual logbook entries. - intrested_event_types.add(EVENT_LOGBOOK_ENTRY) + interested_event_types.add(EVENT_LOGBOOK_ENTRY) - return tuple(intrested_event_types) + return interested_event_types @callback -def extract_attr(source: Mapping[str, Any], attr: str) -> list[str]: - """Extract an attribute as a list or string.""" +def extract_attr( + event_type: EventType[Any] | str, source: Mapping[str, Any], attr: str +) -> list[str]: + """Extract an attribute as a list or string. + + For EVENT_CALL_SERVICE events, the entity_id is inside service_data, + not at the top level. Check service_data as a fallback. + """ if (value := source.get(attr)) is None: - return [] + # Early return to avoid unnecessary dict lookups for non-service events + if event_type != EVENT_CALL_SERVICE: + return [] + if service_data := source.get(ATTR_SERVICE_DATA): + value = service_data.get(attr) + if value is None: + return [] if isinstance(value, list): return value return str(value).split(",") @@ -135,7 +147,7 @@ def event_forwarder_filtered( def _forward_events_filtered_by_entities_filter(event: Event) -> None: assert entities_filter is not None event_data = event.data - entity_ids = extract_attr(event_data, ATTR_ENTITY_ID) + entity_ids = extract_attr(event.event_type, event_data, ATTR_ENTITY_ID) if entity_ids and not any( entities_filter(entity_id) for entity_id in entity_ids ): @@ -157,9 +169,12 @@ def event_forwarder_filtered( @callback def _forward_events_filtered_by_device_entity_ids(event: Event) -> None: event_data = event.data + event_type = event.event_type if entity_ids_set.intersection( - extract_attr(event_data, ATTR_ENTITY_ID) - ) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)): + extract_attr(event_type, event_data, ATTR_ENTITY_ID) + ) or device_ids_set.intersection( + extract_attr(event_type, event_data, ATTR_DEVICE_ID) + ): target(event) return _forward_events_filtered_by_device_entity_ids @@ -170,7 +185,7 @@ def async_subscribe_events( hass: HomeAssistant, subscriptions: list[CALLBACK_TYPE], target: Callable[[Event[Any]], None], - event_types: tuple[EventType[Any] | str, ...], + event_types: Collection[EventType[Any] | str], entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index f27a470a23d..d4578d6db87 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -1,7 +1,5 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final @@ -162,7 +160,10 @@ def async_event_to_row(event: Event) -> EventAsRow: # that are missing new_state or old_state # since the logbook does not show these new_state: State = event.data["new_state"] - context = new_state.context + # Use the event's context rather than the state's context because + # State.expire() replaces the context with a copy that loses + # origin_event, which is needed for context augmentation. + context = event.context return EventAsRow( row_id=hash(event), event_type=None, diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 1a139bb379e..217a27bd0b9 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -1,16 +1,16 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - -from collections.abc import Callable, Generator, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Collection, Generator, Sequence +from dataclasses import dataclass, field from datetime import datetime as dt import logging import time from typing import TYPE_CHECKING, Any +from lru import LRU from sqlalchemy.engine import Result from sqlalchemy.engine.row import Row +from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters @@ -37,6 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.event_type import EventType from .const import ( @@ -80,10 +81,18 @@ from .models import ( async_event_to_row, ) from .queries import statement_for_request -from .queries.common import PSEUDO_EVENT_STATE_CHANGED +from .queries.common import ( + PSEUDO_EVENT_STATE_CHANGED, + select_context_user_ids_for_context_ids, +) _LOGGER = logging.getLogger(__name__) +# Bound for the parent-context user-id cache — only needs to bridge the +# historical→live handoff, so the in-flight set is realistically ~tens with +# peak bursts of ~100. Ceiling bounds memory in pathological cases. +MAX_CONTEXT_USER_IDS_CACHE = 256 + @dataclass(slots=True) class LogbookRun: @@ -99,6 +108,14 @@ class LogbookRun: include_entity_name: bool timestamp: bool memoize_new_contexts: bool = True + # True when this run will switch to a live stream; gates population of + # context_user_ids (wasted work for one-shot REST/get_events callers). + for_live_stream: bool = False + # context_id -> user_id for parent context attribution; persisted across + # batches so child rows can inherit user_id from a parent seen earlier. + context_user_ids: LRU[bytes, bytes] = field( + default_factory=lambda: LRU(MAX_CONTEXT_USER_IDS_CACHE) + ) class EventProcessor: @@ -107,12 +124,13 @@ class EventProcessor: def __init__( self, hass: HomeAssistant, - event_types: tuple[EventType[Any] | str, ...], + event_types: Collection[EventType[Any] | str], entity_ids: list[str] | None = None, device_ids: list[str] | None = None, context_id: str | None = None, timestamp: bool = False, include_entity_name: bool = True, + for_live_stream: bool = False, ) -> None: """Init the event stream.""" assert not (context_id and (entity_ids or device_ids)), ( @@ -133,6 +151,7 @@ class EventProcessor: entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, timestamp=timestamp, + for_live_stream=for_live_stream, ) self.context_augmenter = ContextAugmenter(self.logbook_run) @@ -180,13 +199,67 @@ class EventProcessor: self.filters, self.context_id, ) - return self.humanify( - execute_stmt_lambda_element(session, stmt, orm_rows=False) + rows = execute_stmt_lambda_element(session, stmt, orm_rows=False) + query_parent_user_ids: dict[bytes, bytes] | None = None + if self.entity_ids or self.device_ids: + # Filtered queries exclude parent call_service rows for + # unrelated targets, so child contexts lose user attribution + # without a pre-pass. all_stmt already includes them. + rows = list(rows) + query_parent_user_ids = self._fetch_parent_user_ids( + session, rows, instance.max_bind_vars + ) + return self.humanify(rows, query_parent_user_ids) + + def _fetch_parent_user_ids( + self, + session: Session, + rows: list[Row], + max_bind_vars: int, + ) -> dict[bytes, bytes] | None: + """Resolve parent-context user_ids for rows in a filtered query. + + Done in Python rather than as a SQL union branch because the + context_parent_id_bin column is sparsely populated — scanning the + States table for non-null parents costs ~40% of the overall query + on real datasets. Here we collect only the parent ids we actually + need and fetch them via an indexed point-lookup on context_id_bin. + """ + cache = self.logbook_run.context_user_ids + pending: set[bytes] = { + parent_id + for row in rows + if (parent_id := row[CONTEXT_PARENT_ID_BIN_POS]) and parent_id not in cache + } + if not pending: + return None + query_parent_user_ids: dict[bytes, bytes] = {} + # The lambda statement unions events and states, so each id appears + # in two IN clauses — halve the chunk size to stay under the + # database's max bind variable count. + for pending_chunk in chunked_or_all(pending, max_bind_vars // 2): + # Schema allows NULL but the query's WHERE clauses exclude it; + # explicit checks satisfy the type checker. + query_parent_user_ids.update( + { + parent_id: user_id + for parent_id, user_id in execute_stmt_lambda_element( + session, + select_context_user_ids_for_context_ids(pending_chunk), + orm_rows=False, + ) + if parent_id is not None and user_id is not None + } ) + if self.logbook_run.for_live_stream: + cache.update(query_parent_user_ids) + return query_parent_user_ids def humanify( - self, rows: Generator[EventAsRow] | Sequence[Row] | Result - ) -> list[dict[str, str]]: + self, + rows: Generator[EventAsRow] | Sequence[Row] | Result, + query_parent_user_ids: dict[bytes, bytes] | None = None, + ) -> list[dict[str, Any]]: """Humanify rows.""" return list( _humanify( @@ -195,6 +268,7 @@ class EventProcessor: self.ent_reg, self.logbook_run, self.context_augmenter, + query_parent_user_ids, ) ) @@ -205,6 +279,7 @@ def _humanify( ent_reg: er.EntityRegistry, logbook_run: LogbookRun, context_augmenter: ContextAugmenter, + query_parent_user_ids: dict[bytes, bytes] | None, ) -> Generator[dict[str, Any]]: """Generate a converted list of events into entries.""" # Continuous sensors, will be excluded from the logbook @@ -220,11 +295,21 @@ def _humanify( context_id_bin: bytes data: dict[str, Any] + context_user_ids = logbook_run.context_user_ids + # Skip the LRU write on one-shot runs — the LogbookRun is discarded. + populate_context_user_ids = logbook_run.for_live_stream + # Process rows for row in rows: context_id_bin = row[CONTEXT_ID_BIN_POS] if memoize_new_contexts and context_id_bin not in context_lookup: context_lookup[context_id_bin] = row + if ( + populate_context_user_ids + and (context_user_id_bin := row[CONTEXT_USER_ID_BIN_POS]) + and context_id_bin not in context_user_ids + ): + context_user_ids[context_id_bin] = context_user_id_bin if row[CONTEXT_ONLY_POS]: continue event_type = row[EVENT_TYPE_POS] @@ -282,12 +367,16 @@ def _humanify( else: continue - time_fired_ts = row[TIME_FIRED_TS_POS] + row_time_fired_ts = row[TIME_FIRED_TS_POS] + # Explicit None check: 0.0 is a valid epoch. + time_fired_ts: float = ( + row_time_fired_ts if row_time_fired_ts is not None else time.time() + ) if timestamp: - when = time_fired_ts or time.time() + when: str | float = time_fired_ts else: when = process_timestamp_to_utc_isoformat( - dt_util.utc_from_timestamp(time_fired_ts) or dt_util.utcnow() + dt_util.utc_from_timestamp(time_fired_ts) ) data[LOGBOOK_ENTRY_WHEN] = when @@ -307,6 +396,28 @@ def _humanify( ): context_augmenter.augment(data, context_row) + # Fall back to the parent context for child contexts that inherit + # user attribution (e.g., generic_thermostat -> switch turn_on). + # Read from context_lookup directly instead of get_context() to + # avoid the origin_event fallback which would return the *child* + # row's origin event, not the parent's. + if CONTEXT_USER_ID not in data and ( + context_parent_id_bin := row[CONTEXT_PARENT_ID_BIN_POS] + ): + parent_user_id_bin: bytes | None = context_user_ids.get( + context_parent_id_bin + ) + if parent_user_id_bin is None and query_parent_user_ids is not None: + parent_user_id_bin = query_parent_user_ids.get(context_parent_id_bin) + if ( + parent_user_id_bin is None + and (parent_row := context_lookup.get(context_parent_id_bin)) + is not None + ): + parent_user_id_bin = parent_row[CONTEXT_USER_ID_BIN_POS] + if parent_user_id_bin: + data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(parent_user_id_bin) + yield data diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index c27da37742b..f3bd1c61436 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -1,7 +1,5 @@ """Queries for logbook.""" -from __future__ import annotations - from collections.abc import Collection from datetime import datetime as dt @@ -47,7 +45,8 @@ def statement_for_request( # object from the non-json ones to prevent # sqlalchemy from quoting them incorrectly - # entities and devices: logbook sends everything for the timeframe for the entities and devices + # entities and devices: logbook sends everything for + # the timeframe for the entities and devices if entity_ids and device_ids: return entities_devices_stmt( start_day, diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index cd596414583..1c545c7fabf 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -1,7 +1,5 @@ """All queries for logbook.""" -from __future__ import annotations - from sqlalchemy import lambda_stmt from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 8f9ab8a80cd..f696211c306 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -1,13 +1,13 @@ """Queries for logbook.""" -from __future__ import annotations - +from collections.abc import Collection from typing import Final import sqlalchemy -from sqlalchemy import select +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.sql.elements import BooleanClauseList, ColumnElement from sqlalchemy.sql.expression import literal +from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( @@ -122,6 +122,26 @@ def select_events_context_id_subquery( ) +def select_context_user_ids_for_context_ids( + context_ids: Collection[bytes], +) -> StatementLambdaElement: + """Select (context_id_bin, context_user_id_bin) for the given context ids. + + Union of events and states since a parent context can originate from + either table (e.g., a state set directly via the API). + """ + return lambda_stmt( + lambda: union_all( + select(Events.context_id_bin, Events.context_user_id_bin) + .where(Events.context_id_bin.in_(context_ids)) + .where(Events.context_user_id_bin.is_not(None)), + select(States.context_id_bin, States.context_user_id_bin) + .where(States.context_id_bin.in_(context_ids)) + .where(States.context_user_id_bin.is_not(None)), + ) + ) + + def select_events_context_only() -> Select: """Generate an events query that mark them as for context_only. diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 0e67ad23381..7c237d9ae9c 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -1,7 +1,5 @@ """Devices queries for logbook.""" -from __future__ import annotations - from collections.abc import Iterable import sqlalchemy diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 494c2965215..85250fcac1e 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -1,7 +1,5 @@ """Entities queries for logbook.""" -from __future__ import annotations - from collections.abc import Collection, Iterable import sqlalchemy @@ -62,7 +60,7 @@ def _apply_entities_context_union( states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: - """Generate a CTE to find the entity and device context ids and a query to find linked row.""" + """Generate a CTE to find entity/device context ids and linked rows.""" entities_cte: CTE = _select_entities_context_ids_sub_query( start_day, end_day, diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index bef34f0858b..82a7e81edc9 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -1,7 +1,5 @@ """Entities and Devices queries for logbook.""" -from __future__ import annotations - from collections.abc import Collection, Iterable from sqlalchemy import lambda_stmt, select, union_all @@ -41,7 +39,7 @@ def _select_entities_device_id_context_ids_sub_query( json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: - """Generate a subquery to find context ids for multiple entities and multiple devices.""" + """Generate a subquery to find context ids for entities and devices.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_type_ids).where( _apply_event_entity_id_device_id_matchers( diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index e4a8e64cecf..79ecc481702 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -1,7 +1,5 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta from http import HTTPStatus diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 4b767f66d69..820cbe71956 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -1,7 +1,5 @@ """Event parser and human readable log generator.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -14,6 +12,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import ActiveConnection, messages +from homeassistant.const import EVENT_CALL_SERVICE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes @@ -289,6 +288,8 @@ async def ws_event_stream( return event_types = async_determine_event_types(hass, entity_ids, device_ids) + # A past end_time makes this a one-shot fetch that never goes live. + will_go_live = not (end_time and end_time <= utc_now) event_processor = EventProcessor( hass, event_types, @@ -297,6 +298,7 @@ async def ws_event_stream( None, timestamp=True, include_entity_name=False, + for_live_stream=will_go_live, ) if end_time and end_time <= utc_now: @@ -357,11 +359,15 @@ async def ws_event_stream( logbook_config: LogbookConfig = hass.data[DOMAIN] entities_filter = logbook_config.entity_filter + # Live subscription needs call_service events so the live consumer can + # cache parent user_ids as they fire. Historical queries don't — the + # context_only join fetches them by context_id regardless of type. + # Unfiltered streams already include it via BUILT_IN_EVENTS. async_subscribe_events( hass, subscriptions, _queue_or_cancel, - event_types, + {*event_types, EVENT_CALL_SERVICE}, entities_filter, entity_ids, device_ids, diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 8593b3c478e..a453aa9d7ff 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -1,7 +1,5 @@ """Support for setting the level of logging for components.""" -from __future__ import annotations - import logging import re @@ -10,6 +8,7 @@ import voluptuous as vol from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from . import websocket_api @@ -74,7 +73,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for key, value in log_filters.items(): _add_log_filter(logging.getLogger(key), value) - # Combine log levels configured in configuration.yaml with log levels set by frontend + # Combine log levels configured in configuration.yaml + # with log levels set by frontend combined_logs = await settings.async_get_levels(hass) set_log_levels(hass, combined_logs) @@ -86,14 +86,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: set_log_levels(hass, service.data) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_DEFAULT_LEVEL, async_service_handler, schema=SERVICE_SET_DEFAULT_LEVEL_SCHEMA, ) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_SET_LEVEL, async_service_handler, diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index ec06701f5b3..aa2b78e663c 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -1,7 +1,5 @@ """Helpers for the logger integration.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Mapping import contextlib diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 660bdf4c599..d20dc5cd680 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -67,6 +67,7 @@ def handle_integration_log_info( vol.Required("persistence"): vol.Coerce(LogPersistance), } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_integration_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] @@ -99,6 +100,7 @@ async def handle_integration_log_level( vol.Required("persistence"): vol.Coerce(LogPersistance), } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/lojack/__init__.py b/homeassistant/components/lojack/__init__.py index 4c691306c9a..f0a807cc946 100644 --- a/homeassistant/components/lojack/__init__.py +++ b/homeassistant/components/lojack/__init__.py @@ -1,7 +1,5 @@ """The LoJack integration for Home Assistant.""" -from __future__ import annotations - from dataclasses import dataclass, field from lojack_api import ApiError, AuthenticationError, LoJackClient, Vehicle diff --git a/homeassistant/components/lojack/config_flow.py b/homeassistant/components/lojack/config_flow.py index 5fdc2fefb62..115b2a1944c 100644 --- a/homeassistant/components/lojack/config_flow.py +++ b/homeassistant/components/lojack/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LoJack integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lojack/const.py b/homeassistant/components/lojack/const.py index 4c395a43c25..99063e754f4 100644 --- a/homeassistant/components/lojack/const.py +++ b/homeassistant/components/lojack/const.py @@ -1,7 +1,5 @@ """Constants for the LoJack integration.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/lojack/coordinator.py b/homeassistant/components/lojack/coordinator.py index ee764542961..749feb792ad 100644 --- a/homeassistant/components/lojack/coordinator.py +++ b/homeassistant/components/lojack/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the LoJack integration.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/lojack/device_tracker.py b/homeassistant/components/lojack/device_tracker.py index 4b2539b9ecb..ac93f7fcf7b 100644 --- a/homeassistant/components/lojack/device_tracker.py +++ b/homeassistant/components/lojack/device_tracker.py @@ -1,8 +1,6 @@ """Device tracker platform for LoJack integration.""" -from __future__ import annotations - -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -49,11 +47,6 @@ class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity): serial_number=self.coordinator.vehicle.vin, ) - @property - def source_type(self) -> SourceType: - """Return the source type of the device.""" - return SourceType.GPS - @property def latitude(self) -> float | None: """Return the latitude of the device.""" diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 3560e9b3321..3ee6e8f52c9 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -1,7 +1,5 @@ """Sensor for checking the status of London air.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/london_underground/__init__.py b/homeassistant/components/london_underground/__init__.py index c9910ee8461..8234c385a54 100644 --- a/homeassistant/components/london_underground/__init__.py +++ b/homeassistant/components/london_underground/__init__.py @@ -1,7 +1,5 @@ """The london_underground component.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession diff --git a/homeassistant/components/london_underground/config_flow.py b/homeassistant/components/london_underground/config_flow.py index baca9b91c32..4d584d8f33f 100644 --- a/homeassistant/components/london_underground/config_flow.py +++ b/homeassistant/components/london_underground/config_flow.py @@ -1,7 +1,5 @@ """Config flow for London Underground integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -91,7 +89,9 @@ class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN): await data.update() except Exception: _LOGGER.exception( - "Unexpected error trying to connect before importing config, aborting import " + "Unexpected error trying to connect" + " before importing config," + " aborting import " ) return self.async_abort(reason="cannot_connect") @@ -103,7 +103,9 @@ class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN): lines = import_data.get(CONF_LINE, DEFAULT_LINES) if "London Overground" in lines: _LOGGER.warning( - "London Overground was removed from the configuration as the line has been divided and renamed" + "London Overground was removed from the" + " configuration as the line has been" + " divided and renamed" ) lines.remove("London Overground") return self.async_create_entry( diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index a80150e6313..9ee836c187f 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for London underground integration.""" -from __future__ import annotations - import asyncio import logging from typing import cast diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index c9df10b470c..dcef04e5c92 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,7 +1,5 @@ """Sensor for checking the status of London Underground tube lines.""" -from __future__ import annotations - import logging from typing import Any @@ -42,7 +40,8 @@ async def async_setup_platform( """Set up the Tube sensor.""" # If configuration.yaml config exists, trigger the import flow. - # If the config entry already exists, this will not be triggered as only one config is allowed. + # If the config entry already exists, this will not be + # triggered as only one config is allowed. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 1814f95d5a1..bd5950b46be 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,7 +1,5 @@ """The lookin integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import logging @@ -125,7 +123,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo push_coordinator, name=entry.title, update_method=lookin_protocol.get_meteo_sensor, - update_interval=METEO_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) + # Updates are pushed (fallback is polling) + update_interval=METEO_UPDATE_INTERVAL, ) await meteo_coordinator.async_config_entry_first_refresh() @@ -144,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bo push_coordinator, name=f"{entry.title} {uuid}", update_method=updater, - update_interval=REMOTE_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) + # Updates are pushed (fallback is polling) + update_interval=REMOTE_UPDATE_INTERVAL, ) await coordinator.async_config_entry_first_refresh() device_coordinators[uuid] = coordinator diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index cc9634ac1b6..423b23b685b 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -1,7 +1,5 @@ """The lookin integration climate platform.""" -from __future__ import annotations - import logging from typing import Any, Final, cast diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index 6aafc89d0b0..3d4e7bd1535 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -1,7 +1,5 @@ """The lookin integration config_flow.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py index d4624932ad9..6fe35f5fb3f 100644 --- a/homeassistant/components/lookin/const.py +++ b/homeassistant/components/lookin/const.py @@ -1,7 +1,5 @@ """The lookin integration constants.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index fd3f73120a2..3decde2424b 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for lookin devices.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from datetime import timedelta import logging @@ -72,7 +70,7 @@ class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @callback def async_set_updated_data(self, data: _DataT) -> None: - """Manually update data, notify listeners and reset refresh interval, and remember.""" + """Manually update data, notify listeners and reset refresh interval.""" self.push_coordinator.update() super().async_set_updated_data(data) diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index fd36301ddb6..99748d7de3a 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -1,7 +1,5 @@ """The lookin integration entity.""" -from __future__ import annotations - from abc import abstractmethod import logging diff --git a/homeassistant/components/lookin/light.py b/homeassistant/components/lookin/light.py index 6e467871428..47c3f6a56f1 100644 --- a/homeassistant/components/lookin/light.py +++ b/homeassistant/components/lookin/light.py @@ -1,7 +1,5 @@ """The lookin integration light platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index 16b69971370..515f65b2ed1 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -1,7 +1,5 @@ """The lookin integration light platform.""" -from __future__ import annotations - import logging from aiolookin import Remote diff --git a/homeassistant/components/lookin/models.py b/homeassistant/components/lookin/models.py index 622efb834c0..3e0774b6444 100644 --- a/homeassistant/components/lookin/models.py +++ b/homeassistant/components/lookin/models.py @@ -1,7 +1,5 @@ """The lookin integration models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py index e53ff135b2f..2c3ed35f4e3 100644 --- a/homeassistant/components/lookin/sensor.py +++ b/homeassistant/components/lookin/sensor.py @@ -1,7 +1,5 @@ """The lookin integration sensor platform.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 94bcd2ec332..5027317bec8 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,7 +1,5 @@ """The loqed integration.""" -from __future__ import annotations - import re import aiohttp diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index a3879d0412f..48f1d146be8 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -1,7 +1,5 @@ """Config flow for loqed integration.""" -from __future__ import annotations - import logging import re from typing import Any @@ -112,6 +110,8 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): if self._host else vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME): str, vol.Required(CONF_API_TOKEN): str, } diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index af7667197a1..316011b0368 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -4,13 +4,14 @@ import asyncio import logging from typing import TypedDict +import aiohttp from aiohttp.web import Request from loqedAPI import loqed from homeassistant.components import cloud, webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_CLOUDHOOK_URL, DOMAIN @@ -119,6 +120,12 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): self.hass, DOMAIN, "Loqed", webhook_id, self._handle_webhook ) + @callback + def _async_unregister_webhook() -> None: + webhook.async_unregister(self.hass, webhook_id) + + self.config_entry.async_on_unload(_async_unregister_webhook) + if cloud.async_active_subscription(self.hass): webhook_url = await async_cloudhook_generate_url( self.hass, self.config_entry @@ -152,20 +159,22 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): else: webhook_url = webhook.async_generate_url(self.hass, webhook_id) - webhook.async_unregister( - self.hass, - webhook_id, - ) _LOGGER.debug("Webhook URL: %s", webhook_url) - webhooks = await self.lock.getWebhooks() + try: + webhooks = await self.lock.getWebhooks() - webhook_index = next( - (x["id"] for x in webhooks if x["url"] == webhook_url), None - ) + webhook_index = next( + (x["id"] for x in webhooks if x["url"] == webhook_url), None + ) - if webhook_index: - await self.lock.deleteWebhook(webhook_index) + if webhook_index: + await self.lock.deleteWebhook(webhook_index) + except (TimeoutError, aiohttp.ClientError) as err: + _LOGGER.warning( + "Could not remove webhook from LOQED bridge; the bridge may be offline. Continuing to unload the entry anyway: %s", + err, + ) async def async_cloudhook_generate_url( diff --git a/homeassistant/components/loqed/entity.py b/homeassistant/components/loqed/entity.py index 9a443e23924..1899c161fbf 100644 --- a/homeassistant/components/loqed/entity.py +++ b/homeassistant/components/loqed/entity.py @@ -1,7 +1,5 @@ """Base entity for the LOQED integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index be44d3ef09f..87b7b45629d 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -1,7 +1,5 @@ """LOQED lock integration for Home Assistant.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 1513d1a6869..05e4f6e87d5 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -128,8 +128,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: conf = await async_hass_config_yaml(hass) except HomeAssistantError as err: - _LOGGER.error(err) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_reload", + ) from err integration = await async_get_integration(hass, DOMAIN) @@ -338,10 +340,7 @@ async def create_yaml_resource_col( @callback def _async_ensure_default_panel(hass: HomeAssistant) -> None: """Ensure a default lovelace panel is registered for backward compatibility.""" - if ( - frontend.DATA_PANELS not in hass.data - or DOMAIN not in hass.data[frontend.DATA_PANELS] - ): + if not frontend.async_panel_exists(hass, DOMAIN): frontend.async_register_built_in_panel(hass, DOMAIN) @@ -396,7 +395,8 @@ async def _async_migrate_default_config( 3. Creates a new dashboard entry with url_path "lovelace" 4. Handles storage files: a. If .storage/lovelace.lovelace does not exist, copies data and removes old file - b. If .storage/lovelace.lovelace already exists, renames old file to lovelace_old as backup + b. If .storage/lovelace.lovelace already exists, + renames old file to lovelace_old as backup 5. Sets the default panel to "lovelace" if not already configured """ # 1. Skip if already migrated (dashboard with url_path "lovelace" exists) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index a0e6185b06f..1066a60e8c6 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -1,14 +1,12 @@ """Home Assistant Cast platform.""" -from __future__ import annotations - from typing import Any from pychromecast import Chromecast from pychromecast.const import CAST_TYPE_CHROMECAST from homeassistant.components.cast import DOMAIN as CAST_DOMAIN -from homeassistant.components.cast.home_assistant_cast import ( # pylint: disable=hass-component-root-import +from homeassistant.components.cast.home_assistant_cast import ( # pylint: disable=home-assistant-component-root-import ATTR_URL_PATH, ATTR_VIEW_PATH, NO_URL_AVAILABLE_ERROR, diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 1102aef02a8..b79946b57b4 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -1,7 +1,5 @@ """Constants for Lovelace.""" -from __future__ import annotations - from typing import TYPE_CHECKING import voluptuous as vol diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 0eea15cf2e2..66fa62bfcdc 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -1,7 +1,5 @@ """Lovelace dashboard support.""" -from __future__ import annotations - from abc import ABC, abstractmethod import logging import os @@ -12,7 +10,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.frontend import DATA_PANELS +from homeassistant.components.frontend import async_panel_exists from homeassistant.const import CONF_FILENAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -286,7 +284,7 @@ class DashboardsCollection(collection.DictStorageCollection): if not allow_single_word and "-" not in url_path: raise vol.Invalid("Url path needs to contain a hyphen (-)") - if DATA_PANELS in self.hass.data and url_path in self.hass.data[DATA_PANELS]: + if async_panel_exists(self.hass, url_path): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="url_already_exists", diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 96f84ccbc60..ad58f210edb 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -1,7 +1,5 @@ """Lovelace resources support.""" -from __future__ import annotations - import logging from typing import Any import uuid @@ -62,14 +60,32 @@ class ResourceStorageCollection(collection.DictStorageCollection): ) self.ll_config = ll_config - async def async_get_info(self) -> dict[str, int]: - """Return the resources info for YAML mode.""" + async def _async_ensure_loaded(self) -> None: + """Ensure the collection has been loaded from storage.""" if not self.loaded: await self.async_load() self.loaded = True + async def async_get_info(self) -> dict[str, int]: + """Return the resources info for YAML mode.""" + await self._async_ensure_loaded() return {"resources": len(self.async_items() or [])} + async def async_create_item(self, data: dict) -> dict: + """Create a new item.""" + await self._async_ensure_loaded() + return await super().async_create_item(data) + + async def async_update_item(self, item_id: str, updates: dict) -> dict: + """Update item.""" + await self._async_ensure_loaded() + return await super().async_update_item(item_id, updates) + + async def async_delete_item(self, item_id: str) -> None: + """Delete item.""" + await self._async_ensure_loaded() + await super().async_delete_item(item_id) + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (store_data := await self.store.async_load()) is not None: @@ -118,10 +134,6 @@ class ResourceStorageCollection(collection.DictStorageCollection): async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - if not self.loaded: - await self.async_load() - self.loaded = True - update_data = self.UPDATE_SCHEMA(update_data) if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) diff --git a/homeassistant/components/lovelace/strings.json b/homeassistant/components/lovelace/strings.json index 2f0fa4ccbf1..23ce614e795 100644 --- a/homeassistant/components/lovelace/strings.json +++ b/homeassistant/components/lovelace/strings.json @@ -1,5 +1,8 @@ { "exceptions": { + "failed_to_reload": { + "message": "Failed to reload dashboard resources. Please check your configuration and try again." + }, "url_already_exists": { "message": "The URL \"{url}\" is already in use. Please choose a different one." } diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index f8eb7772c78..d1137e173bf 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -1,7 +1,5 @@ """Websocket API for Lovelace.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from functools import wraps from typing import TYPE_CHECKING, Any @@ -45,7 +43,8 @@ def _handle_errors[_R]( ) -> None: url_path = msg.get(CONF_URL_PATH) - # When url_path is None, prefer "lovelace" dashboard if it exists (for YAML mode) + # When url_path is None, prefer "lovelace" dashboard + # if it exists (for YAML mode) # Otherwise fall back to dashboards[None] (storage mode default) if url_path is None: config = hass.data[LOVELACE_DATA].dashboards.get(DOMAIN) or hass.data[ diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index a2e9a809acb..c397bde15e7 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -1,7 +1,5 @@ """Support for OpenWRT (luci) routers.""" -from __future__ import annotations - import logging from openwrt_luci_rpc import OpenWrtRpc diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index bb1c80b5a58..88a69706ec9 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -4,8 +4,6 @@ Sensor.Community was previously called Luftdaten, hence the domain differs from the integration name. """ -from __future__ import annotations - from luftdaten import Luftdaten from homeassistant.const import Platform diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index 1ee444d5c84..37faa2a4c36 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Sensor.Community integration.""" -from __future__ import annotations - from typing import Any from luftdaten import Luftdaten diff --git a/homeassistant/components/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py index 2c311bb6409..4df8e20ffc9 100644 --- a/homeassistant/components/luftdaten/coordinator.py +++ b/homeassistant/components/luftdaten/coordinator.py @@ -4,12 +4,10 @@ Sensor.Community was previously called Luftdaten, hence the domain differs from the integration name. """ -from __future__ import annotations - import logging from luftdaten import Luftdaten -from luftdaten.exceptions import LuftdatenError +from luftdaten.exceptions import LuftdatenConnectionError, LuftdatenError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -47,11 +45,22 @@ class LuftdatenDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float | int """Update sensor/binary sensor data.""" try: await self._sensor_community.get_data() + except LuftdatenConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err except LuftdatenError as err: - raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err if not self._sensor_community.values: - raise UpdateFailed("Did not receive sensor data from Sensor.Community") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_data_received", + ) data: dict[str, float | int] = self._sensor_community.values data.update(self._sensor_community.meta) diff --git a/homeassistant/components/luftdaten/diagnostics.py b/homeassistant/components/luftdaten/diagnostics.py index 3affde44387..d66ed1bc1d1 100644 --- a/homeassistant/components/luftdaten/diagnostics.py +++ b/homeassistant/components/luftdaten/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Sensor.Community.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 07500f2e10c..67c400a01dd 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -1,7 +1,5 @@ """Support for Sensor.Community sensors.""" -from __future__ import annotations - from typing import cast from homeassistant.components.sensor import ( @@ -27,6 +25,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator +PARALLEL_UPDATES = 0 + SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index 412d665e0dd..d40f74e32e7 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -21,5 +21,16 @@ "sensor": { "pressure_at_sealevel": { "name": "Pressure at sea level" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Sensor.Community service." + }, + "no_data_received": { + "message": "Did not receive sensor data from the Sensor.Community service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Sensor.Community service." + } } } diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index c650fca92ec..5a2f2867475 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -3,7 +3,7 @@ import logging from typing import Final -from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info +from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info, Sensors from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant @@ -18,10 +18,11 @@ from .coordinator import ( LunatoneData, LunatoneDevicesDataUpdateCoordinator, LunatoneInfoDataUpdateCoordinator, + LunatoneSensorsDataUpdateCoordinator, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] +PLATFORMS: Final[list[Platform]] = [Platform.LIGHT, Platform.SENSOR] async def _update_unique_id( @@ -69,7 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) info_api = Info(auth_api) - devices_api = Devices(auth_api) + devices_api = Devices(info_api) + sensors_api = Sensors(auth_api) coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() @@ -105,6 +107,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api) await coordinator_devices.async_config_entry_first_refresh() + coordinator_sensors = LunatoneSensorsDataUpdateCoordinator(hass, entry, sensors_api) + await coordinator_sensors.async_config_entry_first_refresh() + dali_line_broadcasts = [ DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines ] @@ -112,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> entry.runtime_data = LunatoneData( coordinator_info, coordinator_devices, + coordinator_sensors, dali_line_broadcasts, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index bb48361299e..2c802fe21c9 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -5,15 +5,17 @@ from typing import Any, Final import aiohttp from lunatone_rest_api_client import Auth, Info import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,13 +30,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self._data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input is not None: - url = user_input[CONF_URL] + url = URL(user_input[CONF_URL]).human_repr()[:-1] data = {CONF_URL: url} self._async_abort_entries_match(data) auth_api = Auth( @@ -64,13 +70,63 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=url, data=data) return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors=errors, + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by zeroconf discovery.""" + url = URL.build(scheme="http", host=discovery_info.host).human_repr()[:-1] + uid = discovery_info.properties["uid"] + await self.async_set_unique_id(uid.replace("-", "")) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + + auth_api = Auth( + session=async_get_clientsession(self.hass), + base_url=url, + ) + info_api = Info(auth_api) + + try: + await info_api.async_update() + except aiohttp.InvalidUrlClientError: + return self.async_abort(reason="invalid_url") + except aiohttp.ClientConnectionError: + return self.async_abort(reason="cannot_connect") + + self._data[CONF_URL] = url + + self.context["title_placeholders"] = { + "model": discovery_info.properties["device"], + "name": discovery_info.name.rsplit(" ", maxsplit=1)[0], + } + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the discovered device.""" + if user_input is not None: + return self.async_create_entry(title=self._data[CONF_URL], data=self._data) + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders=self._data, ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" - return await self.async_step_user(user_input) + if user_input is not None: + return await self.async_step_user(user_input) + + entry = self._get_reconfigure_entry() + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + {vol.Required(CONF_URL, default=entry.data[CONF_URL]): cv.string}, + ), + description_placeholders={CONF_NAME: entry.title}, + ) diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index 6f2c310ac7b..7ddc5e562a0 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -1,13 +1,18 @@ """Coordinator for handling data fetching and updates.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging import aiohttp -from lunatone_rest_api_client import DALIBroadcast, Device, Devices, Info +from lunatone_rest_api_client import ( + DALIBroadcast, + Device, + Devices, + Info, + Sensor, + Sensors, +) from lunatone_rest_api_client.models import InfoData from homeassistant.config_entries import ConfigEntry @@ -20,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10) +DEFAULT_SENSORS_SCAN_INTERVAL = timedelta(seconds=30) @dataclass @@ -28,6 +34,7 @@ class LunatoneData: coordinator_info: LunatoneInfoDataUpdateCoordinator coordinator_devices: LunatoneDevicesDataUpdateCoordinator + coordinator_sensors: LunatoneSensorsDataUpdateCoordinator dali_line_broadcasts: list[DALIBroadcast] @@ -100,5 +107,40 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic if self.devices_api.data is None: raise UpdateFailed("Did not receive devices data from Lunatone REST API") - return {device.id: device for device in self.devices_api.devices} + + +class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Sensor]]): + """Data update coordinator for Lunatone sensors.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + sensors_api: Sensors, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-sensors", + always_update=False, + update_interval=DEFAULT_SENSORS_SCAN_INTERVAL, + ) + self.sensors_api = sensors_api + + async def _async_update_data(self) -> dict[int, Sensor]: + """Update sensor data.""" + try: + await self.sensors_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve sensors data from Lunatone REST API" + ) from ex + + if self.sensors_api.data is None: + raise UpdateFailed("Did not receive sensors data from Lunatone REST API") + return {sensor.id: sensor for sensor in self.sensors_api.sensors} diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index 72243ae713a..d79c808714f 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -1,8 +1,5 @@ """Platform for Lunatone light integration.""" -from __future__ import annotations - -import asyncio from typing import Any from lunatone_rest_api_client import DALIBroadcast @@ -10,6 +7,9 @@ from lunatone_rest_api_client.models import LineStatus from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ColorMode, LightEntity, brightness_supported, @@ -28,7 +28,6 @@ from .coordinator import ( ) PARALLEL_UPDATES = 0 -STATUS_UPDATE_DELAY = 0.04 async def async_setup_entry( @@ -74,6 +73,8 @@ class LunatoneLight( _attr_has_entity_name = True _attr_name = None _attr_should_poll = False + _attr_min_color_temp_kelvin = 1000 + _attr_max_color_temp_kelvin = 10000 def __init__( self, @@ -123,7 +124,13 @@ class LunatoneLight( @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self._device is not None and self._device.brightness is not None: + if self._device.rgbw_color is not None: + return ColorMode.RGBW + if self._device.rgb_color is not None: + return ColorMode.RGB + if self._device.color_temperature is not None: + return ColorMode.COLOR_TEMP + if self._device.brightness is not None: return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -132,6 +139,32 @@ class LunatoneLight( """Return the supported color modes.""" return {self.color_mode} + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temp of this light in kelvin.""" + return self._device.color_temperature + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of this light.""" + rgb_color = self._device.rgb_color + return rgb_color and ( + round(rgb_color[0] * 255), + round(rgb_color[1] * 255), + round(rgb_color[2] * 255), + ) + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the RGBW color of this light.""" + rgbw_color = self._device.rgbw_color + return rgbw_color and ( + round(rgbw_color[0] * 255), + round(rgbw_color[1] * 255), + round(rgbw_color[2] * 255), + round(rgbw_color[3] * 255), + ) + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -141,16 +174,26 @@ class LunatoneLight( async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if brightness_supported(self.supported_color_modes): - await self._device.fade_to_brightness( - brightness_to_value( - self.BRIGHTNESS_SCALE, - kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self._device.fade_to_color_temperature( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) + if ATTR_RGB_COLOR in kwargs: + await self._device.fade_to_rgbw_color( + tuple(color / 255 for color in kwargs[ATTR_RGB_COLOR]) + ) + if ATTR_RGBW_COLOR in kwargs: + rgbw_color = tuple(color / 255 for color in kwargs[ATTR_RGBW_COLOR]) + await self._device.fade_to_rgbw_color(rgbw_color[:-1], rgbw_color[-1]) + if ATTR_BRIGHTNESS in kwargs or not self.is_on: + await self._device.fade_to_brightness( + brightness_to_value( + self.BRIGHTNESS_SCALE, + kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + ) ) - ) else: await self._device.switch_on() - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: @@ -161,8 +204,6 @@ class LunatoneLight( await self._device.fade_to_brightness(0) else: await self._device.switch_off() - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() @@ -175,6 +216,8 @@ class LunatoneLineBroadcastLight( _attr_assumed_state = True _attr_color_mode = ColorMode.BRIGHTNESS + _attr_has_entity_name = True + _attr_name = None _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__( @@ -221,13 +264,9 @@ class LunatoneLineBroadcastLight( await self._broadcast.fade_to_brightness( brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255)) ) - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self._coordinator_devices.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the line to turn off.""" await self._broadcast.fade_to_brightness(0) - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self._coordinator_devices.async_refresh() diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 33ca0382fbb..8f6ee96b727 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,15 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.7.0"] + "requirements": ["lunatone-rest-api-client==0.9.1"], + "zeroconf": [ + { + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*" + }, + "type": "_http._tcp.local." + } + ] } diff --git a/homeassistant/components/lunatone/sensor.py b/homeassistant/components/lunatone/sensor.py new file mode 100644 index 00000000000..20932fe0f79 --- /dev/null +++ b/homeassistant/components/lunatone/sensor.py @@ -0,0 +1,157 @@ +"""Platform for Lunatone sensor integration.""" + +from typing import Final + +from lunatone_rest_api_client import Sensor +from lunatone_rest_api_client.models import SensorAddressType, SensorType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LunatoneConfigEntry, LunatoneSensorsDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { + SensorType.AIR_HUMIDITY: SensorEntityDescription( + key="air_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.AIR_PRESSURE: SensorEntityDescription( + key="air_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.AIR_QUALITY: SensorEntityDescription( + key="air_quality", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.ECO2: SensorEntityDescription( + key="eco2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.LIGHT: SensorEntityDescription( + key="light", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.TEMPERATURE: SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorType.VOC: SensorEntityDescription( + key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Lunatone sensors from the config entry.""" + coordinator_sensors = config_entry.runtime_data.coordinator_sensors + + assert config_entry.unique_id is not None + + async_add_entities( + LunatoneSensor( + coordinator_sensors, description, sensor_id, config_entry.unique_id + ) + for sensor_id, sensor_data in coordinator_sensors.data.items() + if (description := SENSOR_TYPES.get(sensor_data.data.type)) + ) + + +class LunatoneSensor( + CoordinatorEntity[LunatoneSensorsDataUpdateCoordinator], SensorEntity +): + """Representation of a Lunatone Sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LunatoneSensorsDataUpdateCoordinator, + description: SensorEntityDescription, + sensor_id: int, + config_entry_unique_id: str, + ) -> None: + """Initialize a Lunatone Sensor.""" + super().__init__(coordinator) + self.entity_description = description + + self._config_entry_unique_id = config_entry_unique_id + self._sensor_id = sensor_id + + self._attr_name = self.sensor.name + self._attr_unique_id = ( + f"{config_entry_unique_id}-sensor{sensor_id}-{description.key}" + ) + device_info = DeviceInfo( + identifiers={(DOMAIN, self._config_entry_unique_id)}, + ) + if ( + self.sensor.data.address_type == SensorAddressType.DALI + and self.sensor.data.dali_sensor_address + ): + device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + f"{self._config_entry_unique_id}" + f"-line{self.sensor.data.dali_sensor_address.line}" + f"-d24-address{self.sensor.data.dali_sensor_address.address}", + ) + }, + name=( + f"DALI Line {self.sensor.data.dali_sensor_address.line}" + f" - A{self.sensor.data.dali_sensor_address.address}\u00b2" + ), + via_device=(DOMAIN, str(self._config_entry_unique_id)), + ) + self._attr_device_info = device_info + + @property + def sensor(self) -> Sensor: + """Return the sensor data.""" + return self.coordinator.data[self._sensor_id] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._sensor_id in self.coordinator.data + + @property + def native_value(self) -> float | None: + """Return the measurement value of the sensor.""" + return self.sensor.data.value diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 438d67782fb..ff57187e1e1 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -2,17 +2,20 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_url": "Failed to connect. Check the URL and if the device is connected to power", "missing_device_info": "Failed to read device information. Check the network connection of the device" }, + "flow_title": "{name} ({model})", "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "discovery_confirm": { + "description": "Do you want to set up the Lunatone device at {url}?" }, "reconfigure": { "data": { @@ -21,17 +24,22 @@ "data_description": { "url": "[%key:component::lunatone::config::step::user::data_description::url%]" }, - "description": "Update the URL." + "description": "Update configuration for {name}." }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "url": "The URL of the Lunatone gateway device." + "url": "The URL of the Lunatone device to connect to." }, - "description": "Connect to the API of your Lunatone DALI IoT Gateway." + "description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API." } } + }, + "exceptions": { + "missing_device_info": { + "message": "Unable to read device information. Please verify the device's network connection." + } } } diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 69f1cfacf33..c6710513e36 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Lupusec System alarm control panels.""" -from __future__ import annotations - from datetime import timedelta import lupupy diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 356ec9ab99b..355fe73515d 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Lupusec Security System binary sensors.""" -from __future__ import annotations - from datetime import timedelta from functools import partial import logging diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 346d1a35703..89f23b45fc5 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -1,7 +1,5 @@ """Support for Lupusec Security System switches.""" -from __future__ import annotations - from datetime import timedelta from functools import partial from typing import Any diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 86c84ae23b5..ddecffb1a8f 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -29,6 +29,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SWITCH, ] @@ -92,83 +93,15 @@ async def async_setup_entry( for area in lutron_client.areas: _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: - platform = None - _LOGGER.debug("Working on output %s", output.type) - if output.type == "SYSTEM_SHADE": - entry_data.covers.append((area.name, output)) - platform = Platform.COVER - elif output.type == "CEILING_FAN_TYPE": - entry_data.fans.append((area.name, output)) - platform = Platform.FAN - elif output.is_dimmable: - entry_data.lights.append((area.name, output)) - platform = Platform.LIGHT - else: - entry_data.switches.append((area.name, output)) - platform = Platform.SWITCH - - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - output.uuid, - output.legacy_uuid, - entry_data.client.guid, - ) - _async_check_device_identifiers( - hass, - device_registry, - output.uuid, - output.legacy_uuid, - entry_data.client.guid, + _setup_output( + hass, entry_data, output, area.name, entity_registry, device_registry ) for keypad in area.keypads: - _async_check_keypad_identifiers( - hass, - device_registry, - keypad.id, - keypad.uuid, - keypad.legacy_uuid, - entry_data.client.guid, + _setup_keypad( + hass, entry_data, keypad, area.name, entity_registry, device_registry ) - for button in keypad.buttons: - # If the button has a function assigned to it, add it as a scene - if button.name != "Unknown Button" and button.button_type in ( - "SingleAction", - "Toggle", - "SingleSceneRaiseLower", - "MasterRaiseLower", - "AdvancedToggle", - ): - # Associate an LED with a button if there is one - led = next( - (led for led in keypad.leds if led.number == button.number), - None, - ) - entry_data.scenes.append((area.name, keypad, button, led)) - platform = Platform.SCENE - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - button.uuid, - button.legacy_uuid, - entry_data.client.guid, - ) - if led is not None: - platform = Platform.SWITCH - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - led.uuid, - led.legacy_uuid, - entry_data.client.guid, - ) - if button.button_type: - entry_data.buttons.append((area.name, keypad, button)) if area.occupancy_group is not None: entry_data.binary_sensors.append((area.name, area.occupancy_group)) platform = Platform.BINARY_SENSOR @@ -202,6 +135,100 @@ async def async_setup_entry( return True +def _setup_output( + hass: HomeAssistant, + entry_data: LutronData, + output: Output, + area_name: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Set up a Lutron output.""" + _LOGGER.debug("Working on output %s", output.type) + if output.type == "SYSTEM_SHADE": + entry_data.covers.append((area_name, output)) + platform = Platform.COVER + elif output.type == "CEILING_FAN_TYPE": + entry_data.fans.append((area_name, output)) + platform = Platform.FAN + elif output.is_dimmable: + entry_data.lights.append((area_name, output)) + platform = Platform.LIGHT + else: + entry_data.switches.append((area_name, output)) + platform = Platform.SWITCH + + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + _async_check_device_identifiers( + hass, + device_registry, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + + +def _setup_keypad( + hass: HomeAssistant, + entry_data: LutronData, + keypad: Keypad, + area_name: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Set up a Lutron keypad.""" + + _async_check_keypad_identifiers( + hass, + device_registry, + keypad.id, + keypad.uuid, + keypad.legacy_uuid, + entry_data.client.guid, + ) + leds_by_number = {led.number: led for led in keypad.leds} + for button in keypad.buttons: + # If the button has a function assigned to it, add it as a scene + if button.name != "Unknown Button" and button.button_type in ( + "SingleAction", + "Toggle", + "SingleSceneRaiseLower", + "MasterRaiseLower", + "AdvancedToggle", + ): + # Associate an LED with a button if there is one + led = leds_by_number.get(button.number) + entry_data.scenes.append((area_name, keypad, button, led)) + + _async_check_entity_unique_id( + hass, + entity_registry, + Platform.SCENE, + button.uuid, + button.legacy_uuid, + entry_data.client.guid, + ) + if led is not None: + for platform in (Platform.SWITCH, Platform.SELECT): + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + led.uuid, + led.legacy_uuid, + entry_data.client.guid, + ) + if button.button_type: + entry_data.buttons.append((area_name, keypad, button)) + + def _async_check_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index fddfdac7c8d..2c9e128526f 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Lutron Powr Savr occupancy sensors.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -56,4 +54,4 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): def _update_attrs(self) -> None: """Update the state attributes.""" - self._attr_is_on = self._lutron_device.state == OccupancyGroup.State.OCCUPIED + self._attr_is_on = self._lutron_device.state is OccupancyGroup.State.OCCUPIED diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 99b8a166b18..4db47d3eaa4 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Lutron integration.""" -from __future__ import annotations - import logging from typing import Any from urllib.error import HTTPError diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 3956bb9f486..586a1c433b6 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -1,7 +1,5 @@ """Support for Lutron shades.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index d6a1168a2fe..7dbf50d0166 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -1,7 +1,5 @@ """Lutron fan platform.""" -from __future__ import annotations - from typing import Any from pylutron import Output diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 9216202bf7c..01ea05a75d5 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -1,7 +1,5 @@ """Support for Lutron lights.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 5f3736f0882..e9a9dca1e10 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -1,7 +1,5 @@ """Support for Lutron scenes.""" -from __future__ import annotations - from typing import Any from pylutron import Button, Keypad, Lutron diff --git a/homeassistant/components/lutron/select.py b/homeassistant/components/lutron/select.py new file mode 100644 index 00000000000..02a58a37b76 --- /dev/null +++ b/homeassistant/components/lutron/select.py @@ -0,0 +1,71 @@ +"""Support for Lutron selects.""" + +from pylutron import Button, Keypad, Led, Lutron + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LutronConfigEntry +from .entity import LutronKeypad + +_LED_STATE_TO_OPTION = { + Led.LED_OFF: "off", + Led.LED_ON: "on", + Led.LED_SLOW_FLASH: "slow_flash", + Led.LED_FAST_FLASH: "fast_flash", +} + +_LED_OPTION_TO_STATE = {v: k for k, v in _LED_STATE_TO_OPTION.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LutronConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Lutron select platform.""" + entry_data = config_entry.runtime_data + + # Add the indicator LEDs for scenes (keypad buttons) + async_add_entities( + [ + LutronLedSelect(area_name, keypad, scene, led, entry_data.client) + for area_name, keypad, scene, led in entry_data.scenes + if led is not None + ], + True, + ) + + +class LutronLedSelect(LutronKeypad, SelectEntity): + """Representation of a Lutron Keypad LED.""" + + _lutron_device: Led + _attr_options = list(_LED_STATE_TO_OPTION.values()) + _attr_translation_key = "led_state" + + def __init__( + self, + area_name: str, + keypad: Keypad, + scene_device: Button, + led_device: Led, + controller: Lutron, + ) -> None: + """Initialize the select entity.""" + super().__init__(area_name, led_device, controller, keypad) + self._attr_name = f"{scene_device.name} LED" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return _LED_STATE_TO_OPTION.get(self._lutron_device.last_state) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._lutron_device.state = _LED_OPTION_TO_STATE[option] + + def _request_state(self) -> None: + """Request the state from the device.""" + _ = self._lutron_device.state diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index 8dcaeffd024..b64ba69dbc3 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -32,6 +32,16 @@ } } } + }, + "select": { + "led_state": { + "state": { + "fast_flash": "Fast flash", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "slow_flash": "Slow flash" + } + } } }, "options": { diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index be7fc8ea9e1..d389570078b 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -1,7 +1,5 @@ """Support for Lutron switches.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index bde3e7d4ec4..c0485ee9ca8 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -1,7 +1,5 @@ """Component for interacting with a Lutron Caseta system.""" -from __future__ import annotations - import asyncio from itertools import chain import logging @@ -291,7 +289,8 @@ def _async_setup_keypads( button_name = _get_button_name(keypad, bridge_button) keypad_lutron_device_id = keypad[LUTRON_KEYPAD_LUTRON_DEVICE_ID] - # Add button to parent keypad, and build keypad_buttons and keypad_button_names_to_leap + # Add button to parent keypad, and build + # keypad_buttons and keypad_button_names_to_leap keypad_buttons[button_lutron_device_id] = LutronButton( lutron_device_id=button_lutron_device_id, leap_button_number=leap_button_number, diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index f8de5c60df0..ded09eb0500 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -1,14 +1,16 @@ -"""Support for Lutron Caseta Occupancy/Vacancy Sensors.""" +"""Support for Lutron Caseta Occupancy/Vacancy/Battery Sensors.""" +from datetime import timedelta from typing import Any -from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED +from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED, BridgeResponseError from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import ATTR_SUGGESTED_AREA +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.const import ATTR_SUGGESTED_AREA, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,9 +18,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA from .entity import LutronCasetaEntity -from .models import LutronCasetaConfigEntry +from .models import LutronCasetaConfigEntry, LutronCasetaData from .util import area_name_from_id +SCAN_INTERVAL = timedelta(days=1) +BATTERY_STATUS_GOOD = "good" +BATTERY_STATUS_LOW = "low" + async def async_setup_entry( hass: HomeAssistant, @@ -27,8 +33,8 @@ async def async_setup_entry( ) -> None: """Set up the Lutron Caseta binary_sensor platform. - Adds occupancy groups from the Caseta bridge associated with the - config_entry as binary_sensor entities. + Adds occupancy groups and shade battery status from the Caseta bridge + associated with the config_entry as binary_sensor entities. """ data = config_entry.runtime_data bridge = data.bridge @@ -37,6 +43,13 @@ async def async_setup_entry( LutronOccupancySensor(occupancy_group, data) for occupancy_group in occupancy_groups.values() ) + async_add_entities( + ( + LutronCasetaBatterySensor(device, data) + for device in bridge.get_devices_by_domain(COVER_DOMAIN) + ), + update_before_add=True, + ) class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): @@ -67,7 +80,7 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_occupancy_subscriber( @@ -88,3 +101,45 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"device_id": self.device_id} + + +class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity): + """Representation of a Lutron Caseta shade low battery sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + _attr_should_poll = True + + def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: + """Initialize the battery sensor.""" + super().__init__(device, data) + # The base entity sets the shade name; remove it so the battery device + # class provides the sensor name. + if hasattr(self, "_attr_name"): + delattr(self, "_attr_name") + self._attr_is_on: bool | None = None + + @property + def unique_id(self) -> str: + """Return the unique ID of the battery sensor.""" + return f"{super().unique_id}_battery" + + # pylint: disable-next=home-assistant-missing-super-call + async def async_added_to_hass(self) -> None: + """Skip bridge subscriptions; the battery sensor is polled.""" + + async def async_update(self) -> None: + """Fetch the latest battery status from the bridge.""" + try: + status = await self._smartbridge.get_battery_status(self.device_id) + except BridgeResponseError: + self._attr_is_on = None + return + normalized_status = status.strip().casefold() if status else None + if normalized_status == BATTERY_STATUS_LOW: + self._attr_is_on = True + elif normalized_status == BATTERY_STATUS_GOOD: + self._attr_is_on = False + else: + self._attr_is_on = None diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index f2da502d346..5b6fd617b29 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -1,7 +1,5 @@ """Support for pico and keypad buttons.""" -from __future__ import annotations - from typing import Any from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 115da5cb101..b15f83d0bc0 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Lutron Caseta.""" -from __future__ import annotations - import asyncio import logging import os diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index ad1530bef5e..446b7bcb233 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -79,7 +79,7 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): """Stop the cover.""" # Send appropriate directional command before stop to ensure it works correctly # Use tracked direction if moving, otherwise use position-based heuristic - if self._movement_direction == ShadeMovementDirection.OPENING or ( + if self._movement_direction is ShadeMovementDirection.OPENING or ( self._movement_direction in (ShadeMovementDirection.STOPPED, None) and self.current_cover_position >= 50 ): @@ -168,7 +168,8 @@ async def async_setup_entry( bridge = data.bridge cover_devices = bridge.get_devices_by_domain(COVER_DOMAIN) async_add_entities( - # default to standard LutronCasetaCover type if the pylutron type is not yet mapped + # default to standard LutronCasetaCover type if the + # pylutron type is not yet mapped PYLUTRON_TYPE_TO_CLASSES.get(cover_device["type"], LutronCasetaShade)( cover_device, data ) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index b3bfaaa7c62..8cd13206026 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for lutron caseta.""" -from __future__ import annotations - import logging from typing import cast @@ -379,7 +377,8 @@ async def async_validate_trigger_config( ) return config - # Retrieve list of valid buttons, preferring hard-coded triggers from device_trigger.py + # Retrieve list of valid buttons, preferring + # hard-coded triggers from device_trigger.py device_type = keypad["type"] valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get( device_type, @@ -408,7 +407,8 @@ async def async_get_triggers( keypad_button_names_to_leap = data.keypad_data.button_names_to_leap - # Retrieve list of valid buttons, preferring hard-coded triggers from device_trigger.py + # Retrieve list of valid buttons, preferring + # hard-coded triggers from device_trigger.py valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get( keypad["type"], keypad_button_names_to_leap[keypad["lutron_device_id"]], diff --git a/homeassistant/components/lutron_caseta/diagnostics.py b/homeassistant/components/lutron_caseta/diagnostics.py index 1e37b65782e..5404d225390 100644 --- a/homeassistant/components/lutron_caseta/diagnostics.py +++ b/homeassistant/components/lutron_caseta/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for lutron_caseta.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index cde2cb52923..9cfab8a5a8a 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -1,7 +1,5 @@ """Component for interacting with a Lutron Caseta system.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 1e7fe07b8ba..660c15cb0f7 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -1,7 +1,5 @@ """Support for Lutron Caseta fans.""" -from __future__ import annotations - from typing import Any from pylutron_caseta import FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_OFF diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index b920a95e435..f5eb51af17c 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -55,7 +55,7 @@ WARM_DEVICE_TYPES = { def to_lutron_level(level): """Convert the given Home Assistant light level (0-255) to Lutron (0-100).""" - return int(round((level * 100) / 255)) + return round((level * 100) / 255) def to_hass_level(level): @@ -82,7 +82,10 @@ async def async_setup_entry( class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): - """Representation of a Lutron Light, including dimmable, white tune, and spectrum tune.""" + """Representation of a Lutron light. + + Including dimmable, white tune, and spectrum/color tune. + """ _attr_supported_features = LightEntityFeature.TRANSITION diff --git a/homeassistant/components/lutron_caseta/logbook.py b/homeassistant/components/lutron_caseta/logbook.py index 5b5d2c0f9f1..b0d5563a29b 100644 --- a/homeassistant/components/lutron_caseta/logbook.py +++ b/homeassistant/components/lutron_caseta/logbook.py @@ -1,7 +1,5 @@ """Describe lutron_caseta logbook events.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index f163307a782..d5318742516 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.27.0"], + "requirements": ["pylutron-caseta==0.28.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index 402fa8885e8..73f399ad8fa 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -1,7 +1,5 @@ """The lutron_caseta integration models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final, TypedDict diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index f1845b2ac12..29f0051f8b9 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -51,7 +51,8 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): keypads = data.keypad_data.keypads parent_keypad = keypads[device["parent_device"]] parent_device_info = parent_keypad["device_info"] - # Append the child device name to the end of the parent keypad name to create the entity name + # Append the child device name to the end of the + # parent keypad name to create the entity name self._attr_name = f"{parent_device_info['name']} {device['device_name']}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device diff --git a/homeassistant/components/lutron_caseta/util.py b/homeassistant/components/lutron_caseta/util.py index d4f0a9083fe..d1eade26af9 100644 --- a/homeassistant/components/lutron_caseta/util.py +++ b/homeassistant/components/lutron_caseta/util.py @@ -1,7 +1,5 @@ """Support for Lutron Caseta.""" -from __future__ import annotations - from .const import UNASSIGNED_AREA diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py index 9ea67f23c3e..d83b18cf926 100644 --- a/homeassistant/components/lw12wifi/light.py +++ b/homeassistant/components/lw12wifi/light.py @@ -1,7 +1,5 @@ """Support for Lagute LW-12 WiFi LED Controller.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 95fb559491d..af610f12737 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -1,7 +1,5 @@ """The Honeywell Lyric integration.""" -from __future__ import annotations - from aiolyric import Lyric from homeassistant.const import Platform @@ -23,7 +21,7 @@ from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: diff --git a/homeassistant/components/lyric/api.py b/homeassistant/components/lyric/api.py index 7399e013b96..5a08e626d3c 100644 --- a/homeassistant/components/lyric/api.py +++ b/homeassistant/components/lyric/api.py @@ -46,6 +46,11 @@ class LyricLocalOAuth2Implementation( ): """Lyric Local OAuth2 implementation.""" + @property + def extra_authorize_data(self) -> dict: + """Prompt the user to choose between Resideo and First Alert apps.""" + return {"appSelect": "1"} + async def _token_request(self, data: dict) -> dict: """Make a token request.""" session = async_get_clientsession(self.hass) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 65bf03416d1..0bab0aab1d2 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -1,7 +1,5 @@ """Support for Honeywell Lyric climate platform.""" -from __future__ import annotations - import asyncio import enum import logging @@ -31,12 +29,13 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( + DOMAIN, LYRIC_EXCEPTIONS, PRESET_HOLD_UNTIL, PRESET_NO_HOLD, @@ -362,9 +361,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): heat_setpoint=target_temp_low, mode=mode, ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_temperature", + ) from err + finally: + await self.coordinator.async_refresh() else: temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Set temperature: %s", temp) @@ -377,9 +380,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self._update_thermostat( self.location, device, heat_setpoint=temp ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_temperature", + ) from err + finally: + await self.coordinator.async_refresh() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -390,9 +397,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self._async_set_hvac_mode_tcc(hvac_mode) case LyricThermostatType.LCC: await self._async_set_hvac_mode_lcc(hvac_mode) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_hvac_mode", + ) from err + finally: + await self.coordinator.async_refresh() async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: """Set hvac mode for TCC devices (e.g., Lyric round).""" @@ -468,9 +479,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self._update_thermostat( self.location, self.device, thermostat_setpoint_status=preset_mode ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_preset_mode", + ) from err + finally: + await self.coordinator.async_refresh() async def async_set_hold_time(self, time_period: str) -> None: """Set the time to hold until.""" @@ -482,23 +497,31 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): thermostat_setpoint_status=PRESET_HOLD_UNTIL, next_period_time=time_period, ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - await self.coordinator.async_refresh() + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_hold_time", + ) from err + finally: + await self.coordinator.async_refresh() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" _LOGGER.debug("Set fan mode: %s", fan_mode) + if fan_mode not in LYRIC_FAN_MODES: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_mode", + translation_placeholders={"fan_mode": fan_mode}, + ) + mode = LYRIC_FAN_MODES[fan_mode] + _LOGGER.debug("Fan mode passed to lyric: %s", mode) try: - _LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode]) - await self._update_fan( - self.location, self.device, mode=LYRIC_FAN_MODES[fan_mode] - ) - except LYRIC_EXCEPTIONS as exception: - _LOGGER.error(exception) - except KeyError: - _LOGGER.error( - "The fan mode requested does not have a corresponding mode in lyric: %s", - fan_mode, - ) - await self.coordinator.async_refresh() + await self._update_fan(self.location, self.device, mode=mode) + except LYRIC_EXCEPTIONS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_fan_mode", + ) from err + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/config_flow.py b/homeassistant/components/lyric/config_flow.py index db4647145fe..51845fdb59c 100644 --- a/homeassistant/components/lyric/config_flow.py +++ b/homeassistant/components/lyric/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Honeywell Lyric.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/lyric/const.py b/homeassistant/components/lyric/const.py index 6d973b56181..3acc4b00810 100644 --- a/homeassistant/components/lyric/const.py +++ b/homeassistant/components/lyric/const.py @@ -5,8 +5,8 @@ from aiolyric.exceptions import LyricAuthenticationException, LyricException DOMAIN = "lyric" -OAUTH2_AUTHORIZE = "https://api.honeywell.com/oauth2/authorize" -OAUTH2_TOKEN = "https://api.honeywell.com/oauth2/token" +OAUTH2_AUTHORIZE = "https://api.honeywellhome.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.honeywellhome.com/oauth2/token" PRESET_NO_HOLD = "NoHold" PRESET_TEMPORARY_HOLD = "TemporaryHold" diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index b9b36e56133..f7bd07123b8 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -1,7 +1,5 @@ """The Honeywell Lyric integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta from http import HTTPStatus diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py index 61ba384b861..feffeae8615 100644 --- a/homeassistant/components/lyric/entity.py +++ b/homeassistant/components/lyric/entity.py @@ -1,7 +1,5 @@ """The Honeywell Lyric integration.""" -from __future__ import annotations - from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricAccessory, LyricRoom diff --git a/homeassistant/components/lyric/icons.json b/homeassistant/components/lyric/icons.json index edb61c3f8e2..6203a932e6b 100644 --- a/homeassistant/components/lyric/icons.json +++ b/homeassistant/components/lyric/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "room_priority": { + "default": "mdi:home-thermometer" + } + }, "sensor": { "setpoint_status": { "default": "mdi:thermostat" diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index 62e5683722f..2a935c09f2d 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -22,5 +22,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aiolyric"], - "requirements": ["aiolyric==2.0.2"] + "requirements": ["aiolyric==2.1.1"] } diff --git a/homeassistant/components/lyric/select.py b/homeassistant/components/lyric/select.py new file mode 100644 index 00000000000..683d2c6b2e3 --- /dev/null +++ b/homeassistant/components/lyric/select.py @@ -0,0 +1,129 @@ +"""Support for Honeywell Lyric select platform.""" + +import logging + +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricRoom + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import LYRIC_EXCEPTIONS +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator +from .entity import LyricDeviceEntity + +_LOGGER = logging.getLogger(__name__) + +# Honeywell Lyric API priority types +PRIORITY_TYPE_PICK_A_ROOM = "PickARoom" +PRIORITY_TYPE_FOLLOW_ME = "FollowMe" +PRIORITY_TYPE_WHOLE_HOUSE = "WholeHouse" + +# Option shown in the select for the FollowMe mode +OPTION_FOLLOW_ME = "follow_me" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LyricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Honeywell Lyric select entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LyricRoomPrioritySelect(coordinator, location, device) + for location in coordinator.data.locations + for device in location.devices + if device.device_class == "Thermostat" + and device.device_id.startswith("LCC") + and coordinator.data.rooms_dict.get(device.mac_id) + ) + + +class LyricRoomPrioritySelect(LyricDeviceEntity, SelectEntity): + """Select entity for Honeywell Lyric thermostat room priority.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "room_priority" + + def __init__( + self, + coordinator: LyricDataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + ) -> None: + """Initialize the room priority select entity.""" + super().__init__( + coordinator, + location, + device, + f"{device.mac_id}_room_priority", + ) + + @property + def _rooms(self) -> dict[int, LyricRoom]: + """Return the rooms for this thermostat.""" + return self.coordinator.data.rooms_dict.get(self._mac_id, {}) + + @property + def options(self) -> list[str]: + """Return the list of available room priority options.""" + room_options = sorted( + room.room_name for room in self._rooms.values() if room.room_name + ) + return [OPTION_FOLLOW_ME, *room_options] + + @property + def current_option(self) -> str | None: + """Return the currently selected room priority.""" + priority = self.coordinator.data.priorities_dict.get(self._mac_id) + if priority is None: + return None + + current = priority.current_priority + if current.priority_type == PRIORITY_TYPE_FOLLOW_ME: + return OPTION_FOLLOW_ME + + if current.priority_type == PRIORITY_TYPE_PICK_A_ROOM: + selected = current.selected_rooms + if selected: + room = self._rooms.get(selected[0]) + if room is not None: + return room.room_name + + return None + + async def async_select_option(self, option: str) -> None: + """Set the room priority.""" + if option == OPTION_FOLLOW_ME: + priority_type = PRIORITY_TYPE_FOLLOW_ME + rooms: list[int] = [] + else: + priority_type = PRIORITY_TYPE_PICK_A_ROOM + room_id = next( + (rid for rid, room in self._rooms.items() if room.room_name == option), + None, + ) + if room_id is None: + _LOGGER.error("Room not found: %s", option) + return + rooms = [room_id] + + _LOGGER.debug("Set room priority: type=%s, rooms=%s", priority_type, rooms) + try: + await self.coordinator.data.update_priority( + self.location, + self.device, + priority_type=priority_type, + rooms=rooms, + ) + except LYRIC_EXCEPTIONS as exception: + raise HomeAssistantError( + f"Failed to set room priority: {exception}" + ) from exception + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f0a8d572353..c7bc97f5597 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -1,7 +1,5 @@ """Support for Honeywell Lyric sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -144,11 +142,13 @@ def get_setpoint_status(status: str, time: str) -> str | None: return LYRIC_SETPOINT_STATUS_NAMES.get(status) -def get_datetime_from_future_time(time_str: str) -> datetime: +def get_datetime_from_future_time(time_str: str | None) -> datetime | None: """Get datetime from future time provided.""" + if time_str is None: + return None time = dt_util.parse_time(time_str) if time is None: - raise ValueError(f"Unable to parse time {time_str}") + return None now = dt_util.utcnow() if time <= now.time(): now = now + timedelta(days=1) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 51f1cff5269..b9547c82514 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -37,6 +37,14 @@ } }, "entity": { + "select": { + "room_priority": { + "name": "Room priority", + "state": { + "follow_me": "Follow me" + } + } + }, "sensor": { "indoor_humidity": { "name": "Indoor humidity" @@ -65,6 +73,24 @@ } }, "exceptions": { + "failed_to_set_fan_mode": { + "message": "Failed to set fan mode" + }, + "failed_to_set_hold_time": { + "message": "Failed to set hold time" + }, + "failed_to_set_hvac_mode": { + "message": "Failed to set HVAC mode" + }, + "failed_to_set_preset_mode": { + "message": "Failed to set preset mode" + }, + "failed_to_set_temperature": { + "message": "Failed to set temperature" + }, + "invalid_fan_mode": { + "message": "The fan mode {fan_mode} is not supported by this device" + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py index cf681bd0b65..e55826b3b2c 100644 --- a/homeassistant/components/madvr/__init__.py +++ b/homeassistant/components/madvr/__init__.py @@ -1,7 +1,5 @@ """The madvr-envy integration.""" -from __future__ import annotations - import logging from madvr.madvr import Madvr @@ -29,7 +27,7 @@ async def async_handle_unload(coordinator: MadVRCoordinator) -> None: async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool: """Set up the integration from a config entry.""" assert entry.unique_id - madVRClient = Madvr( + mad_vr_client = Madvr( host=entry.data[CONF_HOST], logger=_LOGGER, port=entry.data[CONF_PORT], @@ -37,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo connect_timeout=10, loop=hass.loop, ) - coordinator = MadVRCoordinator(hass, entry, madVRClient) + coordinator = MadVRCoordinator(hass, entry, mad_vr_client) entry.runtime_data = coordinator diff --git a/homeassistant/components/madvr/binary_sensor.py b/homeassistant/components/madvr/binary_sensor.py index 45c915aba8c..0286e0925f4 100644 --- a/homeassistant/components/madvr/binary_sensor.py +++ b/homeassistant/components/madvr/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor entities for the madVR integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/madvr/config_flow.py b/homeassistant/components/madvr/config_flow.py index 60f7b8fc481..71c3aa43fcf 100644 --- a/homeassistant/components/madvr/config_flow.py +++ b/homeassistant/components/madvr/config_flow.py @@ -104,7 +104,8 @@ async def test_connection(hass: HomeAssistant, host: str, port: int) -> str: # try to connect try: await asyncio.wait_for(madvr_client.open_connection(), timeout=15) - # connection can raise HeartBeatError if the device is not available or connection does not work + # connection can raise HeartBeatError if the device is not + # available or connection does not work except (TimeoutError, aiohttp.ClientError, OSError, HeartBeatError) as err: _LOGGER.error("Error connecting to madVR: %s", err) raise CannotConnect from err diff --git a/homeassistant/components/madvr/coordinator.py b/homeassistant/components/madvr/coordinator.py index c1ed87fbee7..7984c5a5b75 100644 --- a/homeassistant/components/madvr/coordinator.py +++ b/homeassistant/components/madvr/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for handling data fetching and updates.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/madvr/diagnostics.py b/homeassistant/components/madvr/diagnostics.py index 39e17a13d6f..993c0c642b3 100644 --- a/homeassistant/components/madvr/diagnostics.py +++ b/homeassistant/components/madvr/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for madVR.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py index 23e969e56e3..25f57224987 100644 --- a/homeassistant/components/madvr/remote.py +++ b/homeassistant/components/madvr/remote.py @@ -1,7 +1,5 @@ """Support for madVR remote control.""" -from __future__ import annotations - from collections.abc import Iterable import logging from typing import Any @@ -54,6 +52,7 @@ class MadvrRemote(MadVREntity, RemoteEntity): _LOGGER.debug("Turning off") try: await self.madvr_client.power_off() + # pylint: disable-next=home-assistant-action-swallowed-exception except (ConnectionError, NotImplementedError) as err: _LOGGER.error("Failed to turn off device %s", err) @@ -63,6 +62,7 @@ class MadvrRemote(MadVREntity, RemoteEntity): try: await self.madvr_client.power_on(mac=self.coordinator.mac) + # pylint: disable-next=home-assistant-action-swallowed-exception except (ConnectionError, NotImplementedError) as err: _LOGGER.error("Failed to turn on device %s", err) @@ -71,5 +71,6 @@ class MadvrRemote(MadVREntity, RemoteEntity): _LOGGER.debug("adding command %s", command) try: await self.madvr_client.add_command_to_queue(command) + # pylint: disable-next=home-assistant-action-swallowed-exception except (ConnectionError, NotImplementedError) as err: _LOGGER.error("Failed to send command %s", err) diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py index 2f0d9f17507..be56fc20210 100644 --- a/homeassistant/components/madvr/sensor.py +++ b/homeassistant/components/madvr/sensor.py @@ -1,7 +1,5 @@ """Sensor entities for the madVR integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index eb704a2d797..7e1d5e14867 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -1,4 +1,5 @@ """Support for Mailgun.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import hashlib import hmac diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index daf5eb904ab..8bc60bb5522 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -1,7 +1,5 @@ """Support for the Mailgun mail notifications.""" -from __future__ import annotations - import logging from typing import Any @@ -44,6 +42,8 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> MailgunNotificationService | None: """Get the Mailgun notification service.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data data = hass.data[DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), @@ -111,5 +111,6 @@ class MailgunNotificationService(BaseNotificationService): files=files, ) _LOGGER.debug("Message sent: %s", resp) + # pylint: disable-next=home-assistant-action-swallowed-exception except MailgunError: _LOGGER.exception("Failed to send message") diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 648368db6d0..230df3f3dce 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for manual alarms.""" -from __future__ import annotations - import datetime from typing import Any @@ -84,7 +82,9 @@ SUPPORTED_ARMING_STATE_TO_FEATURE = { AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, AlarmControlPanelState.ARMED_VACATION: AlarmControlPanelEntityFeature.ARM_VACATION, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS: ( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ), } ATTR_PREVIOUS_STATE = "previous_state" @@ -422,6 +422,7 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): }, ) + # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( "Invalid alarm code provided", translation_domain=DOMAIN, @@ -455,12 +456,15 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): self._state_ts = state.last_updated - if next_state := state.attributes.get(ATTR_NEXT_STATE): - # If in arming or pending state we record the transition, - # not the current state - self._state = AlarmControlPanelState(next_state) - else: - self._state = AlarmControlPanelState(state.state) + try: + if next_state := state.attributes.get(ATTR_NEXT_STATE): + # If in arming or pending state we record the transition, + # not the current state + self._state = AlarmControlPanelState(next_state) + else: + self._state = AlarmControlPanelState(state.state) + except ValueError: + return if prev_state := state.attributes.get(ATTR_PREVIOUS_STATE): self._previous_state = prev_state diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index cb03b71ce22..234e68cea5f 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for manual alarms controllable via MQTT.""" -from __future__ import annotations - import datetime import logging from typing import Any diff --git a/homeassistant/components/marantz_infrared/__init__.py b/homeassistant/components/marantz_infrared/__init__.py new file mode 100644 index 00000000000..701ca9f9838 --- /dev/null +++ b/homeassistant/components/marantz_infrared/__init__.py @@ -0,0 +1,37 @@ +"""Marantz IR Remote integration for Home Assistant.""" + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] + + +@dataclass +class MarantzIrRuntimeData: + """Runtime data for a Marantz IR config entry. + + The RC-5 toggle bit must alternate between distinct key presses so + the receiver can distinguish a new press from a held-down repeat. + The toggle is tracked at the device level (one value per config + entry) so all entities of a config entry share it. + """ + + toggle: int = 0 + + +type MarantzIrConfigEntry = ConfigEntry[MarantzIrRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool: + """Set up Marantz IR from a config entry.""" + entry.runtime_data = MarantzIrRuntimeData() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool: + """Unload a Marantz IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/marantz_infrared/button.py b/homeassistant/components/marantz_infrared/button.py new file mode 100644 index 00000000000..44d7db88c89 --- /dev/null +++ b/homeassistant/components/marantz_infrared/button.py @@ -0,0 +1,81 @@ +"""Button platform for Marantz IR integration. + +Only commands that aren't already exposed by the media player live here: +speaker A/B, source-direct toggle, and loudness toggle. +""" + +from dataclasses import dataclass + +from infrared_protocols.codes.marantz.audio import MarantzAudioCode + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MarantzIrConfigEntry +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, MODELS +from .entity import MarantzIrEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class MarantzIrButtonEntityDescription(ButtonEntityDescription): + """Describes Marantz IR button entity.""" + + command_code: MarantzAudioCode + + +BUTTON_DESCRIPTIONS: tuple[MarantzIrButtonEntityDescription, ...] = ( + MarantzIrButtonEntityDescription( + key="speaker_ab", + translation_key="speaker_ab", + command_code=MarantzAudioCode.SPEAKER_AB, + ), + MarantzIrButtonEntityDescription( + key="source_direct", + translation_key="source_direct", + command_code=MarantzAudioCode.SOURCE_DIRECT, + ), + MarantzIrButtonEntityDescription( + key="loudness", + translation_key="loudness", + command_code=MarantzAudioCode.LOUDNESS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MarantzIrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Marantz IR buttons from config entry.""" + infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + model_codes = MODELS[entry.data[CONF_MODEL]].codes + async_add_entities( + MarantzIrButton(entry, infrared_entity_id, description) + for description in BUTTON_DESCRIPTIONS + if description.command_code in model_codes + ) + + +class MarantzIrButton(MarantzIrEntity, ButtonEntity): + """Marantz IR button entity.""" + + entity_description: MarantzIrButtonEntityDescription + + def __init__( + self, + entry: MarantzIrConfigEntry, + infrared_entity_id: str, + description: MarantzIrButtonEntityDescription, + ) -> None: + """Initialize Marantz IR button.""" + super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key) + self.entity_description = description + + async def async_press(self) -> None: + """Press the button.""" + await self._send_marantz_command(self.entity_description.command_code) diff --git a/homeassistant/components/marantz_infrared/config_flow.py b/homeassistant/components/marantz_infrared/config_flow.py new file mode 100644 index 00000000000..47f41802402 --- /dev/null +++ b/homeassistant/components/marantz_infrared/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Marantz IR integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_MODEL +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, DOMAIN, MODELS + + +class MarantzIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Marantz IR.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID] + model = user_input[CONF_MODEL] + + await self.async_set_unique_id(f"{model}_{entity_id}") + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=MODELS[model].name, data=user_input) + + model_options = [ + SelectOptionDict(value=slug, label=model.name) + for slug, model in MODELS.items() + ] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=model_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/marantz_infrared/const.py b/homeassistant/components/marantz_infrared/const.py new file mode 100644 index 00000000000..269c8e8c6b6 --- /dev/null +++ b/homeassistant/components/marantz_infrared/const.py @@ -0,0 +1,12 @@ +"""Constants for the Marantz IR integration.""" + +from infrared_protocols.codes.marantz import models as marantz_models + +from homeassistant.util import slugify + +DOMAIN = "marantz_infrared" +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" + +MODELS: dict[str, marantz_models.MarantzModel] = { + slugify(model.name): model for model in marantz_models.ALL_MODELS +} diff --git a/homeassistant/components/marantz_infrared/entity.py b/homeassistant/components/marantz_infrared/entity.py new file mode 100644 index 00000000000..6732daaf6f3 --- /dev/null +++ b/homeassistant/components/marantz_infrared/entity.py @@ -0,0 +1,48 @@ +"""Common entity for Marantz IR integration.""" + +from infrared_protocols.codes.marantz import models as marantz_models +from infrared_protocols.codes.marantz.audio import MarantzAudioCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.const import CONF_MODEL +from homeassistant.helpers.device_registry import DeviceInfo + +from . import MarantzIrConfigEntry +from .const import DOMAIN, MODELS + + +class MarantzIrEntity(InfraredEmitterConsumerEntity): + """Marantz IR base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: MarantzIrConfigEntry, + infrared_entity_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize Marantz IR entity.""" + self._infrared_emitter_entity_id = infrared_entity_id + self._runtime_data = entry.runtime_data + self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" + lib_model = MODELS[entry.data[CONF_MODEL]] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"Marantz {lib_model.name}", + manufacturer="Marantz", + model=None if lib_model is marantz_models.GENERIC else lib_model.name, + ) + + async def _send_marantz_command( + self, code: MarantzAudioCode, repeat_count: int = 0 + ) -> None: + """Send an IR command using the Marantz protocol. + + Flips the RC-5 toggle bit before each frame so the receiver + treats consecutive presses as new presses, not as a held repeat. + """ + self._runtime_data.toggle ^= 1 + await self._send_command( + code.to_command(repeat_count=repeat_count, toggle=self._runtime_data.toggle) + ) diff --git a/homeassistant/components/marantz_infrared/manifest.json b/homeassistant/components/marantz_infrared/manifest.json new file mode 100644 index 00000000000..7dd7d336e9e --- /dev/null +++ b/homeassistant/components/marantz_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "marantz_infrared", + "name": "Marantz Infrared", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/marantz_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "silver" +} diff --git a/homeassistant/components/marantz_infrared/media_player.py b/homeassistant/components/marantz_infrared/media_player.py new file mode 100644 index 00000000000..3f3e9c2e1f1 --- /dev/null +++ b/homeassistant/components/marantz_infrared/media_player.py @@ -0,0 +1,151 @@ +"""Media player platform for Marantz IR integration.""" + +from dataclasses import dataclass +from typing import Any + +from infrared_protocols.codes.marantz.audio import MarantzAudioCode + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity + +from . import MarantzIrConfigEntry +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, MODELS +from .entity import MarantzIrEntity + +PARALLEL_UPDATES = 1 + +SOURCE_TO_CODE: dict[str, MarantzAudioCode] = { + "cd": MarantzAudioCode.SOURCE_CD, + "coax": MarantzAudioCode.SOURCE_COAX, + "laserdisc": MarantzAudioCode.SOURCE_LD, + "md": MarantzAudioCode.SOURCE_MD, + "network": MarantzAudioCode.SOURCE_NETWORK, + "optical": MarantzAudioCode.SOURCE_OPTICAL, + "phono": MarantzAudioCode.SOURCE_PHONO, + "recorder": MarantzAudioCode.SOURCE_CDR, + "satellite": MarantzAudioCode.SOURCE_SAT, + "tape": MarantzAudioCode.SOURCE_TAPE, + "tuner": MarantzAudioCode.SOURCE_TUNER, + "tv": MarantzAudioCode.SOURCE_TV, + "vcr": MarantzAudioCode.SOURCE_VCR1, +} + + +@dataclass +class _MarantzAmplifierExtraData(ExtraStoredData): + """Persisted assumed-state data for a Marantz amplifier. + + Stored separately from the entity state because while the amplifier is + OFF, ``MediaPlayerEntity.state_attributes`` strips ``source`` / mute, + so a restart in the OFF state would otherwise lose them. + """ + + source: str | None + is_volume_muted: bool | None + + def as_dict(self) -> dict[str, Any]: + """Serialize for the restore-state store.""" + return {"source": self.source, "is_volume_muted": self.is_volume_muted} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MarantzIrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Marantz IR media player from config entry.""" + infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + async_add_entities([MarantzIrAmplifierMediaPlayer(entry, infrared_entity_id)]) + + +class MarantzIrAmplifierMediaPlayer(MarantzIrEntity, MediaPlayerEntity, RestoreEntity): + """Marantz IR amplifier media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_translation_key = "receiver" + + def __init__(self, entry: MarantzIrConfigEntry, infrared_entity_id: str) -> None: + """Initialize Marantz IR amplifier media player.""" + super().__init__(entry, infrared_entity_id, unique_id_suffix="media_player") + codes = MODELS[entry.data[CONF_MODEL]].codes + self._source_to_code = { + source: code for source, code in SOURCE_TO_CODE.items() if code in codes + } + self._attr_source_list = list(self._source_to_code) + features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + if self._source_to_code: + features |= MediaPlayerEntityFeature.SELECT_SOURCE + self._attr_supported_features = features + + @property + def extra_restore_state_data(self) -> ExtraStoredData: + """Persist source and mute regardless of ON/OFF state.""" + return _MarantzAmplifierExtraData( + source=self._attr_source, + is_volume_muted=self._attr_is_volume_muted, + ) + + async def async_added_to_hass(self) -> None: + """Restore last known assumed state, source, and mute.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None and ( + last_state.state in (MediaPlayerState.ON, MediaPlayerState.OFF) + ): + self._attr_state = MediaPlayerState(last_state.state) + + if (extra := await self.async_get_last_extra_data()) is not None: + data = extra.as_dict() + if (source := data.get("source")) in self._source_to_code: + self._attr_source = source + if (muted := data.get("is_volume_muted")) is not None: + self._attr_is_volume_muted = bool(muted) + + async def async_turn_on(self) -> None: + """Send discrete power-on command.""" + await self._send_marantz_command(MarantzAudioCode.POWER_ON, repeat_count=5) + self._attr_state = MediaPlayerState.ON + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Send discrete power-off command.""" + await self._send_marantz_command(MarantzAudioCode.POWER_OFF) + self._attr_state = MediaPlayerState.OFF + self.async_write_ha_state() + + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self._send_marantz_command(MarantzAudioCode.VOLUME_UP) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self._send_marantz_command(MarantzAudioCode.VOLUME_DOWN) + + async def async_mute_volume(self, mute: bool) -> None: + """Send discrete mute-on or mute-off command.""" + await self._send_marantz_command( + MarantzAudioCode.MUTE_ON if mute else MarantzAudioCode.MUTE_OFF + ) + self._attr_is_volume_muted = mute + self.async_write_ha_state() + + async def async_select_source(self, source: str) -> None: + """Select an input source.""" + await self._send_marantz_command(self._source_to_code[source]) + self._attr_source = source + self.async_write_ha_state() diff --git a/homeassistant/components/marantz_infrared/quality_scale.yaml b/homeassistant/components/marantz_infrared/quality_scale.yaml new file mode 100644 index 00000000000..a3e99d84d4f --- /dev/null +++ b/homeassistant/components/marantz_infrared/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: + status: exempt + comment: | + This integration does not use custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration has no external dependencies. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/marantz_infrared/strings.json b/homeassistant/components/marantz_infrared/strings.json new file mode 100644 index 00000000000..2728eb255d6 --- /dev/null +++ b/homeassistant/components/marantz_infrared/strings.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "This Marantz device has already been configured with this transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "infrared_emitter_entity_id": "Infrared transmitter", + "model": "Model" + }, + "data_description": { + "infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands.", + "model": "The Marantz model to control." + }, + "description": "Select the Marantz model and the infrared transmitter entity to use for controlling your Marantz device.", + "title": "Set up Marantz IR Remote" + } + } + }, + "entity": { + "button": { + "loudness": { + "name": "Loudness" + }, + "source_direct": { + "name": "Source direct" + }, + "speaker_ab": { + "name": "Speaker A/B" + } + }, + "media_player": { + "receiver": { + "state_attributes": { + "source": { + "state": { + "cd": "CD", + "coax": "Coax", + "laserdisc": "LaserDisc", + "md": "MD", + "network": "Network", + "optical": "Optical", + "phono": "Phono", + "recorder": "Recorder", + "satellite": "Satellite", + "tape": "Tape", + "tuner": "Tuner", + "tv": "TV", + "vcr": "VCR" + } + } + } + } + } + } +} diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 257a2c22854..5b0377f8b3c 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -1,7 +1,5 @@ """Support for the MaryTTS service.""" -from __future__ import annotations - from typing import Any from speak2mary import MaryTTS diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 15d9aec6333..b2177fa96db 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -1,7 +1,5 @@ """The Mastodon integration.""" -from __future__ import annotations - from mastodon.Mastodon import ( Account, Instance, @@ -55,7 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> translation_key="auth_failed", ) from error except MastodonError as ex: - raise ConfigEntryNotReady("Failed to connect") from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_connect", + ) from ex assert entry.unique_id diff --git a/homeassistant/components/mastodon/binary_sensor.py b/homeassistant/components/mastodon/binary_sensor.py index 42400c8b238..dc535b34513 100644 --- a/homeassistant/components/mastodon/binary_sensor.py +++ b/homeassistant/components/mastodon/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for the Mastodon integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 963df3d2193..68e90b53852 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Mastodon.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 63d2ef7c66e..269bcb25548 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -15,6 +15,7 @@ DEFAULT_NAME: Final = "Mastodon" ATTR_ACCOUNT_NAME = "account_name" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" +ATTR_QUOTE_APPROVAL_POLICY = "quote_approval_policy" ATTR_IDEMPOTENCY_KEY = "idempotency_key" ATTR_CONTENT_WARNING = "content_warning" ATTR_MEDIA_WARNING = "media_warning" @@ -28,9 +29,10 @@ ATTR_DISPLAY_NAME = "display_name" ATTR_NOTE = "note" ATTR_AVATAR = "avatar" ATTR_AVATAR_MIME_TYPE = "avatar_mime_type" +ATTR_DELETE_AVATAR = "delete_avatar" ATTR_HEADER = "header" ATTR_HEADER_MIME_TYPE = "header_mime_type" -ATTR_LOCKED = "locked" +ATTR_DELETE_HEADER = "delete_header" ATTR_BOT = "bot" ATTR_DISCOVERABLE = "discoverable" ATTR_FIELDS = "fields" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 5246bbd413a..fb1e3562628 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching Mastodon data.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta @@ -64,6 +62,9 @@ class MastodonCoordinator(DataUpdateCoordinator[Account]): translation_key="auth_failed", ) from error except MastodonError as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from ex return account diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index 434f6c0acac..1481cb7d610 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the Mastodon integration.""" -from __future__ import annotations - from typing import Any from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonNotFoundError diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py index 29e31e7deac..dcef3df36b5 100644 --- a/homeassistant/components/mastodon/entity.py +++ b/homeassistant/components/mastodon/entity.py @@ -26,7 +26,8 @@ class MastodonEntity(CoordinatorEntity[MastodonCoordinator]): assert unique_id is not None self._attr_unique_id = f"{unique_id}_{entity_description.key}" - # Legacy yaml config default title is Mastodon, don't make name Mastodon Mastodon + # Legacy yaml config default title is Mastodon, + # don't make name Mastodon Mastodon name = "Mastodon" if data.title != DEFAULT_NAME: name = f"Mastodon {data.title}" diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 2de970e263c..c34dc93b988 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["mastodon"], "quality_scale": "gold", - "requirements": ["Mastodon.py==2.1.2"] + "requirements": ["Mastodon.py==2.2.1"] } diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 113f3de17d7..724e8afe7a9 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -1,7 +1,5 @@ """Mastodon platform for sensor components.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 5e93447fba5..6684b6bc866 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant.components import camera, image from homeassistant.components.media_source import async_resolve_media -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_NAME +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_LOCKED, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -38,6 +38,8 @@ from .const import ( ATTR_AVATAR_MIME_TYPE, ATTR_BOT, ATTR_CONTENT_WARNING, + ATTR_DELETE_AVATAR, + ATTR_DELETE_HEADER, ATTR_DISCOVERABLE, ATTR_DISPLAY_NAME, ATTR_DURATION, @@ -47,11 +49,11 @@ from .const import ( ATTR_HIDE_NOTIFICATIONS, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, - ATTR_LOCKED, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, ATTR_NOTE, + ATTR_QUOTE_APPROVAL_POLICY, ATTR_STATUS, ATTR_VALUE, ATTR_VISIBILITY, @@ -73,6 +75,14 @@ class StatusVisibility(StrEnum): DIRECT = "direct" +class QuoteApprovalPolicy(StrEnum): + """QuoteApprovalPolicy model.""" + + PUBLIC = "public" + FOLLOWERS = "followers" + NOBODY = "nobody" + + SERVICE_GET_ACCOUNT = "get_account" SERVICE_GET_ACCOUNT_SCHEMA = vol.Schema( { @@ -107,6 +117,9 @@ SERVICE_POST_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY_ID): str, vol.Required(ATTR_STATUS): str, vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_QUOTE_APPROVAL_POLICY): vol.In( + [x.lower() for x in QuoteApprovalPolicy] + ), vol.Optional(ATTR_IDEMPOTENCY_KEY): str, vol.Optional(ATTR_CONTENT_WARNING): str, vol.Optional(ATTR_LANGUAGE): str, @@ -122,8 +135,10 @@ SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY_ID): str, vol.Optional(ATTR_DISPLAY_NAME): str, vol.Optional(ATTR_NOTE): str, - vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}), - vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}), + vol.Exclusive(ATTR_AVATAR, ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}), + vol.Exclusive(ATTR_DELETE_AVATAR, ATTR_AVATAR): cv.boolean, + vol.Exclusive(ATTR_HEADER, ATTR_HEADER): MediaSelector({"accept": ["image/*"]}), + vol.Exclusive(ATTR_DELETE_HEADER, ATTR_HEADER): cv.boolean, vol.Optional(ATTR_LOCKED): bool, vol.Optional(ATTR_BOT): bool, vol.Optional(ATTR_DISCOVERABLE): bool, @@ -165,7 +180,7 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_UPDATE_PROFILE, _async_update_profile, schema=SERVICE_UPDATE_PROFILE_SCHEMA, - supports_response=SupportsResponse.ONLY, + supports_response=SupportsResponse.OPTIONAL, ) @@ -287,6 +302,11 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: if ATTR_VISIBILITY in call.data else None ) + quote_approval_policy: str | None = ( + QuoteApprovalPolicy(call.data[ATTR_QUOTE_APPROVAL_POLICY]) + if ATTR_QUOTE_APPROVAL_POLICY in call.data + else None + ) idempotency_key: str | None = call.data.get(ATTR_IDEMPOTENCY_KEY) spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) language: str | None = call.data.get(ATTR_LANGUAGE) @@ -307,6 +327,7 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: client=client, status=status, visibility=visibility, + quote_approval_policy=quote_approval_policy, idempotency_key=idempotency_key, spoiler_text=spoiler_text, language=language, @@ -364,7 +385,7 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: ) from err -async def _async_update_profile(call: ServiceCall) -> ServiceResponse: +async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None: """Update profile information.""" params = dict(call.data.copy()) @@ -387,9 +408,21 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse: for field in fields if field[ATTR_NAME].strip() ] + delete_avatar = params.pop("delete_avatar", False) + delete_header = params.pop("delete_header", False) try: - return await call.hass.async_add_executor_job( - lambda: client.account_update_credentials(**params) + + def _update_profile() -> Any: + if delete_avatar: + client.account_delete_avatar() + if delete_header: + client.account_delete_header() + if call.return_response or params: + return client.account_update_credentials(**params) + return None + + response: Account | None = await call.hass.async_add_executor_job( + _update_profile ) except MastodonUnauthorizedError as error: entry.async_start_reauth(call.hass) @@ -403,6 +436,9 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse: translation_domain=DOMAIN, translation_key="unable_to_update_profile", ) from err + if call.return_response: + return response + return None async def _resolve_media( diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index ec637315c82..39e6f027c4c 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -50,6 +50,14 @@ post: - private - direct translation_key: post_visibility + quote_approval_policy: + selector: + select: + options: + - public + - followers + - nobody + translation_key: quote_approval_policy idempotency_key: selector: text: @@ -286,12 +294,24 @@ update_profile: media: accept: - "image/*" + delete_avatar: + required: false + selector: + constant: + value: true + label: "" header: required: false selector: media: accept: - "image/*" + delete_header: + required: false + selector: + constant: + value: true + label: "" locked: selector: boolean: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index c63f9168627..2529248a613 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -101,6 +101,9 @@ "auth_failed": { "message": "Authentication failed, please reauthenticate with Mastodon." }, + "failed_to_connect": { + "message": "Failed to connect." + }, "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, @@ -130,6 +133,9 @@ }, "unable_to_upload_image": { "message": "Unable to upload image {media_path}." + }, + "update_failed": { + "message": "Update failed." } }, "selector": { @@ -152,6 +158,13 @@ "public": "Public - Visible to everyone", "unlisted": "Unlisted - Public but not shown in public timelines" } + }, + "quote_approval_policy": { + "options": { + "followers": "Followers - Only accounts that follow you can quote this post", + "nobody": "Nobody - No one but you can quote this post", + "public": "Public - Anyone can quote this post" + } } }, "services": { @@ -222,6 +235,10 @@ "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning).", "name": "Media warning" }, + "quote_approval_policy": { + "description": "Who can quote this post (default: account setting).\nIgnored if visibility is private or direct.", + "name": "Who can quote" + }, "status": { "description": "The status to post.", "name": "Status" @@ -266,6 +283,14 @@ "description": "Select the Mastodon account to update the profile of.", "name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]" }, + "delete_avatar": { + "description": "Permanently removes your current profile picture.", + "name": "Delete profile picture" + }, + "delete_header": { + "description": "Permanently removes your current header picture.", + "name": "Delete header picture" + }, "discoverable": { "description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.", "name": "Discoverable" diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index 898578c931b..f48b01f5848 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -1,7 +1,5 @@ """Mastodon util functions.""" -from __future__ import annotations - import mimetypes from typing import Any diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 08924645f62..d2fd6810029 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -1,7 +1,5 @@ """The Matrix bot component.""" -from __future__ import annotations - import asyncio from collections.abc import Sequence import logging @@ -127,6 +125,12 @@ CONFIG_SCHEMA = vol.Schema( ) +def _read_image_size(image_path: str) -> tuple[int, int]: + """Open image to determine image size.""" + with Image.open(image_path) as image: + return image.size + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Matrix bot component.""" config = config[DOMAIN] @@ -227,7 +231,8 @@ class MatrixBot: else: command[CONF_ROOMS] = list(self._listening_rooms.values()) - # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD, CONF_EXPRESSION, or CONF_REACTION are set. + # COMMAND_SCHEMA guarantees that exactly one of + # CONF_WORD, CONF_EXPRESSION, or CONF_REACTION are set. if (word_command := command.get(CONF_WORD)) is not None: for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) @@ -243,7 +248,8 @@ class MatrixBot: async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" - # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + # Corresponds to message type 'm.text' and NOT other + # RoomMessage subtypes, like 'm.notice' and 'm.emote'. if not isinstance(message, (RoomMessageText, ReactionEvent)): return # Don't respond to our own messages. @@ -344,11 +350,12 @@ class MatrixBot: resolve_response, ) return {} - # The config schema guarantees it's a valid room alias or id, so room_id is always set. + # The config schema guarantees it's a valid room alias + # or id, so room_id is always set. return {room_alias_or_id: room_id} async def _resolve_room_aliases(self, listening_rooms: list[RoomAnyID]) -> None: - """Resolve any RoomAliases into RoomIDs for the purpose of client interactions.""" + """Resolve RoomAliases into RoomIDs for client interactions.""" resolved_rooms = [ self.hass.async_create_task( self._resolve_room_alias(room_alias_or_id), eager_start=False @@ -434,7 +441,8 @@ class MatrixBot: ) elif isinstance(response, WhoamiResponse): _LOGGER.debug( - "Successfully restored login from access token: user_id '%s', device_id '%s'", + "Successfully restored login from access token:" + " user_id '%s', device_id '%s'", response.user_id, response.device_id, ) @@ -504,8 +512,9 @@ class MatrixBot: return # Get required image metadata. - image = await self.hass.async_add_executor_job(Image.open, image_path) - (width, height) = image.size + (width, height) = await self.hass.async_add_executor_job( + _read_image_size, image_path + ) mime_type = mimetypes.guess_type(image_path)[0] file_stat = await aiofiles.os.stat(image_path) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 2ad943a8490..8755819e950 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==12.1.1", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==12.2.0", "aiofiles==24.1.0"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 0fc08e6c5aa..312d19c1cff 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -1,7 +1,5 @@ """Support for Matrix notifications.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/matrix/services.py b/homeassistant/components/matrix/services.py index 45dab85b4e6..25502937470 100644 --- a/homeassistant/components/matrix/services.py +++ b/homeassistant/components/matrix/services.py @@ -1,7 +1,5 @@ """The Matrix bot component.""" -from __future__ import annotations - from typing import TYPE_CHECKING import voluptuous as vol diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index ae005ebbf05..5db621637e2 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -1,9 +1,8 @@ """The Matter integration.""" -from __future__ import annotations - import asyncio from functools import cache +from typing import TYPE_CHECKING from matter_server.client import MatterClient from matter_server.client.exceptions import ( @@ -14,9 +13,10 @@ from matter_server.client.exceptions import ( ServerVersionTooOld, ) from matter_server.common.errors import MatterError, NodeNotExists +from yarl import URL from homeassistant.components.hassio import AddonError, AddonManager, AddonState -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -35,6 +35,7 @@ from .api import async_register_api from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER from .discovery import SUPPORTED_PLATFORMS from .helpers import ( + MatterConfigEntry, MatterEntryData, get_matter, get_node_from_device_entry, @@ -43,8 +44,12 @@ from .helpers import ( from .models import MatterDeviceInfo from .services import async_setup_services +if TYPE_CHECKING: + from matter_ble_proxy import MatterBleProxy + CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +BLE_PROXY_CONNECT_TIMEOUT = 10 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,14 +60,13 @@ def get_matter_device_info( hass: HomeAssistant, device_id: str ) -> MatterDeviceInfo | None: """Return Matter device info or None if device does not exist.""" - # Test hass.data[DOMAIN] to ensure config entry is set up - if not hass.data.get(DOMAIN, False) or not ( + if not hass.config_entries.async_loaded_entries(DOMAIN) or not ( node := node_from_ha_device_id(hass, device_id) ): return None return MatterDeviceInfo( - unique_id=node.device_info.uniqueID, + unique_id=node.device_info.uniqueID or "", vendor_id=hex(node.device_info.vendorID), product_id=hex(node.device_info.productID), ) @@ -74,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MatterConfigEntry) -> bool: """Set up Matter from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): await _async_ensure_addon_running(hass, entry) @@ -119,8 +123,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_delete_issue(hass, DOMAIN, "server_version_version_too_old") async_delete_issue(hass, DOMAIN, "server_version_version_too_new") + ble_proxy: MatterBleProxy | None = None + async def on_hass_stop(event: Event) -> None: """Handle incoming stop event from Home Assistant.""" + if ble_proxy is not None: + try: + await ble_proxy.disconnect() + except Exception: # noqa: BLE001 + LOGGER.exception( + "Failed to disconnect BLE proxy during Home Assistant stop" + ) await matter_client.disconnect() entry.async_on_unload( @@ -152,32 +165,96 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listen_task.cancel() raise ConfigEntryNotReady("Failed to set default fabric label") from err - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - # create an intermediate layer (adapter) which keeps track of the nodes # and discovery of platform entities from the node attributes matter = MatterAdapter(hass, matter_client, entry) - hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task) - await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) - await matter.setup_nodes() + # Gate on `ble_proxy_enabled`, not `bluetooth_enabled`: the latter is also true + # when the server uses a local BLE adapter, where no `/ble` endpoint exists. + # Importing `.ble_proxy` lazily here avoids pulling `matter_ble_proxy` + `bleak` + # into every Matter setup when the server has BLE proxy disabled. + server_info = matter_client.server_info + if server_info and server_info.ble_proxy_enabled: + if "bluetooth" not in hass.config.components: + LOGGER.warning( + "Matter server reports BLE proxy support but Home Assistant's " + "bluetooth integration is not loaded; BLE proxy will not be used" + ) + elif (ble_proxy_url := _derive_ble_proxy_url(entry.data[CONF_URL])) is None: + LOGGER.warning( + "Could not derive BLE proxy endpoint from %s; BLE proxy will not be used", + entry.data[CONF_URL], + ) + else: + from .ble_proxy import create_matter_ble_proxy # noqa: PLC0415 - # If the listen task is already failed, we need to raise ConfigEntryNotReady - if listen_task.done() and (listen_error := listen_task.exception()) is not None: - await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id) - try: - await matter_client.disconnect() - finally: - raise ConfigEntryNotReady(listen_error) from listen_error + LOGGER.debug("Matter server reports BLE available, connecting BLE proxy") + ble_proxy = create_matter_ble_proxy(hass, ble_proxy_url) + try: + async with asyncio.timeout(BLE_PROXY_CONNECT_TIMEOUT): + await ble_proxy.connect() + except (TimeoutError, ConnectionError, OSError) as err: + LOGGER.warning( + "Failed to connect BLE proxy - BLE commissioning may not work: %s", + err, + ) + ble_proxy = None + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error connecting BLE proxy") + ble_proxy = None - return True + entry.runtime_data = MatterEntryData(matter, listen_task, ble_proxy) + + setup_error: BaseException | None = None + try: + await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) + await matter.setup_nodes() + except Exception as err: # noqa: BLE001 + # Platform/node setup raised. Cancel the listen task so the cleanup + # block below tears down the matter client and BLE proxy alongside + # the partially-loaded platforms, then surfaces this error. + listen_task.cancel() + setup_error = err + else: + if listen_task.done() and (listen_err := listen_task.exception()) is not None: + setup_error = listen_err + + if setup_error is None: + return True + + await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS) + try: + if ble_proxy is not None: + try: + await ble_proxy.disconnect() + except Exception: # noqa: BLE001 + LOGGER.exception("Failed to disconnect BLE proxy during setup abort") + await matter_client.disconnect() + finally: + raise ConfigEntryNotReady(setup_error) from setup_error + + +def _derive_ble_proxy_url(matter_ws_url: str) -> str | None: + """Derive the `/ble` endpoint URL by swapping the trailing `/ws` path segment. + + Uses real URL parsing so hostnames containing `ws` aren't corrupted. Returns + `None` when the path does not match the expected `/ws` shape, so callers can + skip BLE proxy setup instead of probing a wrong endpoint. + """ + parsed = URL(matter_ws_url) + path = parsed.path.rstrip("/") + if path.endswith("/ws"): + new_path = f"{path[:-3]}/ble" + elif not path: + new_path = "/ble" + else: + return None + return str(parsed.with_path(new_path)) async def _client_listen( hass: HomeAssistant, - entry: ConfigEntry, + entry: MatterConfigEntry, matter_client: MatterClient, init_ready: asyncio.Event, ) -> None: @@ -185,13 +262,13 @@ async def _client_listen( try: await matter_client.start_listening(init_ready) except MatterError as err: - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise if not hass.is_stopping: @@ -199,16 +276,23 @@ async def _client_listen( hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MatterConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( entry, SUPPORTED_PLATFORMS ) if unload_ok: - matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id) - matter_entry_data.listen_task.cancel() - await matter_entry_data.adapter.matter_client.disconnect() + # Disconnect the BLE proxy first so it stops accepting new GATT/BTP + # traffic before the matter client (which originates that traffic) is + # torn down. + if (ble_proxy := entry.runtime_data.ble_proxy) is not None: + try: + await ble_proxy.disconnect() + except Exception: # noqa: BLE001 + LOGGER.exception("Failed to disconnect BLE proxy during unload") + entry.runtime_data.listen_task.cancel() + await entry.runtime_data.adapter.matter_client.disconnect() if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: addon_manager: AddonManager = get_addon_manager(hass) @@ -222,7 +306,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: MatterConfigEntry) -> None: """Config entry is being removed.""" if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): @@ -246,7 +330,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: def _remove_via_devices( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: MatterConfigEntry, device_entry: dr.DeviceEntry ) -> None: """Remove all via devices associated with a device.""" device_registry = dr.async_get(hass) @@ -259,7 +343,7 @@ def _remove_via_devices( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: MatterConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" node = get_node_from_device_entry(hass, device_entry) @@ -288,7 +372,9 @@ async def async_remove_config_entry_device( return True -async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_ensure_addon_running( + hass: HomeAssistant, entry: MatterConfigEntry +) -> None: """Ensure that Matter Server add-on is installed and running.""" addon_manager = _get_addon_manager(hass) try: @@ -298,14 +384,14 @@ async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) - addon_state = addon_info.state - if addon_state == AddonState.NOT_INSTALLED: + if addon_state is AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( addon_info.options, catch_error=True, ) raise ConfigEntryNotReady - if addon_state == AddonState.NOT_RUNNING: + if addon_state is AddonState.NOT_RUNNING: addon_manager.async_schedule_start_addon(catch_error=True) raise ConfigEntryNotReady diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index dad780d9a87..c7955e58f07 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -1,14 +1,11 @@ """Matter to Home Assistant adapter.""" -from __future__ import annotations - from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from matter_server.client.models.device_types import BridgedNode from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -16,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER from .discovery import async_discover_entities -from .helpers import get_device_id +from .helpers import MatterConfigEntry, get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient @@ -38,7 +35,7 @@ class MatterAdapter: self, hass: HomeAssistant, matter_client: MatterClient, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, ) -> None: """Initialize the adapter.""" self.matter_client = matter_client diff --git a/homeassistant/components/matter/addon.py b/homeassistant/components/matter/addon.py index a463685a073..25ffd03d3e1 100644 --- a/homeassistant/components/matter/addon.py +++ b/homeassistant/components/matter/addon.py @@ -1,7 +1,5 @@ """Provide add-on management.""" -from __future__ import annotations - from homeassistant.components.hassio import AddonManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 39597bc2ab2..fce05ca6c37 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,7 +1,5 @@ """Handle websocket api for Matter.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate @@ -278,7 +276,7 @@ async def websocket_open_commissioning_window( matter: MatterAdapter, node: MatterNode, ) -> None: - """Open a commissioning window to commission a device present on this controller to another.""" + """Open a commissioning window to commission a device to another.""" result = await matter.matter_client.open_commissioning_window(node_id=node.node_id) connection.send_result(msg[ID], dataclass_to_dict(result)) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 84ed60d580b..4cd90a849e7 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,7 +1,5 @@ """Matter binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -15,23 +13,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter binary sensor from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) @@ -61,6 +58,9 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): self._attr_is_on = value +_PUMP_STATUS = clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap +_VALVE_FAULT = clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ # device specific: translate Hue motion to sensor to HA Motion sensor @@ -377,11 +377,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # DeviceFault or SupplyFault bit enabled device_to_ha=lambda x: bool( - x - & ( - clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault - | clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault - ) + x & (_PUMP_STATUS.kDeviceFault | _PUMP_STATUS.kSupplyFault) ), ), entity_class=MatterBinarySensor, @@ -396,10 +392,7 @@ DISCOVERY_SCHEMAS = [ key="PumpStatusRunning", translation_key="pump_running", device_class=BinarySensorDeviceClass.RUNNING, - device_to_ha=lambda x: bool( - x - & clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning - ), + device_to_ha=lambda x: bool(x & _PUMP_STATUS.kRunning), ), entity_class=MatterBinarySensor, required_attributes=( @@ -445,10 +438,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, # GeneralFault bit from ValveFault attribute - device_to_ha=lambda x: bool( - x - & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault - ), + device_to_ha=lambda x: bool(x & _VALVE_FAULT.kGeneralFault), ), entity_class=MatterBinarySensor, required_attributes=( @@ -464,10 +454,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, # Blocked bit from ValveFault attribute - device_to_ha=lambda x: bool( - x - & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked - ), + device_to_ha=lambda x: bool(x & _VALVE_FAULT.kBlocked), ), entity_class=MatterBinarySensor, required_attributes=( @@ -483,10 +470,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, # Leaking bit from ValveFault attribute - device_to_ha=lambda x: bool( - x - & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking - ), + device_to_ha=lambda x: bool(x & _VALVE_FAULT.kLeaking), ), entity_class=MatterBinarySensor, required_attributes=( @@ -531,8 +515,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # LocalTemperature bit from RemoteSensing attribute device_to_ha=lambda x: bool( - x - & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kLocalTemperature # Calculated Local Temperature is derived from a remote node + x & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kLocalTemperature ), ), entity_class=MatterBinarySensor, @@ -547,8 +530,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # OutdoorTemperature bit from RemoteSensing attribute device_to_ha=lambda x: bool( - x - & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOutdoorTemperature # OutdoorTemperature is derived from a remote node + x & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOutdoorTemperature ), ), entity_class=MatterBinarySensor, @@ -566,8 +548,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # Occupancy bit from RemoteSensing attribute device_to_ha=lambda x: bool( - x - & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOccupancy # Occupancy is derived from a remote node + x & clusters.Thermostat.Bitmaps.RemoteSensingBitmap.kOccupancy ), ), entity_class=MatterBinarySensor, @@ -575,4 +556,48 @@ DISCOVERY_SCHEMAS = [ featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy, allow_multi=True, ), + # GeneralDiagnostics active fault sensors + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="GeneralDiagnosticsActiveHardwareFaults", + translation_key="active_hardware_faults", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_to_ha=bool, + ), + entity_class=MatterBinarySensor, + required_attributes=( + clusters.GeneralDiagnostics.Attributes.ActiveHardwareFaults, + ), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="GeneralDiagnosticsActiveRadioFaults", + translation_key="active_radio_faults", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_to_ha=bool, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.GeneralDiagnostics.Attributes.ActiveRadioFaults,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="GeneralDiagnosticsActiveNetworkFaults", + translation_key="active_network_faults", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_to_ha=bool, + ), + entity_class=MatterBinarySensor, + required_attributes=( + clusters.GeneralDiagnostics.Attributes.ActiveNetworkFaults, + ), + ), ] diff --git a/homeassistant/components/matter/ble_proxy.py b/homeassistant/components/matter/ble_proxy.py new file mode 100644 index 00000000000..ed68476e693 --- /dev/null +++ b/homeassistant/components/matter/ble_proxy.py @@ -0,0 +1,122 @@ +"""BLE proxy client for the Matter integration. + +Thin Home Assistant adapter around the `matter_ble_proxy` library: the protocol +logic, command dispatch, binary frame handling, and connection bookkeeping live +in the library; this module only provides HA-specific `BleScanSource` and +`BleDeviceResolver` backends that wire into Home Assistant's bluetooth +component (which transparently supports ESPHome BLE proxies). + +See `docs/ble-proxy-protocol.md` in the matter-server repository for the +protocol specification. +""" + +from collections.abc import Callable +import logging + +from bleak.backends.device import BLEDevice +from home_assistant_bluetooth import BluetoothServiceInfoBleak +from matter_ble_proxy import ( + AdvertisementData, + BleDeviceResolver, + BleScanSource, + MatterBleProxy, +) + +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + BluetoothScanningMode, + async_ble_device_from_address, + async_register_callback, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +_LOGGER = logging.getLogger(__name__) + + +class HaBluetoothScanSource(BleScanSource): + """`BleScanSource` backed by Home Assistant's bluetooth component. + + HA owns the BLE adapter; we only register an advertisement callback so the + adapter is never started/stopped from here. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._cancel: CALLBACK_TYPE | None = None + + async def start( # pylint: disable=arguments-renamed + self, callback_fn: Callable[[AdvertisementData], None] + ) -> None: + """Register an advertisement callback with HA's bluetooth component.""" + if self._cancel is not None: + return + + # Drop HA's synchronous replay of stale history on register; otherwise a + # rotating peripheral's old addresses each become a parallel connect candidate. + # `MONOTONIC_TIME` is the clock that stamps `service_info.time`. + scan_start = MONOTONIC_TIME() + + @callback + def _on_advertisement( + service_info: BluetoothServiceInfoBleak, + _change: object, + ) -> None: + if service_info.time < scan_start: + return + try: + callback_fn(_to_advertisement_data(service_info)) + except Exception: + _LOGGER.exception("BLE proxy advertisement forward failed") + + self._cancel = async_register_callback( + self._hass, + _on_advertisement, + None, + BluetoothScanningMode.PASSIVE, + ) + + async def stop(self) -> None: + """Unregister the advertisement callback.""" + if self._cancel is not None: + self._cancel() + self._cancel = None + + +class HaBluetoothDeviceResolver(BleDeviceResolver): + """`BleDeviceResolver` that asks HA's bluetooth registry for a `BLEDevice`.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + + async def resolve(self, address: str) -> BLEDevice | None: + """Return HA's cached BLEDevice for `address`, or None if unknown.""" + return async_ble_device_from_address(self._hass, address, connectable=True) + + +def _to_advertisement_data( + service_info: BluetoothServiceInfoBleak, +) -> AdvertisementData: + """Translate HA's `BluetoothServiceInfoBleak` to the library's wire type.""" + return AdvertisementData( + address=service_info.address, + name=service_info.name, + rssi=service_info.rssi, + connectable=service_info.connectable, + service_data=dict(service_info.service_data), + manufacturer_data=dict(service_info.manufacturer_data), + service_uuids=list(service_info.service_uuids), + ) + + +def create_matter_ble_proxy(hass: HomeAssistant, ws_url: str) -> MatterBleProxy: + """Return a `MatterBleProxy` wired into Home Assistant's bluetooth component.""" + return MatterBleProxy( + ws_url=ws_url, + scan_source=HaBluetoothScanSource(hass), + device_resolver=HaBluetoothDeviceResolver(hass), + task_factory=lambda coro: hass.async_create_background_task( + coro, name="matter_ble_proxy" + ), + ) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 11a364622e3..53ee04b5a40 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -1,35 +1,33 @@ """Matter Button platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters +from matter_server.common.custom_clusters import HeimanCluster from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Button platform.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.BUTTON, async_add_entities) @@ -169,4 +167,15 @@ DISCOVERY_SCHEMAS = [ value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id, allow_multi=True, # Also used in water_heater ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="HeimanSmokeCoAlarmTemporaryMuteRequest", + translation_key="temporary_mute_request", + command=HeimanCluster.Commands.MutingSensor, + ), + entity_class=MatterCommandButton, + required_attributes=(HeimanCluster.Attributes.AcceptedCommandList,), + value_contains=HeimanCluster.Commands.MutingSensor.command_id, + ), ] diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 4b508a57954..5c46cda2619 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -1,7 +1,5 @@ """Matter climate platform.""" -from __future__ import annotations - from dataclasses import dataclass from enum import IntEnum from typing import Any @@ -26,13 +24,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema HUMIDITY_SCALING_FACTOR = 100 @@ -194,11 +191,11 @@ class ThermostatRunningState(IntEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter climate platform from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.CLIMATE, async_add_entities) @@ -221,8 +218,10 @@ class MatterClimate(MatterEntity, ClimateEntity): def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the climate entity.""" - # Initialize preset handle mapping as instance attribute before calling super().__init__() - # because MatterEntity.__init__() calls _update_from_device() which needs this attribute + # Initialize preset handle mapping as instance attribute + # before calling super().__init__() because + # MatterEntity.__init__() calls _update_from_device() + # which needs this attribute self._matter_presets = [] self._preset_handle_by_name: dict[str, bytes | None] = {} self._preset_name_by_handle: dict[bytes | None, str] = {} @@ -251,7 +250,7 @@ class MatterClimate(MatterEntity, ClimateEntity): clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) await self.write_attribute( - value=int(target_temperature * TEMPERATURE_SCALING_FACTOR), + value=round(target_temperature * TEMPERATURE_SCALING_FACTOR), matter_attribute=matter_attribute, ) return @@ -260,7 +259,7 @@ class MatterClimate(MatterEntity, ClimateEntity): # multi setpoint control - low setpoint (heat) if self.target_temperature_low != target_temperature_low: await self.write_attribute( - value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR), + value=round(target_temperature_low * TEMPERATURE_SCALING_FACTOR), matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, ) @@ -268,7 +267,7 @@ class MatterClimate(MatterEntity, ClimateEntity): # multi setpoint control - high setpoint (cool) if self.target_temperature_high != target_temperature_high: await self.write_attribute( - value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR), + value=round(target_temperature_high * TEMPERATURE_SCALING_FACTOR), matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) @@ -365,7 +364,8 @@ class MatterClimate(MatterEntity, ClimateEntity): self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) or [] ) - # Build preset mapping: use device-provided name if available, else generate unique name + # Build preset mapping: use device-provided name if + # available, else generate unique name self._preset_handle_by_name.clear() self._preset_name_by_handle.clear() if self._matter_presets: @@ -415,8 +415,10 @@ class MatterClimate(MatterEntity, ClimateEntity): def _update_hvac_mode_and_action(self) -> None: """Update HVAC mode and action from device.""" if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: - # special case: the appliance has a dedicated Power switch on the OnOff cluster - # if the mains power is off - treat it as if the HVAC mode is off + # special case: the appliance has a dedicated Power + # switch on the OnOff cluster + # if the mains power is off - treat it as if the + # HVAC mode is off self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = None else: diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 0c73ccd4089..5f8654c99b3 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Matter integration.""" -from __future__ import annotations - import asyncio from typing import Any @@ -290,10 +288,10 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): addon_info = await self._async_get_addon_info() - if addon_info.state == AddonState.RUNNING: + if addon_info.state is AddonState.RUNNING: return await self.async_step_finish_addon_setup() - if addon_info.state == AddonState.NOT_RUNNING: + if addon_info.state is AddonState.NOT_RUNNING: return await self.async_step_start_addon() return await self.async_step_install_addon() diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2d81577772a..710174e3287 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -1,7 +1,5 @@ """Matter cover.""" -from __future__ import annotations - from dataclasses import dataclass from enum import IntEnum from math import floor @@ -17,14 +15,13 @@ from homeassistant.components.cover import ( CoverEntityDescription, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema # The MASK used for extracting bits 0 to 1 of the byte. @@ -35,7 +32,9 @@ TYPE_MAP = { clusters.WindowCovering.Enums.Type.kRollerShade: CoverDeviceClass.SHADE, clusters.WindowCovering.Enums.Type.kRollerShade2Motor: CoverDeviceClass.SHADE, clusters.WindowCovering.Enums.Type.kRollerShadeExterior: CoverDeviceClass.SHADE, - clusters.WindowCovering.Enums.Type.kRollerShadeExterior2Motor: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior2Motor: ( + CoverDeviceClass.SHADE + ), clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, clusters.WindowCovering.Enums.Type.kTiltBlindTiltOnly: CoverDeviceClass.BLIND, @@ -44,7 +43,7 @@ TYPE_MAP = { class OperationalStatus(IntEnum): - """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + """Ongoing operations enumeration for coverings per Matter spec.""" COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 COVERING_IS_CURRENTLY_OPENING = 0b01 @@ -54,11 +53,11 @@ class OperationalStatus(IntEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Cover from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.COVER, async_add_entities) @@ -74,7 +73,7 @@ class MatterCover(MatterEntity, CoverEntity): @property def is_closed(self) -> bool | None: - """Return true if cover is closed, if there is no position report, return None.""" + """Return true if cover is closed, None if no position.""" if not self._entity_info.endpoint.has_attribute( None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths ): diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 23b6854c791..71e06282e1f 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -1,21 +1,19 @@ """Provide diagnostics for Matter.""" -from __future__ import annotations - from copy import deepcopy from typing import Any from chip.clusters import Objects from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path -from homeassistant.components.diagnostics import REDACTED -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .helpers import get_matter, get_node_from_device_entry +from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location} +SERVER_INFO_TO_REDACT = {"wifi_ssid"} def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]: @@ -41,12 +39,13 @@ def remove_serialization_type(data: dict[str, Any]) -> dict[str, Any]: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MatterConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" matter = get_matter(hass) server_diagnostics = await matter.matter_client.get_diagnostics() data = dataclass_to_dict(server_diagnostics) + data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT) nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]] data["nodes"] = nodes @@ -54,7 +53,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: MatterConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" matter = get_matter(hass) @@ -62,7 +61,9 @@ async def async_get_device_diagnostics( node = get_node_from_device_entry(hass, device) return { - "server_info": dataclass_to_dict(server_diagnostics.info), + "server_info": async_redact_data( + dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT + ), "node": redact_matter_attributes( remove_serialization_type(dataclass_to_dict(node.node_data) if node else {}) ), diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 278eb8b7e83..498b54ef7cd 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -1,7 +1,5 @@ """Map Matter Nodes and Attributes to Home Assistant entities.""" -from __future__ import annotations - from collections.abc import Generator from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, NullValue @@ -23,6 +21,7 @@ from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS +from .siren import DISCOVERY_SCHEMAS as SIREN_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS from .vacuum import DISCOVERY_SCHEMAS as VACUUM_SCHEMAS @@ -41,6 +40,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.NUMBER: NUMBER_SCHEMAS, Platform.SELECT: SELECT_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, + Platform.SIREN: SIREN_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, Platform.UPDATE: UPDATE_SCHEMAS, Platform.VACUUM: VACUUM_SCHEMAS, @@ -146,7 +146,8 @@ def async_discover_entities( continue # BEGIN checks on actual attribute values - # these are the least likely to be used and least efficient, so they are checked last + # these are the least likely to be used and least + # efficient, so they are checked last # check if PRIMARY value exists but is none/null if not schema.allow_none_value and any( diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 80a50491e46..4159d7a171e 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -1,7 +1,5 @@ """Matter entity base class.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import functools @@ -125,8 +123,11 @@ class MatterEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) - self._attr_available = self._endpoint.node.available - # mark endpoint postfix if the device has the primary attribute on multiple endpoints + self._attr_available = ( + self._endpoint.node.available and self._get_bridged_reachable() + ) + # mark endpoint postfix if the device has the primary + # attribute on multiple endpoints if not self._endpoint.node.is_bridge_device and any( ep for ep in self._endpoint.node.endpoints.values() @@ -212,6 +213,44 @@ class MatterEntity(Entity): node_filter=self._endpoint.node.node_id, ) ) + # Subscribe to BridgedDeviceBasicInformation Reachable + # attribute (AttributeId: 17) for devices connected via a + # Matter bridge, to reflect real reachability status. + if self._endpoint.has_attribute( + None, clusters.BridgedDeviceBasicInformation.Attributes.Reachable + ): + reachable_attr_path = self.get_matter_attribute_path( + clusters.BridgedDeviceBasicInformation.Attributes.Reachable + ) + if reachable_attr_path not in sub_paths: + sub_paths.append(reachable_attr_path) + self._unsubscribes.append( + self.matter_client.subscribe_events( + callback=self._on_matter_event, + event_filter=EventType.ATTRIBUTE_UPDATED, + node_filter=self._endpoint.node.node_id, + attr_path_filter=reachable_attr_path, + ) + ) + # If we are a composed device subscribe to the parent's Reachable attribute + if self._compose_parent is not None and self._compose_parent.has_attribute( + None, clusters.BridgedDeviceBasicInformation.Attributes.Reachable + ): + parent_reachable_attr_path = create_attribute_path( + self._compose_parent.endpoint_id, + clusters.BridgedDeviceBasicInformation.Attributes.Reachable.cluster_id, + clusters.BridgedDeviceBasicInformation.Attributes.Reachable.attribute_id, + ) + if parent_reachable_attr_path not in sub_paths: + sub_paths.append(parent_reachable_attr_path) + self._unsubscribes.append( + self.matter_client.subscribe_events( + callback=self._on_matter_event, + event_filter=EventType.ATTRIBUTE_UPDATED, + node_filter=self._compose_parent.node.node_id, + attr_path_filter=parent_reachable_attr_path, + ) + ) # subscribe to FeatureMap attribute (as that can dynamically change) self._unsubscribes.append( self.matter_client.subscribe_events( @@ -237,10 +276,39 @@ class MatterEntity(Entity): name = f"{name} ({self._name_postfix})" return name + @cached_property + def _compose_parent(self) -> MatterEndpoint | None: + """Return the composed parent endpoint, if any.""" + return self._endpoint.node.get_compose_parent(self._endpoint.endpoint_id) + + @callback + def _get_bridged_reachable(self) -> bool: + """Return reachability state for bridged endpoints, True if not applicable.""" + # if we are the endpoint of a composed device, we have to check the + # parent endpoint's reachable attribute + if self._compose_parent is not None: + compose_parent_reachable = self._compose_parent.get_attribute_value( + None, clusters.BridgedDeviceBasicInformation.Attributes.Reachable + ) + # assume unreachable only if there is an attribute present that + # explicitly states reachable=false for the parent + if compose_parent_reachable is not None and not compose_parent_reachable: + return False + # check if our endpoint has a reachable attribute + # absence of reachable attribute is assumed as reachable (non-bridged devices) + reachable = self.get_matter_attribute_value( + clusters.BridgedDeviceBasicInformation.Attributes.Reachable + ) + if reachable is None: + return True + return bool(reachable) + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: """Call on update from the device.""" - self._attr_available = self._endpoint.node.available + self._attr_available = ( + self._endpoint.node.available and self._get_bridged_reachable() + ) self._update_from_device() self.async_write_ha_state() @@ -312,7 +380,7 @@ class MatterEntity(Entity): ) -> Any: """Write an attribute(value) on the primary endpoint. - If matter_attribute is not provided, the primary attribute of the entity is used. + If matter_attribute is not provided, the primary attribute is used. """ if matter_attribute is None: matter_attribute = self._entity_info.primary_attribute diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index d840daad8ba..13eee8aa3d1 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -1,7 +1,5 @@ """Matter event entities from Node events.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -14,13 +12,12 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema SwitchFeature = clusters.Switch.Bitmaps.Feature @@ -39,11 +36,11 @@ EVENT_TYPES_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter switches from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.EVENT, async_add_entities) @@ -82,7 +79,8 @@ class MatterEventEntity(MatterEntity, EventEntity): # momentary switch without multi press support event_types.append("initial_press") if feature_map & SwitchFeature.kMomentarySwitchRelease: - # momentary switch without multi press support can optionally support release + # momentary switch without multi press support + # can optionally support release event_types.append("short_release") # a momentary switch can optionally support long press diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 6195030a740..724c2e3f658 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -1,7 +1,5 @@ """Matter Fan platform support.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -14,13 +12,12 @@ from homeassistant.components.fan import ( FanEntityDescription, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema FanControlFeature = clusters.FanControl.Bitmaps.Feature @@ -45,11 +42,11 @@ PRESET_SLEEP_WIND = "sleep_wind" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter fan from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.FAN, async_add_entities) @@ -173,8 +170,10 @@ class MatterFan(MatterEntity, FanEntity): self._calculate_features() if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: - # special case: the appliance has a dedicated Power switch on the OnOff cluster - # if the mains power is off - treat it as if the fan mode is off + # special case: the appliance has a dedicated Power + # switch on the OnOff cluster + # if the mains power is off - treat it as if the + # fan mode is off self._attr_preset_mode = None self._attr_percentage = 0 return @@ -254,8 +253,10 @@ class MatterFan(MatterEntity, FanEntity): return self._feature_map = feature_map self._attr_supported_features = FanEntityFeature(0) + # Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed + # does not leave a stale speed_count / percentage_step. + self._attr_speed_count = 100 if feature_map & FanControlFeature.kMultiSpeed: - self._attr_supported_features |= FanEntityFeature.SET_SPEED self._attr_speed_count = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) ) @@ -305,8 +306,12 @@ class MatterFan(MatterEntity, FanEntity): if feature_map & FanControlFeature.kAirflowDirection: self._attr_supported_features |= FanEntityFeature.DIRECTION + # PercentSetting is always a mandatory attribute of the FanControl cluster, + # so percentage-based speed control is always available. self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON ) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index fc06bfd4822..91a680e46c5 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -1,11 +1,10 @@ """Provide integration helpers that are aware of the matter integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from typing import TYPE_CHECKING +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -13,6 +12,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID if TYPE_CHECKING: + from matter_ble_proxy import MatterBleProxy from matter_server.client.models.node import MatterEndpoint, MatterNode from matter_server.common.models import ServerInfoMessage @@ -29,6 +29,10 @@ class MatterEntryData: adapter: MatterAdapter listen_task: asyncio.Task + ble_proxy: MatterBleProxy | None = None + + +type MatterConfigEntry = ConfigEntry[MatterEntryData] @callback @@ -37,8 +41,8 @@ def get_matter(hass: HomeAssistant) -> MatterAdapter: # NOTE: This assumes only one Matter connection/fabric can exist. # Shall we support connecting to multiple servers in the client or by # config entries? In case of the config entry we need to fix this. - matter_entry_data: MatterEntryData = next(iter(hass.data[DOMAIN].values())) - return matter_entry_data.adapter + entries: list[MatterConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return entries[0].runtime_data.adapter def get_operational_instance_id( diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index e5645a7fcdd..dc5f2a96061 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -102,7 +102,7 @@ "default": "mdi:home-lightning-bolt" }, "eve_weather_trend": { - "default": "mdi:weather", + "default": "mdi:weather-cloudy", "state": { "cloudy": "mdi:weather-cloudy", "rainy": "mdi:weather-rainy", diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 599f34bc9f4..defc753b912 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,7 +1,5 @@ """Matter light.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -23,7 +21,6 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,7 +28,7 @@ from homeassistant.util import color as color_util from .const import LOGGER from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema from .util import ( convert_to_hass_hs, @@ -41,14 +38,17 @@ from .util import ( renormalize, ) +_CC_COLOR_MODE = clusters.ColorControl.Enums.ColorModeEnum + COLOR_MODE_MAP = { - clusters.ColorControl.Enums.ColorModeEnum.kCurrentHueAndCurrentSaturation: ColorMode.HS, - clusters.ColorControl.Enums.ColorModeEnum.kCurrentXAndCurrentY: ColorMode.XY, - clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP, + _CC_COLOR_MODE.kCurrentHueAndCurrentSaturation: ColorMode.HS, + _CC_COLOR_MODE.kCurrentXAndCurrentY: ColorMode.XY, + _CC_COLOR_MODE.kColorTemperatureMireds: ColorMode.COLOR_TEMP, } # Maximum Mireds value per the Matter spec is 65279 -# Conversion between Kelvin and Mireds is 1,000,000 / Kelvin, so this corresponds to a minimum color temperature of ~15.3K +# Conversion between Kelvin and Mireds is 1,000,000 / Kelvin, +# so this corresponds to a minimum color temperature of ~15.3K # Which is shown in UI as 15 Kelvin due to rounding. # But converting 15 Kelvin back to Mireds gives 66666 which is above the maximum, # and causes Invoke error, so cap values over maximum when sending @@ -86,11 +86,11 @@ TRANSITION_BLOCKLIST = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Light from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.LIGHT, async_add_entities) @@ -364,24 +364,16 @@ class MatterLight(MatterEntity, LightEntity): assert capabilities is not None - if ( - capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kHueSaturation - ): + color_caps = clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap + if capabilities & color_caps.kHueSaturation: supported_color_modes.add(ColorMode.HS) self._supports_color = True - if ( - capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kXy - ): + if capabilities & color_caps.kXy: supported_color_modes.add(ColorMode.XY) self._supports_color = True - if ( - capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kColorTemperature - ): + if capabilities & color_caps.kColorTemperature: supported_color_modes.add(ColorMode.COLOR_TEMP) self._supports_color_temperature = True min_mireds = self.get_matter_attribute_value( @@ -402,7 +394,8 @@ class MatterLight(MatterEntity, LightEntity): supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes self._check_transition_blocklist() - # flag support for transition as soon as we support setting brightness and/or color + # flag support for transition as soon as we support + # setting brightness and/or color if ( supported_color_modes != {ColorMode.ONOFF} and not self._transitions_disabled @@ -464,7 +457,14 @@ class MatterLight(MatterEntity, LightEntity): self._transitions_disabled = True LOGGER.warning( "Detected a device that has been reported to have firmware issues " - "with light transitions. Transitions will be disabled for this light" + "with light transitions. Transitions will be disabled for this " + "light: %s %s (vendor_id: %s, product_id: %s, hw: %s, sw: %s)", + device_info.vendorName, + device_info.productName, + device_info.vendorID, + device_info.productID, + device_info.hardwareVersionString, + device_info.softwareVersionString, ) @@ -540,7 +540,8 @@ DISCOVERY_SCHEMAS = [ clusters.ColorControl.Attributes.CurrentSaturation, ), ), - # Additional schema to match (color temperature) lights with incorrect/missing device type + # Additional schema to match (color temperature) lights + # with incorrect/missing device type MatterDiscoverySchema( platform=Platform.LIGHT, entity_description=MatterLightEntityDescription( diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 80316ea8014..8b316a6867c 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -1,7 +1,5 @@ """Matter lock.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from typing import Any @@ -15,7 +13,6 @@ from homeassistant.components.lock import ( LockEntityDescription, LockEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -34,7 +31,7 @@ from .const import ( LOGGER, ) from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .lock_helpers import ( DoorLockFeature, GetLockCredentialStatusResult, @@ -70,11 +67,11 @@ DOOR_LOCK_OPERATION_SOURCE: dict[int, str] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter lock from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.LOCK, async_add_entities) diff --git a/homeassistant/components/matter/lock_helpers.py b/homeassistant/components/matter/lock_helpers.py index 1f95aba1987..6b1d4cb5dbd 100644 --- a/homeassistant/components/matter/lock_helpers.py +++ b/homeassistant/components/matter/lock_helpers.py @@ -4,8 +4,6 @@ Provides DoorLock cluster endpoint resolution, feature detection, and business logic for lock user/credential management. """ -from __future__ import annotations - from typing import TYPE_CHECKING, Any, TypedDict from chip.clusters import Objects as clusters @@ -71,6 +69,8 @@ class LockUserData(TypedDict): user_type: str credential_rule: str credentials: list[LockUserCredentialData] + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_user_index: int | None @@ -115,6 +115,8 @@ class GetLockCredentialStatusResult(TypedDict): credential_exists: bool user_index: int | None + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_credential_index: int | None @@ -214,6 +216,8 @@ def _format_user_response(user_data: Any) -> LockUserData | None: _get_attr(user_data, "credentialRule"), "unknown" ), credentials=credentials, + creator_fabric_index=_get_attr(user_data, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(user_data, "lastModifiedFabricIndex"), next_user_index=_get_attr(user_data, "nextUserIndex"), ) @@ -817,7 +821,8 @@ async def get_lock_credential_status( ) -> GetLockCredentialStatusResult: """Get the status of a credential slot on the lock. - Returns typed dict with credential_exists, user_index, next_credential_index. + Returns typed dict with credential_exists, user_index, creator_fabric_index, + last_modified_fabric_index, and next_credential_index. Raises HomeAssistantError on failure. """ lock_endpoint = _get_lock_endpoint_or_raise(node) @@ -839,5 +844,7 @@ async def get_lock_credential_status( return GetLockCredentialStatusResult( credential_exists=bool(_get_attr(response, "credentialExists")), user_index=_get_attr(response, "userIndex"), + creator_fabric_index=_get_attr(response, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(response, "lastModifiedFabricIndex"), next_credential_index=_get_attr(response, "nextCredentialIndex"), ) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 8274886cd11..28d4ae01685 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -1,13 +1,13 @@ { "domain": "matter", "name": "Matter", - "after_dependencies": ["hassio"], + "after_dependencies": ["bluetooth", "hassio"], "codeowners": ["@home-assistant/matter"], "config_flow": true, "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["matter-python-client==0.4.1"], + "requirements": ["matter-python-client==0.7.1", "matter-ble-proxy==0.7.1"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 50d0a5745da..df30a636bae 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,7 +1,5 @@ """Models used for the Matter integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, TypedDict diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 21be4cd90d1..c7bfd56a2fe 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -1,7 +1,5 @@ """Matter Number Inputs.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -17,7 +15,6 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -30,17 +27,17 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Number Input from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.NUMBER, async_add_entities) @@ -67,7 +64,8 @@ class MatterRangeNumberEntityDescription( # command: a custom callback to create the command to send to the device # the callback's argument will be the converted device value from ha_to_device - # if omitted the command will just be a write_attribute command to the primary attribute + # if omitted the command will just be a write_attribute + # command to the primary attribute command: Callable[[int], ClusterCommand] | None = None @@ -93,7 +91,7 @@ class MatterNumber(MatterEntity, NumberEntity): class MatterRangeNumber(MatterEntity, NumberEntity): - """Representation of a Matter Attribute as a Number entity with min and max values.""" + """Matter Attribute as a Number entity with min and max.""" entity_description: MatterRangeNumberEntityDescription @@ -114,7 +112,8 @@ class MatterRangeNumber(MatterEntity, NumberEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - # get the value from the primary attribute and convert it to the HA value if needed + # get the value from the primary attribute and convert + # it to the HA value if needed value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value_convert := self.entity_description.device_to_ha: value = value_convert(value) @@ -179,7 +178,6 @@ DISCOVERY_SCHEMAS = [ device_to_ha=lambda x: 255 if x is None else x, ha_to_device=lambda x: None if x == 255 else int(x), native_step=1, - native_unit_of_measurement=None, ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnLevel,), @@ -200,7 +198,6 @@ DISCOVERY_SCHEMAS = [ device_to_ha=lambda x: 255 if x is None else x, ha_to_device=lambda x: None if x == 255 else int(x), native_step=1, - native_unit_of_measurement=None, ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.StartUpCurrentLevel,), @@ -361,7 +358,7 @@ DISCOVERY_SCHEMAS = [ None if x is None else min(x, 200) / 2 ) # Matter range (1-200, capped at 200) ), - ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% + ha_to_device=lambda x: round(x * 2), # HA range 0.5-100.0% mode=NumberMode.SLIDER, ), entity_class=MatterLevelControlNumber, @@ -374,7 +371,8 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterNumberEntityDescription( key="PIROccupiedToUnoccupiedDelay", entity_category=EntityCategory.CONFIG, - translation_key="hold_time", # pir_occupied_to_unoccupied_delay for old revisions + # pir_occupied_to_unoccupied_delay for old revisions + translation_key="hold_time", native_max_value=65534, native_min_value=0, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -417,7 +415,8 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=( clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay, - # This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present + # This attribute is mandatory when + # PIRUnoccupiedToOccupiedDelay is present clusters.OccupancySensing.Attributes.HoldTime, ), featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, @@ -441,6 +440,31 @@ DISCOVERY_SCHEMAS = [ featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="BooleanStateConfigurationCurrentSensitivityLevel", + entity_category=EntityCategory.CONFIG, + translation_key="sensitivity_level", + native_min_value=1, + native_step=1, + device_to_ha=lambda x: x + 1, + ha_to_device=lambda x: int(x) - 1, + max_attribute=( + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels + ), + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels, + ), + featuremap_contains=( + clusters.BooleanStateConfiguration.Bitmaps.Feature.kSensitivityLevel + ), + allow_multi=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index a0c87cc4974..2986c95b33a 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -1,7 +1,5 @@ """Matter ModeSelect Cluster Support.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -11,20 +9,21 @@ from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterComm from chip.clusters.Types import Nullable from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema DOOR_LOCK_OPERATING_MODE_MAP = { clusters.DoorLock.Enums.OperatingModeEnum.kNormal: "normal", clusters.DoorLock.Enums.OperatingModeEnum.kVacation: "vacation", clusters.DoorLock.Enums.OperatingModeEnum.kPrivacy: "privacy", - clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock: "no_remote_lock_unlock", + clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock: ( + "no_remote_lock_unlock" + ), clusters.DoorLock.Enums.OperatingModeEnum.kPassage: "passage", } DOOR_LOCK_OPERATING_MODE_MAP_REVERSE = { @@ -66,11 +65,11 @@ type SelectCluster = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter ModeSelect from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.SELECT, async_add_entities) @@ -86,7 +85,8 @@ class MatterMapSelectEntityDescription(MatterSelectEntityDescription): device_to_ha: Callable[[int], str | None] ha_to_device: Callable[[str], int | None] - # list attribute: the attribute descriptor to get the list of values (= list of integers) + # list attribute: the attribute descriptor to get the list + # of values (= list of integers) list_attribute: type[ClusterAttributeDescriptor] @@ -94,11 +94,15 @@ class MatterMapSelectEntityDescription(MatterSelectEntityDescription): class MatterListSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterListSelectEntity.""" - # list attribute: the attribute descriptor to get the list of values (= list of strings) + # list attribute: the attribute descriptor to get the list + # of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] - # command: a custom callback to create the command to send to the device - # the callback's argument will be the index of the selected list value - # if omitted the command will just be a write_attribute command to the primary attribute + # command: a custom callback to create the command to send + # to the device + # the callback's argument will be the index of the selected + # list value + # if omitted the command will just be a write_attribute + # command to the primary attribute command: Callable[[int], ClusterCommand] | None = None @@ -128,7 +132,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): class MatterMapSelectEntity(MatterAttributeSelectEntity): - """Representation of a Matter select entity where the options are defined in a State map.""" + """Matter select entity where options are from a State map.""" entity_description: MatterMapSelectEntityDescription @@ -146,7 +150,8 @@ class MatterMapSelectEntity(MatterAttributeSelectEntity): for value in available_values if (mapped_value := self.entity_description.device_to_ha(value)) ] - # use base implementation from MatterAttributeSelectEntity to set the current option + # use base implementation from + # MatterAttributeSelectEntity to set the current option super()._update_from_device() @@ -188,7 +193,7 @@ class MatterDoorLockOperatingModeSelectEntity(MatterAttributeSelectEntity): This entity dynamically filters available operating modes based on the device's `SupportedOperatingModes` bitmap attribute. In this bitmap, bit=0 indicates a - supported mode and bit=1 indicates unsupported (inverted from typical bitmap conventions). + supported mode and bit=1 indicates unsupported (inverted from typical conventions). If the bitmap is unavailable, only mandatory modes are included. The mapping from bitmap bits to operating mode values is defined by the Matter specification. """ @@ -226,7 +231,7 @@ class MatterDoorLockOperatingModeSelectEntity(MatterAttributeSelectEntity): class MatterListSelectEntity(MatterEntity, SelectEntity): - """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" + """Select entity from Matter list and selected item attributes.""" entity_description: MatterListSelectEntityDescription @@ -560,17 +565,24 @@ DISCOVERY_SCHEMAS = [ clusters.PumpConfigurationAndControl.Attributes.OperationMode, ), ), + # Keep the legacy vendor-specific select entities until HA 2026.11.0, + # so existing users can migrate before we remove them in favor of the + # generic number slider. MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["10 mm", "20 mm", "30 mm"], device_to_ha={ - 0: "10 mm", # 10 mm => CurrentSensitivityLevel=0 / highest sensitivity level - 1: "20 mm", # 20 mm => CurrentSensitivityLevel=1 / medium sensitivity level - 2: "30 mm", # 30 mm => CurrentSensitivityLevel=2 / lowest sensitivity level + # CurrentSensitivityLevel=0 / highest + 0: "10 mm", + # CurrentSensitivityLevel=1 / medium + 1: "20 mm", + # CurrentSensitivityLevel=2 / lowest + 2: "30 mm", }.get, ha_to_device={ "10 mm": 0, @@ -584,12 +596,14 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4447,), product_id=(8194,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -612,12 +626,14 @@ DISCOVERY_SCHEMAS = [ 8197, 8195, ), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -637,6 +653,7 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4619,), product_id=(4097,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 36fdbc7d3f6..06edeafce7c 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,7 +1,5 @@ """Matter sensors.""" -from __future__ import annotations - from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import TYPE_CHECKING, cast @@ -23,13 +21,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, REVOLUTIONS_PER_MINUTE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, Platform, UnitOfApparentPower, @@ -50,7 +48,7 @@ from homeassistant.util import dt as dt_util, slugify from .const import CONCENTRATION_BECQUERELS_PER_CUBIC_METER from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema AIR_QUALITY_MAP = { @@ -71,14 +69,15 @@ CONTAMINATION_STATE_MAP = { clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", } +_tvoc = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement CONCENTRATION_LEVEL_MAP = { # enum with known Concentration Level values which we can translate - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kUnknown: None, - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kLow: "low", - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kMedium: "medium", - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kHigh: "high", - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kCritical: "critical", - clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Enums.LevelValueEnum.kUnknownEnumValue: None, + _tvoc.Enums.LevelValueEnum.kUnknown: None, + _tvoc.Enums.LevelValueEnum.kLow: "low", + _tvoc.Enums.LevelValueEnum.kMedium: "medium", + _tvoc.Enums.LevelValueEnum.kHigh: "high", + _tvoc.Enums.LevelValueEnum.kCritical: "critical", + _tvoc.Enums.LevelValueEnum.kUnknownEnumValue: None, } EVE_CLUSTER_WEATHER_MAP = { @@ -97,43 +96,68 @@ OPERATIONAL_STATE_MAP = { clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", } +_op_err = clusters.OperationalState.Enums.ErrorStateEnum OPERATIONAL_STATE_ERROR_MAP = { # enum with known Error state values which we can translate - clusters.OperationalState.Enums.ErrorStateEnum.kNoError: "no_error", - clusters.OperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume", - clusters.OperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation", - clusters.OperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state", + _op_err.kNoError: "no_error", + _op_err.kUnableToStartOrResume: "unable_to_start_or_resume", + _op_err.kUnableToCompleteOperation: ("unable_to_complete_operation"), + _op_err.kCommandInvalidInState: "command_invalid_in_state", } +_rvc_op = clusters.RvcOperationalState.Enums.OperationalStateEnum RVC_OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate **OPERATIONAL_STATE_MAP, - clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", - clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", - clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", + _rvc_op.kSeekingCharger: "seeking_charger", + _rvc_op.kCharging: "charging", + _rvc_op.kDocked: "docked", } +_rvc_err = clusters.RvcOperationalState.Enums.ErrorStateEnum RVC_OPERATIONAL_STATE_ERROR_MAP = { # enum with known Error state values which we can translate - clusters.RvcOperationalState.Enums.ErrorStateEnum.kNoError: "no_error", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kFailedToFindChargingDock: "failed_to_find_charging_dock", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kStuck: "stuck", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinMissing: "dust_bin_missing", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinFull: "dust_bin_full", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankEmpty: "water_tank_empty", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankMissing: "water_tank_missing", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankLidOpen: "water_tank_lid_open", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kMopCleaningPadMissing: "mop_cleaning_pad_missing", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kLowBattery: "low_battery", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kCannotReachTargetArea: "cannot_reach_target_area", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankFull: "dirty_water_tank_full", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankMissing: "dirty_water_tank_missing", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kWheelsJammed: "wheels_jammed", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kBrushJammed: "brush_jammed", - clusters.RvcOperationalState.Enums.ErrorStateEnum.kNavigationSensorObscured: "navigation_sensor_obscured", + _rvc_err.kNoError: "no_error", + _rvc_err.kUnableToStartOrResume: "unable_to_start_or_resume", + _rvc_err.kUnableToCompleteOperation: ("unable_to_complete_operation"), + _rvc_err.kCommandInvalidInState: "command_invalid_in_state", + _rvc_err.kFailedToFindChargingDock: ("failed_to_find_charging_dock"), + _rvc_err.kStuck: "stuck", + _rvc_err.kDustBinMissing: "dust_bin_missing", + _rvc_err.kDustBinFull: "dust_bin_full", + _rvc_err.kWaterTankEmpty: "water_tank_empty", + _rvc_err.kWaterTankMissing: "water_tank_missing", + _rvc_err.kWaterTankLidOpen: "water_tank_lid_open", + _rvc_err.kMopCleaningPadMissing: "mop_cleaning_pad_missing", + _rvc_err.kLowBattery: "low_battery", + _rvc_err.kCannotReachTargetArea: "cannot_reach_target_area", + _rvc_err.kDirtyWaterTankFull: "dirty_water_tank_full", + _rvc_err.kDirtyWaterTankMissing: "dirty_water_tank_missing", + _rvc_err.kWheelsJammed: "wheels_jammed", + _rvc_err.kBrushJammed: "brush_jammed", + _rvc_err.kNavigationSensorObscured: ("navigation_sensor_obscured"), +} + +THREAD_ROUTING_ROLE_MAP = { + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnspecified: "unspecified", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnassigned: "unassigned", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kSleepyEndDevice: "sleepy_end_device", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kEndDevice: "end_device", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kReed: "reed", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kRouter: "router", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kLeader: "leader", + clusters.ThreadNetworkDiagnostics.Enums.RoutingRoleEnum.kUnknownEnumValue: "unknown", +} + +BOOT_REASON_MAP = { + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnspecified: "unspecified", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kPowerOnReboot: "power_on_reboot", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kBrownOutReset: "brown_out_reset", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareWatchdogReset: "software_watchdog_reset", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kHardwareWatchdogReset: "hardware_watchdog_reset", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareUpdateCompleted: "software_update_completed", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareReset: "software_reset", + clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnknownEnumValue: None, } BOOST_STATE_MAP = { @@ -161,7 +185,9 @@ ESA_STATE_MAP = { clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kFault: "fault", - clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPowerAdjustActive: "power_adjust_active", + clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPowerAdjustActive: ( + "power_adjust_active" + ), clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kPaused: "paused", } @@ -184,14 +210,15 @@ EVSE_FAULT_STATE_MAP = { clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", } +_pump_ctrl = clusters.PumpConfigurationAndControl.Enums.ControlModeEnum PUMP_CONTROL_MODE_MAP = { - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantSpeed: "constant_speed", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantPressure: "constant_pressure", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kProportionalPressure: "proportional_pressure", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantFlow: "constant_flow", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantTemperature: "constant_temperature", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kAutomatic: "automatic", - clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, + _pump_ctrl.kConstantSpeed: "constant_speed", + _pump_ctrl.kConstantPressure: "constant_pressure", + _pump_ctrl.kProportionalPressure: "proportional_pressure", + _pump_ctrl.kConstantFlow: "constant_flow", + _pump_ctrl.kConstantTemperature: "constant_temperature", + _pump_ctrl.kAutomatic: "automatic", + _pump_ctrl.kUnknownEnumValue: None, } MATTER_2000_TO_UNIX_EPOCH_OFFSET = ( @@ -225,11 +252,11 @@ def matter_epoch_microseconds_to_utc(x: int | None) -> datetime | None: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter sensors from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.SENSOR, async_add_entities) @@ -242,7 +269,8 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterListSensorEntityDescription(MatterSensorEntityDescription): """Describe Matter sensor entities from MatterListSensor.""" - # list attribute: the attribute descriptor to get the list of values (= list of strings) + # list attribute: the attribute descriptor to get the list + # of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] @@ -250,8 +278,10 @@ class MatterListSensorEntityDescription(MatterSensorEntityDescription): class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescription): """Describe Matter sensor entities from Matter OperationalState objects.""" - # list attribute: the attribute descriptor to get the list of values (= list of structs) - # needs to be set for handling OperationalState not on the OperationalState cluster, but + # list attribute: the attribute descriptor to get the list + # of values (= list of structs) + # needs to be set for handling OperationalState not on the + # OperationalState cluster, but # on one of its derived clusters (e.g. RvcOperationalState) state_list_attribute: type[ClusterAttributeDescriptor] = ( clusters.OperationalState.Attributes.OperationalStateList @@ -280,7 +310,7 @@ class MatterSensor(MatterEntity, SensorEntity): class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): - """Representation of a Matter sensor for Matter 1.0 draft ElectricalMeasurement cluster.""" + """Matter sensor for 1.0 draft ElectricalMeasurement cluster.""" entity_description: MatterSensorEntityDescription @@ -421,6 +451,19 @@ DISCOVERY_SCHEMAS = [ ), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="SoilMoistureSensor", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -510,8 +553,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="PowerSourceBatReplacementDescription", translation_key="battery_replacement_description", - native_unit_of_measurement=None, - device_class=None, entity_category=EntityCategory.DIAGNOSTIC, ), entity_class=MatterSensor, @@ -526,7 +567,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="EveEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -554,7 +594,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="EveEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, @@ -605,7 +644,6 @@ DISCOVERY_SCHEMAS = [ key="EveWeatherWeatherTrend", translation_key="eve_weather_trend", device_class=SensorDeviceClass.ENUM, - native_unit_of_measurement=None, options=[x for x in EVE_CLUSTER_WEATHER_MAP.values() if x is not None], device_to_ha=EVE_CLUSTER_WEATHER_MAP.get, ), @@ -698,8 +736,6 @@ DISCOVERY_SCHEMAS = [ key="AirQuality", translation_key="air_quality", device_class=SensorDeviceClass.ENUM, - state_class=None, - # convert to set first to remove the duplicate unknown value options=[x for x in AIR_QUALITY_MAP.values() if x is not None], device_to_ha=lambda x: AIR_QUALITY_MAP[x], ), @@ -763,7 +799,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="HepaFilterCondition", native_unit_of_measurement=PERCENTAGE, - device_class=None, state_class=SensorStateClass.MEASUREMENT, translation_key="hepa_filter_condition", ), @@ -775,7 +810,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ActivatedCarbonFilterCondition", native_unit_of_measurement=PERCENTAGE, - device_class=None, state_class=SensorStateClass.MEASUREMENT, translation_key="activated_carbon_filter_condition", ), @@ -789,7 +823,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ThirdRealityEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -806,7 +839,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ThirdRealityEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, @@ -823,7 +855,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="NeoEnergySensorWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -838,7 +869,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="NeoEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=1, state_class=SensorStateClass.TOTAL_INCREASING, @@ -880,8 +910,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="SwitchCurrentPosition", - native_unit_of_measurement=None, - device_class=None, state_class=SensorStateClass.MEASUREMENT, translation_key="switch_current_position", entity_category=EntityCategory.DIAGNOSTIC, @@ -896,7 +924,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementWatt", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.MILLIWATT, suggested_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, @@ -1052,7 +1079,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ElectricalEnergyMeasurementCumulativeEnergyImported", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, @@ -1072,7 +1098,6 @@ DISCOVERY_SCHEMAS = [ key="ElectricalEnergyMeasurementCumulativeEnergyExported", translation_key="energy_exported", device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, @@ -1091,7 +1116,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="ElectricalMeasurementActivePower", device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, @@ -1185,8 +1209,6 @@ DISCOVERY_SCHEMAS = [ key="OperationalStateCountdownTime", translation_key="estimated_end_time", device_class=SensorDeviceClass.TIMESTAMP, - state_class=None, - # Add countdown to current datetime to get the estimated end time device_to_ha=( lambda x: dt_util.utcnow() + timedelta(seconds=x) if x > 0 else None ), @@ -1544,8 +1566,6 @@ DISCOVERY_SCHEMAS = [ key="ValveConfigurationAndControlAutoCloseTime", translation_key="auto_close_time", device_class=SensorDeviceClass.TIMESTAMP, - state_class=None, - # AutoCloseTime is defined as epoch-us in the spec device_to_ha=matter_epoch_microseconds_to_utc, ), entity_class=MatterSensor, @@ -1560,8 +1580,6 @@ DISCOVERY_SCHEMAS = [ key="ServiceAreaEstimatedEndTime", translation_key="estimated_end_time", device_class=SensorDeviceClass.TIMESTAMP, - state_class=None, - # EstimatedEndTime is defined as epoch-s (Matter 2000 epoch) in the spec device_to_ha=matter_epoch_seconds_to_utc, ), entity_class=MatterSensor, @@ -1593,4 +1611,98 @@ DISCOVERY_SCHEMAS = [ required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,), featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor, ), + # WiFiNetworkDiagnostics cluster sensors + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WiFiDiagnosticsRssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.WiFiNetworkDiagnostics.Attributes.Rssi,), + ), + # ThreadNetworkDiagnostics cluster sensors + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThreadDiagnosticsChannel", + translation_key="thread_channel", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + entity_class=MatterSensor, + required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.Channel,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThreadDiagnosticsRoutingRole", + translation_key="thread_routing_role", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=list(THREAD_ROUTING_ROLE_MAP.values()), + device_to_ha=lambda value: THREAD_ROUTING_ROLE_MAP.get(value, "unknown"), + ), + entity_class=MatterSensor, + required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.RoutingRole,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThreadDiagnosticsNetworkName", + translation_key="thread_network_name", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + entity_class=MatterSensor, + required_attributes=(clusters.ThreadNetworkDiagnostics.Attributes.NetworkName,), + ), + # GeneralDiagnostics cluster sensors + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="GeneralDiagnosticsRebootCount", + translation_key="reboot_count", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(clusters.GeneralDiagnostics.Attributes.RebootCount,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="GeneralDiagnosticsUpTime", + translation_key="uptime", + device_class=SensorDeviceClass.UPTIME, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_to_ha=lambda uptime: dt_util.utcnow() - timedelta(seconds=uptime), + ), + entity_class=MatterSensor, + required_attributes=(clusters.GeneralDiagnostics.Attributes.UpTime,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="GeneralDiagnosticsBootReason", + translation_key="boot_reason", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + reason for reason in BOOT_REASON_MAP.values() if reason is not None + ], + device_to_ha=BOOT_REASON_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.GeneralDiagnostics.Attributes.BootReason,), + ), ] diff --git a/homeassistant/components/matter/services.py b/homeassistant/components/matter/services.py index e8076d76cfc..ccf33321dbe 100644 --- a/homeassistant/components/matter/services.py +++ b/homeassistant/components/matter/services.py @@ -1,7 +1,5 @@ """Services for Matter devices.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN diff --git a/homeassistant/components/matter/siren.py b/homeassistant/components/matter/siren.py new file mode 100644 index 00000000000..c07a4346492 --- /dev/null +++ b/homeassistant/components/matter/siren.py @@ -0,0 +1,69 @@ +"""Matter sirens.""" + +from dataclasses import dataclass +from typing import Any + +from matter_server.common.custom_clusters import HeimanCluster + +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MatterEntity, MatterEntityDescription +from .helpers import MatterConfigEntry +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MatterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Matter sirens from Config Entry.""" + matter = config_entry.runtime_data.adapter + matter.register_platform_handler(Platform.SIREN, async_add_entities) + + +@dataclass(frozen=True, kw_only=True) +class MatterSirenEntityDescription(SirenEntityDescription, MatterEntityDescription): + """Describe Matter Siren entities.""" + + +class MatterSiren(MatterEntity, SirenEntity): + """Representation of a Matter siren.""" + + entity_description: MatterSirenEntityDescription + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + await self.write_attribute(value=1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + await self.write_attribute(value=0) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + self._attr_is_on = bool(value) if value is not None else None + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.SIREN, + entity_description=MatterSirenEntityDescription( + key="HeimanSiren", + translation_key="siren", + ), + entity_class=MatterSiren, + required_attributes=(HeimanCluster.Attributes.SirenActive,), + ), +] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1286b8bae94..f6696e89aa5 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -47,6 +47,15 @@ }, "entity": { "binary_sensor": { + "active_hardware_faults": { + "name": "Hardware faults" + }, + "active_network_faults": { + "name": "Network faults" + }, + "active_radio_faults": { + "name": "Radio faults" + }, "actuator": { "name": "Actuator" }, @@ -141,6 +150,9 @@ }, "stop": { "name": "[%key:common::action::stop%]" + }, + "temporary_mute_request": { + "name": "Temporary mute" } }, "climate": { @@ -259,6 +271,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "sensitivity_level": { + "name": "Sensitivity" + }, "speaker_setpoint": { "name": "Volume" }, @@ -402,6 +417,18 @@ "battery_voltage": { "name": "Battery voltage" }, + "boot_reason": { + "name": "Boot reason", + "state": { + "brown_out_reset": "Brownout reset", + "hardware_watchdog_reset": "Hardware watchdog reset", + "power_on_reboot": "Power-on reboot", + "software_reset": "Software reset", + "software_update_completed": "Software update completed", + "software_watchdog_reset": "Software watchdog reset", + "unspecified": "Unspecified" + } + }, "contamination_state": { "name": "Contamination state", "state": { @@ -570,6 +597,9 @@ "reactive_current": { "name": "Reactive current" }, + "reboot_count": { + "name": "Reboot count" + }, "rms_current": { "name": "Effective current" }, @@ -585,6 +615,25 @@ "tank_volume": { "name": "Tank volume" }, + "thread_channel": { + "name": "Thread channel" + }, + "thread_network_name": { + "name": "Thread network name" + }, + "thread_routing_role": { + "name": "Thread routing role", + "state": { + "end_device": "End device", + "leader": "Leader", + "reed": "Router eligible end device", + "router": "Router", + "sleepy_end_device": "Sleepy end device", + "unassigned": "Unassigned", + "unknown": "Unknown", + "unspecified": "Unspecified" + } + }, "tvoc_level": { "name": "TVOC level", "state": { @@ -594,16 +643,27 @@ "medium": "[%key:common::state::medium%]" } }, + "uptime": { + "name": "Uptime" + }, "valve_position": { "name": "Valve position" }, "voltage": { "name": "Voltage" }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, "window_covering_target_position": { "name": "Target opening position" } }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } + }, "switch": { "child_lock": { "name": "Child lock" diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 8e19d3d13b6..6ad536fcf2b 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -1,7 +1,5 @@ """Matter switches.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -15,13 +13,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema EVSE_SUPPLY_STATE_MAP = { @@ -34,11 +31,11 @@ EVSE_SUPPLY_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter switches from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.SWITCH, async_add_entities) @@ -317,4 +314,14 @@ DISCOVERY_SCHEMAS = [ value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterNumericSwitchEntityDescription( + key="EveChildLock", + entity_category=EntityCategory.CONFIG, + translation_key="child_lock", + ), + entity_class=MatterNumericSwitch, + required_attributes=(clusters.EveCluster.Attributes.ChildLock,), + ), ] diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 56d98f8b5b0..9f85290b80d 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -1,7 +1,5 @@ """Matter update.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any @@ -17,7 +15,6 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -26,7 +23,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema SCAN_INTERVAL = timedelta(hours=12) @@ -59,11 +56,11 @@ class MatterUpdateExtraStoredData(ExtraStoredData): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter lock from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.UPDATE, async_add_entities) @@ -176,18 +173,25 @@ class MatterUpdate(MatterEntity, UpdateEntity): release_notes = "" # insert extra heavy warning case the update is not from the main net - if self._software_update.update_source != UpdateSource.MAIN_NET_DCL: + if self._software_update.update_source is not UpdateSource.MAIN_NET_DCL: release_notes += ( "\n\n" - f"Update provided by {self._software_update.update_source.value}. " - "Installing this update is at your own risk and you may run into unexpected " - "problems such as the need to re-add and factory reset your device.\n\n" + "Update provided by " + f"{self._software_update.update_source.value}. " + "Installing this update is at your own risk " + "and you may run into unexpected " + "problems such as the need to re-add and " + "factory reset your device.\n\n" ) return release_notes + ( - "\n\nThe update process can take a while, " - "especially for battery powered devices. Please be patient and wait until the update " - "process is fully completed. Do not remove power from the device while it's updating. " - "The device may restart during the update process and be unavailable for several minutes." + "\n\n" + "The update process can take a while, " + "especially for battery powered devices. " + "Please be patient and wait until the update " + "process is fully completed. Do not remove power " + "from the device while it's updating. " + "The device may restart during the update process " + "and be unavailable for several minutes." "\n\n" ) diff --git a/homeassistant/components/matter/util.py b/homeassistant/components/matter/util.py index 0df2230ab96..92c1d66ab71 100644 --- a/homeassistant/components/matter/util.py +++ b/homeassistant/components/matter/util.py @@ -1,7 +1,5 @@ """Provide integration utilities.""" -from __future__ import annotations - XY_COLOR_FACTOR = 65536 diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 30fa8a7fde3..4bd42317f77 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -1,7 +1,5 @@ """Matter vacuum platform.""" -from __future__ import annotations - from dataclasses import dataclass from enum import IntEnum import logging @@ -17,14 +15,13 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema _LOGGER = logging.getLogger(__name__) @@ -55,11 +52,11 @@ class ModeTag(IntEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter vacuum platform from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.VACUUM, async_add_entities) @@ -214,7 +211,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess ): raise HomeAssistantError( - f"Failed to select areas: {response['statusText'] or response['status']}" + "Failed to select areas: " + f"{response['statusText'] or response['status']}" ) await self.send_device_command( diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index f2deea97d7f..b40f8728ff9 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -1,7 +1,5 @@ """Matter valve platform.""" -from __future__ import annotations - from dataclasses import dataclass from chip.clusters import Objects as clusters @@ -13,13 +11,12 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema ValveConfigurationAndControl = clusters.ValveConfigurationAndControl @@ -28,11 +25,11 @@ ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter valve platform from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.VALVE, async_add_entities) diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index 5f3c0ce3a95..47191facf48 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -1,7 +1,5 @@ """Matter water heater platform.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast @@ -19,7 +17,6 @@ from homeassistant.components.water_heater import ( WaterHeaterEntityDescription, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_WHOLE, @@ -31,12 +28,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema TEMPERATURE_SCALING_FACTOR = 100 -# Map HA WH system mode to Matter ThermostatRunningMode attribute of the Thermostat cluster (Heat = 4) +# Map HA WH system mode to Matter ThermostatRunningMode +# attribute of the Thermostat cluster (Heat = 4) WATER_HEATER_SYSTEM_MODE_MAP = { STATE_ECO: 4, STATE_HIGH_DEMAND: 4, @@ -48,11 +46,11 @@ DEFAULT_BOOST_DURATION = 3600 # 1 hour async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter WaterHeater platform from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.WATER_HEATER, async_add_entities) @@ -90,7 +88,10 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity): temporary_setpoint: int | None = None, ) -> None: """Set boost.""" - boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( + boost_info_cls = ( + clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct + ) + boost_info = boost_info_cls( duration=duration, emergencyBoost=emergency_boost, temporarySetpoint=( diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py index a45404b7959..e4f6ee0f7b2 100644 --- a/homeassistant/components/maxcube/binary_sensor.py +++ b/homeassistant/components/maxcube/binary_sensor.py @@ -1,7 +1,5 @@ """Support for MAX! binary sensors via MAX! Cube.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index c434d146323..8a39018d7c4 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -1,7 +1,5 @@ """Support for MAX! Thermostats via MAX! Cube.""" -from __future__ import annotations - import logging from typing import Any @@ -95,7 +93,8 @@ class MaxCubeClimate(ClimateEntity): def min_temp(self) -> float: """Return the minimum temperature.""" temp = self._device.min_temperature or MIN_TEMPERATURE - # OFF_TEMPERATURE (always off) a is valid temperature to maxcube but not to Home Assistant. + # OFF_TEMPERATURE (always off) is valid to maxcube + # but not to Home Assistant. # We use HVACMode.OFF instead to represent a turned off thermostat. return max(temp, MIN_TEMPERATURE) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index ccbb331573e..c015b04294d 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -1,7 +1,5 @@ """The Mazda Connected Services integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index c4238017564..21ae6bedff2 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -1,17 +1,16 @@ """The Model Context Protocol integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, llm from .application_credentials import authorization_server_context -from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry @@ -42,7 +41,10 @@ async def async_get_config_entry_implementation( async def _create_token_manager( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> TokenManager | None: - """Create a OAuth token manager for the config entry if the server requires authentication.""" + """Create a OAuth token manager for the config entry. + + Returns None if the server does not require authentication. + """ try: implementation = await async_get_config_entry_implementation(hass, entry) except config_entry_oauth2_flow.ImplementationUnavailableError as err: diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py index 9b8bed894e4..6e01ac89464 100644 --- a/homeassistant/components/mcp/application_credentials.py +++ b/homeassistant/components/mcp/application_credentials.py @@ -1,7 +1,5 @@ """Application credentials platform for Model Context Protocol.""" -from __future__ import annotations - from collections.abc import Generator from contextlib import contextmanager import contextvars diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 2f93ffbd960..9fcf038cd74 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Model Context Protocol integration.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable, Mapping from dataclasses import dataclass @@ -15,7 +13,7 @@ from yarl import URL from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -26,13 +24,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context -from .const import ( - CONF_ACCESS_TOKEN, - CONF_AUTHORIZATION_URL, - CONF_SCOPE, - CONF_TOKEN_URL, - DOMAIN, -) +from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -246,13 +238,16 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): _LOGGER.debug("Protected resource metadata: %s", resource_metadata) oauth_config = await async_discover_authorization_server( self.hass, - # Use the first authorization server from the resource metadata as it - # is the most common to have only one and there is not a defined strategy. + # Use the first authorization server from the + # resource metadata as it is the most common to + # have only one and there is not a defined + # strategy. resource_metadata.authorization_servers[0], ) else: _LOGGER.debug( - "Discovering authorization server without protected resource metadata" + "Discovering authorization server without" + " protected resource metadata" ) oauth_config = await async_discover_authorization_server( self.hass, @@ -270,7 +265,9 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.oauth_config = oauth_config self.data.update( { - CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url, + CONF_AUTHORIZATION_URL: ( + oauth_config.authorization_server.authorize_url + ), CONF_TOKEN_URL: oauth_config.authorization_server.token_url, CONF_SCOPE: _select_scopes( self.auth_header, oauth_config, resource_metadata @@ -432,11 +429,12 @@ async def async_discover_protected_resource( auth_url: str, mcp_server_url: str, ) -> ResourceMetadata: - """Discover the OAuth configuration for a protected resource for MCP spec version 2025-11-25+. + """Discover the OAuth configuration for a protected resource. - This implements the functionality in the MCP spec for discovery. We use the information - from the WWW-Authenticate header to fetch the resource metadata implementing - RFC9728. + This is for MCP spec version 2025-11-25+. It implements the + functionality in the MCP spec for discovery. We use the information + from the WWW-Authenticate header to fetch the resource metadata + implementing RFC9728. For the url https://example.com/public/mcp we attempt these urls: - https://example.com/.well-known/oauth-protected-resource/public/mcp diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 19fad8f4736..2170976e084 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -2,7 +2,6 @@ DOMAIN = "mcp" -CONF_ACCESS_TOKEN = "access_token" CONF_AUTHORIZATION_URL = "authorization_url" CONF_TOKEN_URL = "token_url" CONF_SCOPE = "scope" diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index f3fa499f34a..c4b8ad952ba 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -1,7 +1,5 @@ """The Model Context Protocol Server integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index e218691975a..0d485e375af 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Model Context Protocol Server integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 3f2e12cbb6a..23aa84de428 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,6 +2,6 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" -# The Stateless API is no longer registered explicitly, but this name may still exist in the -# users config entry. +# The Stateless API is no longer registered explicitly, but this +# name may still exist in the users config entry. STATELESS_LLM_API = "stateless_assist" diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 19ace718564..3af6fb4806a 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -24,6 +24,8 @@ See https://modelcontextprotocol.io/docs/concepts/transports """ import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from dataclasses import dataclass from http import HTTPStatus import logging @@ -39,7 +41,7 @@ from mcp.shared.message import SessionMessage from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import llm @@ -54,9 +56,6 @@ _LOGGER = logging.getLogger(__name__) STREAMABLE_API = "/api/mcp" TIMEOUT = 60 # Seconds -# Content types -CONTENT_TYPE_JSON = "application/json" - # Legacy SSE endpoint SSE_API = f"/{DOMAIN}/sse" MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}" @@ -102,17 +101,29 @@ class Streams: write_stream: MemoryObjectSendStream[SessionMessage] write_stream_reader: MemoryObjectReceiveStream[SessionMessage] + async def aclose(self) -> None: + """Close open memory streams.""" + await self.read_stream.aclose() + await self.read_stream_writer.aclose() + await self.write_stream.aclose() + await self.write_stream_reader.aclose() -def create_streams() -> Streams: + +@asynccontextmanager +async def create_streams() -> AsyncGenerator[Streams]: """Create a new pair of streams for MCP server communication.""" read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - return Streams( + streams = Streams( read_stream=read_stream, read_stream_writer=read_stream_writer, write_stream=write_stream, write_stream_reader=write_stream_reader, ) + try: + yield streams + finally: + await streams.aclose() async def create_mcp_server( @@ -155,9 +166,9 @@ class ModelContextProtocolSSEView(HomeAssistantView): session_manager = entry.runtime_data server, options = await create_mcp_server(hass, self.context(request), entry) - streams = create_streams() async with ( + create_streams() as streams, sse_response(request) as response, session_manager.create(Session(streams.read_stream_writer)) as session_id, ): @@ -261,21 +272,24 @@ class ModelContextProtocolStreamableView(HomeAssistantView): # request is sent to the MCP server and we wait for a single response # then shut down the server. server, options = await create_mcp_server(hass, self.context(request), entry) - streams = create_streams() - async def run_server() -> None: - await server.run( - streams.read_stream, streams.write_stream, options, stateless=True + async with create_streams() as streams: + + async def run_server() -> None: + await server.run( + streams.read_stream, streams.write_stream, options, stateless=True + ) + + async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: + tg.start_soon(run_server) + + await streams.read_stream_writer.send(SessionMessage(message)) + session_message = await anext(streams.write_stream_reader) + tg.cancel_scope.cancel() + + _LOGGER.debug("Sending response: %s", session_message) + return web.json_response( + data=session_message.message.model_dump( + by_alias=True, exclude_none=True + ), ) - - async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: - tg.start_soon(run_server) - - await streams.read_stream_writer.send(SessionMessage(message)) - session_message = await anext(streams.write_stream_reader) - tg.cancel_scope.cancel() - - _LOGGER.debug("Sending response: %s", session_message) - return web.json_response( - data=session_message.message.model_dump(by_alias=True, exclude_none=True), - ) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 2e4c645441b..d54fc33ebc5 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -3,11 +3,11 @@ "name": "Model Context Protocol Server", "codeowners": ["@allenporter"], "config_flow": true, - "dependencies": ["homeassistant", "http", "conversation"], + "dependencies": ["http", "conversation"], "documentation": "https://www.home-assistant.io/integrations/mcp_server", "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.10.0"], + "requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.13.0"], "single_config_entry": true } diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 907114f06cd..82ccbcd2cf1 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -10,10 +10,12 @@ See https://modelcontextprotocol.io/docs/concepts/architecture#implementation-ex from collections.abc import Callable, Sequence import json import logging -from typing import Any +from typing import Any, cast from mcp import types from mcp.server import Server +from mcp.server.lowlevel.helper_types import ReadResourceContents +from pydantic import AnyUrl import voluptuous as vol from voluptuous_openapi import convert @@ -25,6 +27,16 @@ from .const import STATELESS_LLM_API _LOGGER = logging.getLogger(__name__) +SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot" +SNAPSHOT_RESOURCE_URL = AnyUrl(SNAPSHOT_RESOURCE_URI) +SNAPSHOT_RESOURCE_MIME_TYPE = "text/plain" +LIVE_CONTEXT_TOOL_NAME = "GetLiveContext" + + +def _has_live_context_tool(llm_api: llm.APIInstance) -> bool: + """Return if the selected API exposes the live context tool.""" + return any(tool.name == LIVE_CONTEXT_TOOL_NAME for tool in llm_api.tools) + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None @@ -90,6 +102,47 @@ async def create_server( ], ) + @server.list_resources() # type: ignore[no-untyped-call,untyped-decorator] + async def handle_list_resources() -> list[types.Resource]: + llm_api = await get_api_instance() + if not _has_live_context_tool(llm_api): + return [] + + return [ + types.Resource( + uri=SNAPSHOT_RESOURCE_URL, + name="assist_context_snapshot", + title="Assist context snapshot", + description=( + "A snapshot of the current Assist context, matching the" + " existing GetLiveContext tool output." + ), + mimeType=SNAPSHOT_RESOURCE_MIME_TYPE, + ) + ] + + @server.read_resource() # type: ignore[no-untyped-call,untyped-decorator] + async def handle_read_resource(uri: AnyUrl) -> Sequence[ReadResourceContents]: + if str(uri) != SNAPSHOT_RESOURCE_URI: + raise ValueError(f"Unknown resource: {uri}") + + llm_api = await get_api_instance() + if not _has_live_context_tool(llm_api): + raise ValueError(f"Unknown resource: {uri}") + + tool_response = await llm_api.async_call_tool( + llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={}) + ) + if not tool_response.get("success"): + raise HomeAssistantError(cast(str, tool_response["error"])) + + return [ + ReadResourceContents( + content=cast(str, tool_response["result"]), + mime_type=SNAPSHOT_RESOURCE_MIME_TYPE, + ) + ] + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] async def list_tools() -> list[types.Tool]: """List available time tools.""" diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index d043ecbf539..416f1effb19 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -1,7 +1,5 @@ """The Mealie integration.""" -from __future__ import annotations - from aiomealie import MealieAuthenticationError, MealieClient, MealieError from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 9831bb8105a..8cf50de2b0b 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for Mealie.""" -from __future__ import annotations - from datetime import datetime from aiomealie import Mealplan, MealplanEntryType diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index b7e49fe324e..ae8e274b2ae 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching Mealie data.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/mealie/diagnostics.py b/homeassistant/components/mealie/diagnostics.py index b1c8640f007..fab6b4cd606 100644 --- a/homeassistant/components/mealie/diagnostics.py +++ b/homeassistant/components/mealie/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the Mealie integration.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 01b3e221268..b86669364f7 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.2.3"] + "requirements": ["aiomealie==1.2.4"] } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index d1e4745bf59..5a37770f97f 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -1,7 +1,6 @@ """Define services for the Mealie integration.""" from dataclasses import asdict -from datetime import date from aiomealie import ( MealieConnectionError, @@ -23,6 +22,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, service +from homeassistant.util import dt as dt_util from .const import ( ATTR_END_DATE, @@ -137,8 +137,8 @@ async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse: entry: MealieConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - start_date = call.data.get(ATTR_START_DATE, date.today()) - end_date = call.data.get(ATTR_END_DATE, date.today()) + start_date = call.data.get(ATTR_START_DATE, dt_util.now().date()) + end_date = call.data.get(ATTR_END_DATE, dt_util.now().date()) if end_date < start_date: raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index c504ba1e7f0..0f7e89d0b4a 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -1,7 +1,5 @@ """Todo platform for Mealie.""" -from __future__ import annotations - from dataclasses import asdict from aiomealie import ( diff --git a/homeassistant/components/mealie/utils.py b/homeassistant/components/mealie/utils.py index 36d0831208b..68d575ffb15 100644 --- a/homeassistant/components/mealie/utils.py +++ b/homeassistant/components/mealie/utils.py @@ -1,7 +1,5 @@ """Mealie util functions.""" -from __future__ import annotations - from awesomeversion import AwesomeVersion diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 5c11b10755c..ac8d23338e5 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Meater.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/meater/diagnostics.py b/homeassistant/components/meater/diagnostics.py index 247457d0bc8..38b0c13d166 100644 --- a/homeassistant/components/meater/diagnostics.py +++ b/homeassistant/components/meater/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the Meater integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 58aa9e8bf9b..deddf8cd28a 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -1,7 +1,5 @@ """The Meater Temperature Probe integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -131,7 +129,8 @@ SENSOR_TYPES = ( ), ), # Remaining time in seconds. When unknown/calculating default is used. Default: -1 - # Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time. + # Exposed as a TIMESTAMP sensor where the timestamp is + # current time + remaining time. MeaterSensorEntityDescription( key="cook_time_remaining", translation_key="cook_time_remaining", diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py index 60f945f5adb..4e8be7758f0 100644 --- a/homeassistant/components/medcom_ble/__init__.py +++ b/homeassistant/components/medcom_ble/__init__.py @@ -1,7 +1,5 @@ """The Medcom BLE integration.""" -from __future__ import annotations - from homeassistant.components import bluetooth from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py index 21951ab221b..d65673f9e88 100644 --- a/homeassistant/components/medcom_ble/config_flow.py +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Medcom BlE integration.""" -from __future__ import annotations - import logging from typing import Any @@ -69,7 +67,8 @@ class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" - # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # We always will have self._discovery_info be a + # BluetoothServiceInfo at this point # and this helps mypy not complain assert self._discovery_info is not None @@ -124,8 +123,9 @@ class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_check_connection(self) -> ConfigFlowResult: - """Check we can connect to the device before considering the configuration is successful.""" - # We always will have self._discovery_info be a BluetoothServiceInfo at this point + """Check device connection before confirming configuration.""" + # We always will have self._discovery_info be a + # BluetoothServiceInfo at this point # and this helps mypy not complain assert self._discovery_info is not None diff --git a/homeassistant/components/medcom_ble/coordinator.py b/homeassistant/components/medcom_ble/coordinator.py index eb7f91f3477..b55aeded1ee 100644 --- a/homeassistant/components/medcom_ble/coordinator.py +++ b/homeassistant/components/medcom_ble/coordinator.py @@ -1,7 +1,5 @@ """The Medcom BLE integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py index 6ca59c07908..bd447441bfc 100644 --- a/homeassistant/components/medcom_ble/sensor.py +++ b/homeassistant/components/medcom_ble/sensor.py @@ -1,7 +1,5 @@ """Support for Medcom BLE radiation monitor sensors.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/media_extractor/config_flow.py b/homeassistant/components/media_extractor/config_flow.py index cb2166c35f1..542ff6c420a 100644 --- a/homeassistant/components/media_extractor/config_flow.py +++ b/homeassistant/components/media_extractor/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Media Extractor integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 613c07a1f4a..1a17e7595b9 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1,10 +1,8 @@ """Component to interface with various media players.""" -from __future__ import annotations - import asyncio import collections -from collections.abc import Callable +from collections.abc import Callable, Container, Mapping from contextlib import suppress import datetime as dt from enum import StrEnum @@ -14,7 +12,7 @@ import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final +from typing import Any, Final, Required, TypedDict, final, override from urllib.parse import quote, urlparse import aiohttp @@ -26,7 +24,7 @@ import voluptuous as vol from yarl import URL from homeassistant.components import websocket_api -from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.http import HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 @@ -52,14 +50,13 @@ from homeassistant.const import ( # noqa: F401 STATE_PLAYING, STATE_STANDBY, ) -from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.core import HomeAssistant, SupportsResponse, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from .browse_media import ( # noqa: F401 @@ -75,7 +72,6 @@ from .const import ( # noqa: F401 ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_LAST_NON_BUFFERING_STATE, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ANNOUNCE, @@ -159,6 +155,7 @@ class MediaPlayerDeviceClass(StrEnum): TV = "tv" SPEAKER = "speaker" RECEIVER = "receiver" + PROJECTOR = "projector" DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass)) @@ -172,7 +169,9 @@ def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]: if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict): if ATTR_MEDIA_CONTENT_TYPE in data or ATTR_MEDIA_CONTENT_ID in data: raise vol.Invalid( - f"Play media cannot contain '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}' or '{ATTR_MEDIA_CONTENT_TYPE}'" + f"Play media cannot contain '{ATTR_MEDIA}' and " + f"'{ATTR_MEDIA_CONTENT_ID}' or " + f"'{ATTR_MEDIA_CONTENT_TYPE}'" ) media_data = data[ATTR_MEDIA] @@ -247,7 +246,6 @@ class _ImageCache(TypedDict): _ENTITY_IMAGE_CACHE = _ImageCache(images=collections.OrderedDict(), maxsize=16) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool: """Return true if specified media player entity_id is on. @@ -588,8 +586,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_volume_level: float | None = None _attr_volume_step: float - __last_non_buffering_state: MediaPlayerState | None = None - # Implement these for your media player @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: @@ -1127,12 +1123,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if (state := self.state) != MediaPlayerState.BUFFERING: - self.__last_non_buffering_state = state - - state_attr: dict[str, Any] = { - ATTR_LAST_NON_BUFFERING_STATE: self.__last_non_buffering_state - } + state_attr: dict[str, Any] = {} if self.support_grouping: state_attr[ATTR_GROUP_MEMBERS] = self.group_members @@ -1258,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): class MediaPlayerImageView(HomeAssistantView): """Media player view to serve an image.""" - requires_auth = False + use_query_token_for_auth = True url = "/api/media_player_proxy/{entity_id}" name = "api:media_player:image" extra_urls = [ @@ -1271,6 +1262,15 @@ class MediaPlayerImageView(HomeAssistantView): """Initialize a media player view.""" self.component = component + @callback + @override + def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: + """Return valid auth tokens, which can be used for query token authentication.""" + if (player := self.component.get_entity(match_info["entity_id"])) is None: + return () + + return (player.access_token,) + async def get( self, request: web.Request, @@ -1280,21 +1280,9 @@ class MediaPlayerImageView(HomeAssistantView): ) -> web.Response: """Start a get request.""" if (player := self.component.get_entity(entity_id)) is None: - status = ( - HTTPStatus.NOT_FOUND - if request[KEY_AUTHENTICATED] - else HTTPStatus.UNAUTHORIZED - ) - return web.Response(status=status) + return web.Response(status=HTTPStatus.NOT_FOUND) assert isinstance(player, MediaPlayerEntity) - authenticated = ( - request[KEY_AUTHENTICATED] - or request.query.get("token") == player.access_token - ) - - if not authenticated: - return web.Response(status=HTTPStatus.UNAUTHORIZED) if media_content_type and media_content_id: media_image_id = request.query.get("media_image_id") @@ -1305,7 +1293,7 @@ class MediaPlayerImageView(HomeAssistantView): data, content_type = await player.async_get_media_image() if data is None: - return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + return web.Response(status=HTTPStatus.NOT_FOUND) headers: LooseHeaders = {CACHE_CONTROL: "max-age=3600"} return web.Response(body=data, content_type=content_type, headers=headers) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index ec9d70476a3..e293ffe8820 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -1,7 +1,5 @@ """Browse media features for media player.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass, field from datetime import timedelta @@ -25,6 +23,7 @@ from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign PATHS_WITHOUT_AUTH = ( + "/local/", "/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/", "/api/assist_satellite/static/", diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py index d63f569642a..2b405be804d 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -1,11 +1,108 @@ """Provides conditions for media players.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import Condition, make_entity_state_condition +from datetime import datetime +from typing import Any +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + Condition, + EntityConditionBase, + EntityNumericalConditionBase, + make_entity_state_condition, +) + +from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED from .const import DOMAIN, MediaPlayerState + +class _MediaPlayerMutedConditionBase(EntityConditionBase): + """Base class for media player is_muted/is_unmuted conditions.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _target_muted: bool + + def _state_valid_since(self, state: State) -> datetime: + """Anchor `for:` durations to `last_updated` for the muted attribute. + + Needed because the domain spec does not reflect that the condition + reads from the muted and volume attributes. + """ + return state.last_updated + + def _has_volume_attributes(self, state: State) -> bool: + """Check if the state has volume muted or volume level attributes.""" + return ( + state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None + or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + ) + + def _should_include(self, state: State) -> bool: + """Skip entities without volume attributes from the all/count check.""" + return super()._should_include(state) and self._has_volume_attributes(state) + + def _is_muted(self, state: State) -> bool: + """Check if the media player is muted.""" + return ( + state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True + or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + ) + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the entity state matches the targeted muted state.""" + if not self._has_volume_attributes(entity_state): + return False + return self._is_muted(entity_state) is self._target_muted + + +class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase): + """Condition that passes when the media player is muted.""" + + _target_muted = True + + +class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase): + """Condition that passes when the media player is not muted.""" + + _target_muted = False + + +class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase): + """Condition for media player volume level with 0.0-1.0 to percentage conversion.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)} + _valid_unit = "%" + + def _get_tracked_value(self, entity_state: State) -> Any: + """Get the volume value converted from 0.0-1.0 to percentage (0-100).""" + raw = super()._get_tracked_value(entity_state) + if raw is None: + return None + try: + return float(raw) * 100.0 + except TypeError, ValueError: + return None + + def _should_include(self, state: State) -> bool: + """Skip media players that do not expose a volume_level attribute.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + ) + + CONDITIONS: dict[str, type[Condition]] = { + "is_muted": MediaPlayerIsMutedCondition, + "is_not_playing": make_entity_state_condition( + DOMAIN, + { + MediaPlayerState.BUFFERING, + MediaPlayerState.IDLE, + MediaPlayerState.OFF, + MediaPlayerState.ON, + MediaPlayerState.PAUSED, + }, + ), "is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF), "is_on": make_entity_state_condition( DOMAIN, @@ -17,18 +114,10 @@ CONDITIONS: dict[str, type[Condition]] = { MediaPlayerState.PLAYING, }, ), - "is_not_playing": make_entity_state_condition( - DOMAIN, - { - MediaPlayerState.BUFFERING, - MediaPlayerState.IDLE, - MediaPlayerState.OFF, - MediaPlayerState.ON, - MediaPlayerState.PAUSED, - }, - ), "is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED), "is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING), + "is_unmuted": MediaPlayerIsUnmutedCondition, + "is_volume": MediaPlayerIsVolumeCondition, } diff --git a/homeassistant/components/media_player/conditions.yaml b/homeassistant/components/media_player/conditions.yaml index ace2747e81f..eb5c39cd5a7 100644 --- a/homeassistant/components/media_player/conditions.yaml +++ b/homeassistant/components/media_player/conditions.yaml @@ -1,20 +1,51 @@ .condition_common: &condition_common - target: + target: &condition_media_player_target entity: domain: media_player fields: - behavior: + behavior: &condition_behavior required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: +.volume_threshold_entity: &volume_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + unit_of_measurement: "%" + - domain: sensor + unit_of_measurement: "%" + +.volume_threshold_number: &volume_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + +is_muted: *condition_common is_off: *condition_common is_on: *condition_common is_not_playing: *condition_common is_paused: *condition_common is_playing: *condition_common +is_unmuted: *condition_common + +is_volume: + target: *condition_media_player_target + fields: + behavior: *condition_behavior + for: *condition_for + threshold: + required: true + selector: + numeric_threshold: + entity: *volume_threshold_entity + mode: is + number: *volume_threshold_number diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index a5d9a07637d..4415b9ab7d1 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -13,7 +13,6 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local" ATTR_GROUP_MEMBERS = "group_members" ATTR_INPUT_SOURCE = "source" ATTR_INPUT_SOURCE_LIST = "source_list" -ATTR_LAST_NON_BUFFERING_STATE = "last_non_buffering_state" ATTR_MEDIA_ANNOUNCE = "announce" ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist" ATTR_MEDIA_ALBUM_NAME = "media_album_name" diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 660f53bc8d5..239e8994b38 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -1,7 +1,5 @@ """Provides device automations for Media player.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 9d1a3fab37e..01396db8d3e 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Media player.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 94c0ced3778..789c2c064f5 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -1,5 +1,8 @@ { "conditions": { + "is_muted": { + "condition": "mdi:volume-mute" + }, "is_not_playing": { "condition": "mdi:stop" }, @@ -14,6 +17,12 @@ }, "is_playing": { "condition": "mdi:play" + }, + "is_unmuted": { + "condition": "mdi:volume-high" + }, + "is_volume": { + "condition": "mdi:volume-medium" } }, "entity_component": { @@ -25,6 +34,12 @@ "playing": "mdi:cast-connected" } }, + "projector": { + "default": "mdi:projector", + "state": { + "off": "mdi:projector-off" + } + }, "receiver": { "default": "mdi:audio-video", "state": { @@ -123,8 +138,32 @@ } }, "triggers": { + "muted": { + "trigger": "mdi:volume-mute" + }, + "paused_playing": { + "trigger": "mdi:pause" + }, + "started_playing": { + "trigger": "mdi:play" + }, "stopped_playing": { "trigger": "mdi:stop" + }, + "turned_off": { + "trigger": "mdi:power" + }, + "turned_on": { + "trigger": "mdi:power" + }, + "unmuted": { + "trigger": "mdi:volume-high" + }, + "volume_changed": { + "trigger": "mdi:volume-medium" + }, + "volume_crossed_threshold": { + "trigger": "mdi:volume-medium" } } } diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index a40575a9dba..b4c2c4f821f 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -1,7 +1,5 @@ """Module that groups code required to handle state restore for component.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index ea5cf9d1b27..72577d2b792 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Media Player state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 3a3c4408f2d..32b27d71fa4 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,14 +1,33 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "condition_threshold_name": "Threshold", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", + "trigger_threshold_name": "Threshold" }, "conditions": { + "is_muted": { + "description": "Tests if one or more media players are muted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + } + }, + "name": "Media player is muted" + }, "is_not_playing": { "description": "Tests if one or more media players are not playing.", "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is not playing" @@ -18,6 +37,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is off" @@ -27,6 +49,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is on" @@ -36,6 +61,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is paused" @@ -45,9 +73,39 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is playing" + }, + "is_unmuted": { + "description": "Tests if one or more media players are not muted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + } + }, + "name": "Media player is not muted" + }, + "is_volume": { + "description": "Tests the volume of one or more media players.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + }, + "threshold": { + "name": "[%key:component::media_player::common::condition_threshold_name%]" + } + }, + "name": "Volume" } }, "device_automation": { @@ -203,6 +261,9 @@ } } }, + "projector": { + "name": "Projector" + }, "receiver": { "name": "Receiver" }, @@ -214,12 +275,6 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "enqueue": { "options": { "add": "Add to queue", @@ -234,13 +289,6 @@ "off": "[%key:common::state::off%]", "one": "Repeat one" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -433,14 +481,113 @@ }, "title": "Media player", "triggers": { - "stopped_playing": { - "description": "Triggers after one or more media players stop playing media.", + "muted": { + "description": "Triggers after one or more media players are muted.", "fields": { "behavior": { "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player muted" + }, + "paused_playing": { + "description": "Triggers after one or more media players pause playing.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player paused playing" + }, + "started_playing": { + "description": "Triggers after one or more media players start playing.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player started playing" + }, + "stopped_playing": { + "description": "Triggers after one or more media players stop playing.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" } }, "name": "Media player stopped playing" + }, + "turned_off": { + "description": "Triggers after one or more media players turn off.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player turned off" + }, + "turned_on": { + "description": "Triggers after one or more media players turn on.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player turned on" + }, + "unmuted": { + "description": "Triggers after one or more media players are unmuted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player unmuted" + }, + "volume_changed": { + "description": "Triggers after the volume of one or more media players changes.", + "fields": { + "threshold": { + "name": "[%key:component::media_player::common::trigger_threshold_name%]" + } + }, + "name": "Media player volume changed" + }, + "volume_crossed_threshold": { + "description": "Triggers after the volume of one or more media players crosses a threshold.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + }, + "threshold": { + "name": "[%key:component::media_player::common::trigger_threshold_name%]" + } + }, + "name": "Media player volume crossed threshold" } } } diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index a39ccfa9ced..25d2c540eb8 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,12 +1,144 @@ """Provides triggers for media players.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, + EntityTriggerBase, + Trigger, + make_entity_transition_trigger, +) -from . import MediaPlayerState +from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState from .const import DOMAIN +VOLUME_DOMAIN_SPECS = { + DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), +} + + +class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): + """Base class for media player muted/unmuted triggers.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _target_muted: bool + + def _has_volume_attributes(self, state: State) -> bool: + """Check if the state has volume muted or volume level attributes.""" + return ( + state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None + or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + ) + + def _should_include(self, state: State) -> bool: + """Check if an entity should participate in all/count checks. + + Entities without volume attributes cannot be muted, so they are + excluded from the check - otherwise an "all" check would never + pass when there are media players without volume support. + """ + return super()._should_include(state) and self._has_volume_attributes(state) + + def is_muted(self, state: State) -> bool: + """Check if the media player is muted.""" + return ( + state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True + or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + ) + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the muted-state changed.""" + if not self._has_volume_attributes(to_state): + return False + + return self.is_muted(from_state) != self.is_muted(to_state) + + 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 + return self.is_muted(state) is self._target_muted + + +class MediaPlayerMutedTrigger(_MediaPlayerMutedStateTriggerBase): + """Class for media player muted triggers.""" + + _target_muted = True + + +class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase): + """Class for media player unmuted triggers.""" + + _target_muted = False + + +class VolumeTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for volume triggers.""" + + _domain_specs = VOLUME_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, state: State) -> float | None: + """Get tracked volume as a percentage.""" + value = super()._get_tracked_value(state) + if value is None: + return None + # Convert 0.0-1.0 range to percentage (0-100) + return value * 100.0 + + def _should_include(self, state: State) -> bool: + """Check if an entity should participate in all/count checks. + + Entities without a volume level cannot have their volume tracked, + so they are excluded - otherwise an "all" check would never pass + when there are media players without volume support. + """ + return ( + super()._should_include(state) + and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + ) + + +class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin): + """Trigger for media player volume changes.""" + + +class VolumeCrossedThresholdTrigger( + EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin +): + """Trigger for media player volume crossing a threshold.""" + + TRIGGERS: dict[str, type[Trigger]] = { + "muted": MediaPlayerMutedTrigger, + "unmuted": MediaPlayerUnmutedTrigger, + "volume_changed": VolumeChangedTrigger, + "volume_crossed_threshold": VolumeCrossedThresholdTrigger, + "paused_playing": make_entity_transition_trigger( + DOMAIN, + from_states={ + MediaPlayerState.BUFFERING, + MediaPlayerState.PLAYING, + }, + to_states={ + MediaPlayerState.PAUSED, + }, + ), + "started_playing": make_entity_transition_trigger( + DOMAIN, + from_states={ + MediaPlayerState.IDLE, + MediaPlayerState.OFF, + MediaPlayerState.ON, + MediaPlayerState.PAUSED, + }, + to_states={ + MediaPlayerState.BUFFERING, + MediaPlayerState.PLAYING, + }, + ), "stopped_playing": make_entity_transition_trigger( DOMAIN, from_states={ @@ -20,6 +152,32 @@ TRIGGERS: dict[str, type[Trigger]] = { MediaPlayerState.ON, }, ), + "turned_off": make_entity_transition_trigger( + DOMAIN, + from_states={ + MediaPlayerState.BUFFERING, + MediaPlayerState.IDLE, + MediaPlayerState.ON, + MediaPlayerState.PAUSED, + MediaPlayerState.PLAYING, + }, + to_states={ + MediaPlayerState.OFF, + }, + ), + "turned_on": make_entity_transition_trigger( + DOMAIN, + from_states={ + MediaPlayerState.OFF, + }, + to_states={ + MediaPlayerState.BUFFERING, + MediaPlayerState.IDLE, + MediaPlayerState.ON, + MediaPlayerState.PAUSED, + MediaPlayerState.PLAYING, + }, + ), } diff --git a/homeassistant/components/media_player/triggers.yaml b/homeassistant/components/media_player/triggers.yaml index cd63373a8ef..a40dbf4d295 100644 --- a/homeassistant/components/media_player/triggers.yaml +++ b/homeassistant/components/media_player/triggers.yaml @@ -1,15 +1,62 @@ -stopped_playing: - target: +.trigger_common: &trigger_common + target: &trigger_media_player_target entity: domain: media_player fields: - behavior: + behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: + +.volume_threshold_entity: &volume_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + unit_of_measurement: "%" + - domain: sensor + unit_of_measurement: "%" + +.volume_threshold_number: &volume_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + +muted: *trigger_common +unmuted: *trigger_common +paused_playing: *trigger_common +started_playing: *trigger_common +stopped_playing: *trigger_common +turned_off: *trigger_common +turned_on: *trigger_common + +volume_changed: + target: *trigger_media_player_target + fields: + threshold: + required: true + selector: + numeric_threshold: + entity: *volume_threshold_entity + mode: changed + number: *volume_threshold_number + +volume_crossed_threshold: + target: *trigger_media_player_target + fields: + behavior: *trigger_behavior + for: *trigger_for + threshold: + required: true + selector: + numeric_threshold: + entity: *volume_threshold_entity + mode: crossed + number: *volume_threshold_number diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e15a7cb47e3..e2d30db2100 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -1,7 +1,5 @@ """The media_source integration.""" -from __future__ import annotations - from typing import Protocol from homeassistant.components import websocket_api @@ -23,7 +21,13 @@ from .const import ( ) from .error import MediaSourceError, Unresolvable from .helper import async_browse_media, async_resolve_media -from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia +from .models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + RootBrowseMediaSource, +) __all__ = [ "DOMAIN", @@ -34,6 +38,7 @@ __all__ = [ "MediaSourceError", "MediaSourceItem", "PlayMedia", + "RootBrowseMediaSource", "Unresolvable", "async_browse_media", "async_resolve_media", @@ -59,7 +64,7 @@ def is_media_source_id(media_content_id: str) -> bool: def generate_media_source_id(domain: str, identifier: str) -> str: """Generate a media source ID.""" - uri = f"{URI_SCHEME}{domain or ''}" + uri = f"{URI_SCHEME}{domain}" if identifier: uri += f"/{identifier}" return uri diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 38c75f19b22..1e9a7cc1eaa 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -1,7 +1,5 @@ """Constants for the media_source integration.""" -from __future__ import annotations - import re from typing import TYPE_CHECKING diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 940b67c33c6..0d7afc2b81c 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -1,18 +1,15 @@ """Helpers for media source.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.media_player import BrowseError, BrowseMedia from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.frame import report_usage from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from homeassistant.loader import bind_hass from .const import DOMAIN, MEDIA_SOURCE_DATA from .error import UnknownMediaSource, Unresolvable -from .models import BrowseMediaSource, MediaSourceItem, PlayMedia +from .models import BrowseMediaSource, MediaSourceItem, PlayMedia, RootBrowseMediaSource @callback @@ -37,13 +34,12 @@ def _get_media_item( return item -@bind_hass async def async_browse_media( hass: HomeAssistant, media_content_id: str | None, *, content_filter: Callable[[BrowseMedia], bool] | None = None, -) -> BrowseMediaSource: +) -> BrowseMediaSource | RootBrowseMediaSource: """Return media player browse media results.""" if DOMAIN not in hass.data: raise BrowseError("Media Source not loaded") @@ -71,7 +67,6 @@ async def async_browse_media( return item -@bind_hass async def async_resolve_media( hass: HomeAssistant, media_content_id: str, diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py index 3c6388db944..c1c4882e7ac 100644 --- a/homeassistant/components/media_source/http.py +++ b/homeassistant/components/media_source/http.py @@ -1,7 +1,5 @@ """HTTP views and WebSocket commands for media sources.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index b947adebad9..4595c75b39f 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -1,7 +1,5 @@ """Local Media Source Implementation.""" -from __future__ import annotations - import io import logging import mimetypes @@ -314,7 +312,7 @@ class LocalMediaView(http.HomeAssistantView): async def head( self, request: web.Request, source_dir_id: str, location: str - ) -> None: + ) -> web.Response: """Handle a HEAD request. This is sent by some DLNA renderers, like Samsung ones, prior to sending @@ -322,7 +320,9 @@ class LocalMediaView(http.HomeAssistantView): Check whether the location exists or not. """ - await self._validate_media_path(source_dir_id, location) + media_path = await self._validate_media_path(source_dir_id, location) + mime_type, _ = mimetypes.guess_type(str(media_path)) + return web.Response(content_type=mime_type) async def get( self, request: web.Request, source_dir_id: str, location: str diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 3e43b6008b1..c02cee7b9b9 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -1,7 +1,5 @@ """Media Source models.""" -from __future__ import annotations - from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -27,11 +25,9 @@ class PlayMedia: class BrowseMediaSource(BrowseMedia): """Represent a browsable media file.""" - def __init__( - self, *, domain: str | None, identifier: str | None, **kwargs: Any - ) -> None: + def __init__(self, *, domain: str, identifier: str | None, **kwargs: Any) -> None: """Initialize media source browse media.""" - media_content_id = f"{URI_SCHEME}{domain or ''}" + media_content_id = f"{URI_SCHEME}{domain}" if identifier: media_content_id += f"/{identifier}" @@ -41,6 +37,17 @@ class BrowseMediaSource(BrowseMedia): self.identifier = identifier +class RootBrowseMediaSource(BrowseMedia): + """Represent the root media source browse node.""" + + domain: None = None + identifier: None = None + + def __init__(self, **kwargs: Any) -> None: + """Initialize root media source browse media.""" + super().__init__(media_content_id=URI_SCHEME, **kwargs) + + @dataclass(slots=True) class MediaSourceItem: """A parsed media item.""" @@ -60,15 +67,13 @@ class MediaSourceItem: uri += f"/{self.identifier}" return uri - async def async_browse(self) -> BrowseMediaSource: + async def async_browse(self) -> BrowseMediaSource | RootBrowseMediaSource: """Browse this item.""" if self.domain is None: title = async_get_cached_translations( self.hass, self.hass.config.language, "common", "media_source" ).get("component.media_source.common.sources_default", "Media Sources") - base = BrowseMediaSource( - domain=None, - identifier=None, + base = RootBrowseMediaSource( media_class=MediaClass.APP, media_content_type=MediaType.APPS, title=title, diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index cd557767522..527647b4203 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -1,7 +1,5 @@ """Support for the Mediaroom Set-up-box.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 34ac5aea1cf..120a4832cc8 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -1,7 +1,5 @@ """The MELCloud Climate integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta from http import HTTPStatus @@ -19,7 +17,12 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.WATER_HEATER, +] async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool: diff --git a/homeassistant/components/melcloud/binary_sensor.py b/homeassistant/components/melcloud/binary_sensor.py new file mode 100644 index 00000000000..e94b6dc2aa8 --- /dev/null +++ b/homeassistant/components/melcloud/binary_sensor.py @@ -0,0 +1,173 @@ +"""Support for MelCloud device binary sensors.""" + +from collections.abc import Callable +import dataclasses +from typing import Any + +from pymelcloud import DEVICE_TYPE_ATW + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator +from .entity import MelCloudEntity + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class MelcloudBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Melcloud binary sensor entity.""" + + value_fn: Callable[[Any], bool | None] + enabled: Callable[[Any], bool] + + +ATW_BINARY_SENSORS: tuple[MelcloudBinarySensorEntityDescription, ...] = ( + MelcloudBinarySensorEntityDescription( + key="boiler_status", + translation_key="boiler_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.boiler_status, + enabled=lambda data: data.device.boiler_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="booster_heater1_status", + translation_key="booster_heater_status", + translation_placeholders={"number": "1"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.booster_heater1_status, + enabled=lambda data: data.device.booster_heater1_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="booster_heater2_status", + translation_key="booster_heater_status", + translation_placeholders={"number": "2"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.booster_heater2_status, + enabled=lambda data: data.device.booster_heater2_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="booster_heater2plus_status", + translation_key="booster_heater_status", + translation_placeholders={"number": "2+"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.booster_heater2plus_status, + enabled=lambda data: data.device.booster_heater2plus_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="immersion_heater_status", + translation_key="immersion_heater_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.immersion_heater_status, + enabled=lambda data: data.device.immersion_heater_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="water_pump1_status", + translation_key="water_pump_status", + translation_placeholders={"number": "1"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.water_pump1_status, + enabled=lambda data: data.device.water_pump1_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="water_pump2_status", + translation_key="water_pump_status", + translation_placeholders={"number": "2"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.water_pump2_status, + enabled=lambda data: data.device.water_pump2_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="water_pump3_status", + translation_key="water_pump_status", + translation_placeholders={"number": "3"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.water_pump3_status, + enabled=lambda data: data.device.water_pump3_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="water_pump4_status", + translation_key="water_pump_status", + translation_placeholders={"number": "4"}, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.water_pump4_status, + enabled=lambda data: data.device.water_pump4_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="valve_3way_status", + translation_key="valve_3way_status", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.device.valve_3way_status, + enabled=lambda data: data.device.valve_3way_status is not None, + ), + MelcloudBinarySensorEntityDescription( + key="valve_2way_status", + translation_key="valve_2way_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.valve_2way_status, + enabled=lambda data: data.device.valve_2way_status is not None, + ), +) + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: MelCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MELCloud device binary sensors based on config_entry.""" + coordinator = entry.runtime_data + + if DEVICE_TYPE_ATW not in coordinator: + return + + entities: list[MelDeviceBinarySensor] = [ + MelDeviceBinarySensor(coord, description) + for description in ATW_BINARY_SENSORS + for coord in coordinator[DEVICE_TYPE_ATW] + if description.enabled(coord) + ] + async_add_entities(entities) + + +class MelDeviceBinarySensor(MelCloudEntity, BinarySensorEntity): + """Representation of a Binary Sensor.""" + + entity_description: MelcloudBinarySensorEntityDescription + + def __init__( + self, + coordinator: MelCloudDeviceUpdateCoordinator, + description: MelcloudBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}" + ) + self._attr_device_info = coordinator.device_info + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 488268a3295..733f4fe7641 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -1,7 +1,5 @@ """Platform for climate integration.""" -from __future__ import annotations - from typing import Any, cast from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice @@ -162,7 +160,9 @@ class AtaDeviceClimate(MelCloudClimate): attr.update( { ATTR_VANE_HORIZONTAL: vane_horizontal, - ATTR_VANE_HORIZONTAL_POSITIONS: self._device.vane_horizontal_positions, + ATTR_VANE_HORIZONTAL_POSITIONS: ( + self._device.vane_horizontal_positions + ), } ) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 22dce40c5d6..ced5545daca 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the MELCloud platform.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from http import HTTPStatus diff --git a/homeassistant/components/melcloud/coordinator.py b/homeassistant/components/melcloud/coordinator.py index 3ffc9460242..031e2540ac7 100644 --- a/homeassistant/components/melcloud/coordinator.py +++ b/homeassistant/components/melcloud/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the MELCloud integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py index c601f886470..8de7ebcdea7 100644 --- a/homeassistant/components/melcloud/diagnostics.py +++ b/homeassistant/components/melcloud/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for MelCloud.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/melcloud/entity.py b/homeassistant/components/melcloud/entity.py index b0d9b839481..d2ea7e1e7dc 100644 --- a/homeassistant/components/melcloud/entity.py +++ b/homeassistant/components/melcloud/entity.py @@ -1,7 +1,5 @@ """Base entity for MELCloud integration.""" -from __future__ import annotations - from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import MelCloudDeviceUpdateCoordinator diff --git a/homeassistant/components/melcloud/icons.json b/homeassistant/components/melcloud/icons.json index 7df606d4144..90d0fe752a9 100644 --- a/homeassistant/components/melcloud/icons.json +++ b/homeassistant/components/melcloud/icons.json @@ -1,11 +1,55 @@ { "entity": { + "binary_sensor": { + "boiler_status": { + "default": "mdi:water-boiler-off", + "state": { + "on": "mdi:water-boiler" + } + }, + "valve_2way_status": { + "default": "mdi:valve-closed", + "state": { + "on": "mdi:valve-open" + } + }, + "valve_3way_status": { + "default": "mdi:valve-closed", + "state": { + "on": "mdi:valve-open" + } + } + }, "sensor": { + "daily_cooling_energy_consumed": { + "default": "mdi:snowflake" + }, + "daily_cooling_energy_produced": { + "default": "mdi:snowflake" + }, + "daily_heating_energy_consumed": { + "default": "mdi:fire" + }, + "daily_heating_energy_produced": { + "default": "mdi:fire" + }, + "daily_hot_water_energy_consumed": { + "default": "mdi:water-boiler" + }, + "daily_hot_water_energy_produced": { + "default": "mdi:water-boiler" + }, + "demand_percentage": { + "default": "mdi:gauge" + }, "energy_consumed": { "default": "mdi:factory" }, "fan_frequency": { "default": "mdi:fan" + }, + "mixing_tank_temperature": { + "default": "mdi:water-thermometer" } } }, diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index b683ee6671a..cd19d93145d 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["melcloud"], - "requirements": ["python-melcloud==0.1.2"] + "requirements": ["python-melcloud==0.1.3"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index f9bf1de42d8..1d9b3f4d0a2 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,7 +1,5 @@ """Support for MelCloud device sensors.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from typing import Any @@ -16,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfEnergy, @@ -34,7 +33,7 @@ from .entity import MelCloudEntity class MelcloudSensorEntityDescription(SensorEntityDescription): """Describes Melcloud sensor entity.""" - value_fn: Callable[[Any], float] + value_fn: Callable[[Any], float | None] enabled: Callable[[Any], bool] @@ -45,8 +44,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.room_temperature, - enabled=lambda x: True, + value_fn=lambda data: data.device.room_temperature, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="energy", @@ -54,8 +53,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.total_energy_consumed, - enabled=lambda x: x.device.has_energy_consumed_meter, + value_fn=lambda data: data.device.total_energy_consumed, + enabled=lambda data: data.device.has_energy_consumed_meter, ), MelcloudSensorEntityDescription( key="outside_temperature", @@ -63,8 +62,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.outdoor_temperature, - enabled=lambda x: x.device.has_outdoor_temperature, + value_fn=lambda data: data.device.outdoor_temperature, + enabled=lambda data: data.device.has_outdoor_temperature, ), ) ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( @@ -74,8 +73,8 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.outside_temperature, - enabled=lambda x: True, + value_fn=lambda data: data.device.outside_temperature, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="tank_temperature", @@ -83,8 +82,58 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.tank_temperature, - enabled=lambda x: True, + value_fn=lambda data: data.device.tank_temperature, + enabled=lambda data: True, + ), + MelcloudSensorEntityDescription( + key="system_flow_temperature", + translation_key="flow_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.device.flow_temperature, + enabled=lambda data: data.device.flow_temperature is not None, + ), + MelcloudSensorEntityDescription( + key="system_return_temperature", + translation_key="return_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.device.return_temperature, + enabled=lambda data: data.device.return_temperature is not None, + ), + MelcloudSensorEntityDescription( + key="flow_temperature_boiler", + translation_key="flow_temperature_boiler", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.device.flow_temperature_boiler, + enabled=lambda data: data.device.flow_temperature_boiler is not None, + ), + MelcloudSensorEntityDescription( + key="return_temperature_boiler", + translation_key="return_temperature_boiler", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.device.return_temperature_boiler, + enabled=lambda data: data.device.return_temperature_boiler is not None, + ), + MelcloudSensorEntityDescription( + key="mixing_tank_temperature", + translation_key="mixing_tank_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.device.mixing_tank_temperature, + enabled=lambda data: data.device.mixing_tank_temperature is not None, ), MelcloudSensorEntityDescription( key="condensing_temperature", @@ -92,8 +141,9 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.get_device_prop("CondensingTemperature"), - enabled=lambda x: True, + suggested_display_precision=1, + value_fn=lambda data: data.device.condensing_temperature, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="fan_frequency", @@ -101,8 +151,17 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.device.get_device_prop("HeatPumpFrequency"), - enabled=lambda x: True, + value_fn=lambda data: data.device.heat_pump_frequency, + enabled=lambda data: True, + ), + MelcloudSensorEntityDescription( + key="demand_percentage", + translation_key="demand_percentage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data: data.device.demand_percentage, + enabled=lambda data: data.device.demand_percentage is not None, ), MelcloudSensorEntityDescription( key="rssi", @@ -110,16 +169,80 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda x: x.device.wifi_signal, - enabled=lambda x: True, + value_fn=lambda data: data.device.wifi_signal, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="energy_produced", translation_key="energy_produced", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, - value_fn=lambda x: x.device.get_device_prop("CurrentEnergyProduced"), - enabled=lambda x: True, + value_fn=lambda data: data.device.get_device_prop("CurrentEnergyProduced"), + enabled=lambda data: True, + ), + MelcloudSensorEntityDescription( + key="daily_heating_energy_consumed", + translation_key="daily_heating_energy_consumed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + value_fn=lambda data: data.device.daily_heating_energy_consumed, + enabled=lambda data: data.device.daily_heating_energy_consumed is not None, + ), + MelcloudSensorEntityDescription( + key="daily_heating_energy_produced", + translation_key="daily_heating_energy_produced", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.daily_heating_energy_produced, + enabled=lambda data: data.device.daily_heating_energy_produced is not None, + ), + MelcloudSensorEntityDescription( + key="daily_cooling_energy_consumed", + translation_key="daily_cooling_energy_consumed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.daily_cooling_energy_consumed, + enabled=lambda data: data.device.daily_cooling_energy_consumed is not None, + ), + MelcloudSensorEntityDescription( + key="daily_cooling_energy_produced", + translation_key="daily_cooling_energy_produced", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.daily_cooling_energy_produced, + enabled=lambda data: data.device.daily_cooling_energy_produced is not None, + ), + MelcloudSensorEntityDescription( + key="daily_hot_water_energy_consumed", + translation_key="daily_hot_water_energy_consumed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + value_fn=lambda data: data.device.daily_hot_water_energy_consumed, + enabled=lambda data: data.device.daily_hot_water_energy_consumed is not None, + ), + MelcloudSensorEntityDescription( + key="daily_hot_water_energy_produced", + translation_key="daily_hot_water_energy_produced", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda data: data.device.daily_hot_water_energy_produced, + enabled=lambda data: data.device.daily_hot_water_energy_produced is not None, ), ) ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( @@ -130,7 +253,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda zone: zone.room_temperature, - enabled=lambda x: True, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="flow_temperature", @@ -138,8 +261,8 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda zone: zone.flow_temperature, - enabled=lambda x: True, + value_fn=lambda zone: zone.zone_flow_temperature, + enabled=lambda data: True, ), MelcloudSensorEntityDescription( key="return_temperature", @@ -147,8 +270,8 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda zone: zone.return_temperature, - enabled=lambda x: True, + value_fn=lambda zone: zone.zone_return_temperature, + enabled=lambda data: True, ), ) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 0af6c7a8647..2a8f1197ef4 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -42,10 +42,51 @@ } }, "entity": { + "binary_sensor": { + "boiler_status": { + "name": "Boiler" + }, + "booster_heater_status": { + "name": "Booster heater {number}" + }, + "immersion_heater_status": { + "name": "Immersion heater" + }, + "valve_2way_status": { + "name": "2-way valve" + }, + "valve_3way_status": { + "name": "3-way valve" + }, + "water_pump_status": { + "name": "Water pump {number}" + } + }, "sensor": { "condensing_temperature": { "name": "Condensing temperature" }, + "daily_cooling_energy_consumed": { + "name": "Daily cooling energy consumed" + }, + "daily_cooling_energy_produced": { + "name": "Daily cooling energy produced" + }, + "daily_heating_energy_consumed": { + "name": "Daily heating energy consumed" + }, + "daily_heating_energy_produced": { + "name": "Daily heating energy produced" + }, + "daily_hot_water_energy_consumed": { + "name": "Daily hot water energy consumed" + }, + "daily_hot_water_energy_produced": { + "name": "Daily hot water energy produced" + }, + "demand_percentage": { + "name": "Demand percentage" + }, "energy_consumed": { "name": "Energy consumed" }, @@ -53,16 +94,25 @@ "name": "Energy produced" }, "fan_frequency": { - "name": "Fan frequency" + "name": "Heat pump frequency" }, "flow_temperature": { "name": "Flow temperature" }, + "flow_temperature_boiler": { + "name": "Boiler flow temperature" + }, + "mixing_tank_temperature": { + "name": "Mixing tank temperature" + }, "outside_temperature": { "name": "Outside temperature" }, "return_temperature": { - "name": "Flow return temperature" + "name": "Return temperature" + }, + "return_temperature_boiler": { + "name": "Boiler return temperature" }, "room_temperature": { "name": "Room temperature" diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index 6b91ef4a353..7eb044bc471 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -1,7 +1,5 @@ """Platform for water_heater integration.""" -from __future__ import annotations - from typing import Any from pymelcloud import DEVICE_TYPE_ATW, AtwDevice diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index bee457bada9..0a94e9cd93a 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -1,7 +1,5 @@ """Support for Melissa Climate A/C.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 2d9faf91bd2..4cc112509d9 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -1,7 +1,5 @@ """The melnor integration.""" -from __future__ import annotations - from melnor_bluetooth.device import Device from homeassistant.components import bluetooth diff --git a/homeassistant/components/melnor/config_flow.py b/homeassistant/components/melnor/config_flow.py index 3274d8a1972..d307b9dadcc 100644 --- a/homeassistant/components/melnor/config_flow.py +++ b/homeassistant/components/melnor/config_flow.py @@ -1,7 +1,5 @@ """Config flow for melnor.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/melnor/entity.py b/homeassistant/components/melnor/entity.py index 377a758a2be..21a4e75ff21 100644 --- a/homeassistant/components/melnor/entity.py +++ b/homeassistant/components/melnor/entity.py @@ -87,7 +87,8 @@ def get_entities_for_valves[_T: EntityDescription]( """Get descriptions for valves.""" entities: list[CoordinatorEntity[MelnorDataUpdateCoordinator]] = [] - # This device may not have 4 valves total, but the library will only expose the right number of valves + # This device may not have 4 valves total, but the library + # will only expose the right number of valves for i in range(1, 5): valve = coordinator.data[f"zone{i}"] diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 863faf080bd..408ff85ea83 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -1,7 +1,5 @@ """Number support for Melnor Bluetooth water timer.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index e645019f1e8..9bba56b063e 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -1,7 +1,5 @@ """Sensor support for Melnor Bluetooth water timer.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index d0240a471b6..294645f745b 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -1,7 +1,5 @@ """Switch support for Melnor Bluetooth water timer.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 978801dd64c..1d6456f043d 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -1,7 +1,5 @@ """Number support for Melnor Bluetooth water timer.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 70995fc69b5..253c3de486a 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -1,7 +1,5 @@ """Support for the Meraki CMX location service.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py index 4d4ffdc814e..841b9fb44f5 100644 --- a/homeassistant/components/message_bird/notify.py +++ b/homeassistant/components/message_bird/notify.py @@ -1,7 +1,5 @@ """MessageBird platform for notify component.""" -from __future__ import annotations - import logging from typing import Any @@ -67,6 +65,7 @@ class MessageBirdNotificationService(BaseNotificationService): self.client.message_create( self.sender, target, message, {"reference": "HA"} ) + # pylint: disable-next=home-assistant-action-swallowed-exception except ErrorException as exception: _LOGGER.error("Failed to notify %s: %s", target, exception) continue diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index d5f80d442a4..f8305094b81 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1,7 +1,5 @@ """The met component.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 54d528a7406..14cb8f60a13 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -1,6 +1,5 @@ """Config flow to configure Met component.""" - -from __future__ import annotations +# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas from typing import Any @@ -54,7 +53,8 @@ def _get_data_schema( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> vol.Schema: """Get a schema with default values.""" - # If tracking home or no config entry is passed in, default value come from Home location + # If tracking home or no config entry is passed in, + # default value come from Home location if config_entry is None or config_entry.data.get(CONF_TRACK_HOME, False): return vol.Schema( { diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index 0ba3b9e1626..99361467b72 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Met.no integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from datetime import timedelta import logging diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 8d8317607be..46fc9cba4db 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -1,7 +1,5 @@ """Support for Met.no weather service.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any @@ -48,6 +46,8 @@ from .const import ( ) from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "Met.no" diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 761d0655237..19e12e6bd76 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -31,6 +31,8 @@ class MetEireannFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=HOME_LOCATION_NAME): str, vol.Required( CONF_LATITUDE, default=self.hass.config.latitude diff --git a/homeassistant/components/met_eireann/coordinator.py b/homeassistant/components/met_eireann/coordinator.py index b2873c19724..389cc1712cc 100644 --- a/homeassistant/components/met_eireann/coordinator.py +++ b/homeassistant/components/met_eireann/coordinator.py @@ -1,7 +1,5 @@ """The met_eireann component.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta import logging diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 889e0ac6db5..2033b84aa67 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -31,7 +31,7 @@ from .coordinator import MetEireannConfigEntry, MetEireannUpdateCoordinator def format_condition(condition: str | None) -> str | None: - """Map the conditions provided by the weather API to those supported by the frontend.""" + """Map weather API conditions to those supported by the frontend.""" if condition is not None: for key, value in CONDITION_MAP.items(): if condition in value: diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 023347a1a8d..d72fdf814e5 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,4 +1,5 @@ """Support for Meteo-France weather data.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index 37995534fb1..24c2c7938ab 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Meteo-France integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 86819d825b7..d57ddf31509 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,7 +1,5 @@ """Meteo-France component constants.""" -from __future__ import annotations - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, diff --git a/homeassistant/components/meteo_france/coordinator.py b/homeassistant/components/meteo_france/coordinator.py index 8c4db6fd87b..0a98c538388 100644 --- a/homeassistant/components/meteo_france/coordinator.py +++ b/homeassistant/components/meteo_france/coordinator.py @@ -1,7 +1,5 @@ """Support for Meteo-France weather data.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 208cd568350..226e99fdd5c 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -1,7 +1,7 @@ { "domain": "meteo_france", "name": "M\u00e9t\u00e9o-France", - "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"], + "codeowners": ["@hacf-fr/reviewers", "@oncleben31", "@Quentame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", "integration_type": "service", diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 75876153d2d..f7a279ec14f 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,7 +1,5 @@ """Support for Meteo-France raining forecast sensor.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -239,7 +237,8 @@ class MeteoFranceSensor[_DataT: Rain | Forecast | CurrentPhenomenons]( if hasattr(coordinator.data, "position"): city_name = coordinator.data.position["name"] self._attr_name = f"{city_name} {description.name}" - self._attr_unique_id = f"{coordinator.data.position['lat']},{coordinator.data.position['lon']}_{description.key}" + pos = coordinator.data.position + self._attr_unique_id = f"{pos['lat']},{pos['lon']}_{description.key}" @property def device_info(self) -> DeviceInfo: @@ -262,7 +261,9 @@ class MeteoFranceSensor[_DataT: Rain | Forecast | CurrentPhenomenons]( # Specific case for probability forecast if path[0] == "probability_forecast": if len(path) == 3: - # This is a fix compared to other entitty as first index is always null in API result for unknown reason + # This is a fix compared to other entity as + # first index is always null in API result + # for unknown reason value = _find_first_probability_forecast_not_null(data, path) else: value = data[0][path[1]] diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 7076edb4f99..d1aebafc545 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -11,6 +11,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, @@ -49,7 +50,8 @@ def format_condition(condition: str, force_day: bool = False) -> str: """Return condition from dict CONDITION_MAP.""" mapped_condition = CONDITION_MAP.get(condition, condition) if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT: - # Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny + # Meteo-France can return clear night condition instead + # of sunny for daily weather, so we map it to sunny return ATTR_CONDITION_SUNNY return mapped_condition @@ -99,7 +101,8 @@ class MeteoFranceWeather( super().__init__(coordinator) self._attr_name = self.coordinator.data.position["name"] self._mode = mode - self._attr_unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" + pos = self.coordinator.data.position + self._attr_unique_id = f"{pos['lat']},{pos['lon']}" @callback def _handle_coordinator_update(self) -> None: @@ -184,6 +187,9 @@ class MeteoFranceWeather( ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["value"], ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["rain"].get("1h"), ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast["wind"].get( + "gust" + ), ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] if forecast["wind"]["direction"] != -1 else None, @@ -191,7 +197,8 @@ class MeteoFranceWeather( ) else: for forecast in self.coordinator.data.daily_forecast: - # stop when we don't have a weather condition (can happen around last days of forecast, max 14) + # stop when we don't have a weather condition + # (can happen around last days of forecast, max 14) if not forecast.get("weather12H"): break forecast_data.append( diff --git a/homeassistant/components/meteo_lt/__init__.py b/homeassistant/components/meteo_lt/__init__.py index 8e508e76203..4d12e484aea 100644 --- a/homeassistant/components/meteo_lt/__init__.py +++ b/homeassistant/components/meteo_lt/__init__.py @@ -1,7 +1,5 @@ """The Meteo.lt integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import CONF_PLACE_CODE, PLATFORMS diff --git a/homeassistant/components/meteo_lt/config_flow.py b/homeassistant/components/meteo_lt/config_flow.py index b9478e8b37e..07bfec2a04d 100644 --- a/homeassistant/components/meteo_lt/config_flow.py +++ b/homeassistant/components/meteo_lt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Meteo.lt integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/meteo_lt/coordinator.py b/homeassistant/components/meteo_lt/coordinator.py index 12044f6fe78..8948250c7f8 100644 --- a/homeassistant/components/meteo_lt/coordinator.py +++ b/homeassistant/components/meteo_lt/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Meteo.lt integration.""" -from __future__ import annotations - import logging import aiohttp @@ -55,7 +53,8 @@ class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]): # Check if forecast data is available if not forecast.forecast_timestamps: raise UpdateFailed( - f"No forecast data available for {self.place_code} - API returned empty timestamps" + f"No forecast data available for {self.place_code}" + " - API returned empty timestamps" ) return forecast diff --git a/homeassistant/components/meteo_lt/weather.py b/homeassistant/components/meteo_lt/weather.py index ec48bbf2a12..ae64c394ee9 100644 --- a/homeassistant/components/meteo_lt/weather.py +++ b/homeassistant/components/meteo_lt/weather.py @@ -1,7 +1,5 @@ """Weather platform for Meteo.lt integration.""" -from __future__ import annotations - from collections import defaultdict from datetime import datetime from typing import Any @@ -129,7 +127,8 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE async def async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast.""" - # Using hourly data to create daily summaries, since daily data is not provided directly + # Using hourly data to create daily summaries, since + # daily data is not provided directly if not self.coordinator.data: return None diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 95124445363..876b22f0690 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor for MeteoAlarm.eu.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -13,7 +11,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,8 +22,6 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Information provided by MeteoAlarm" -CONF_COUNTRY = "country" -CONF_LANGUAGE = "language" CONF_PROVINCE = "province" DEFAULT_NAME = "meteoalarm" @@ -84,5 +80,12 @@ class MeteoAlertBinarySensor(BinarySensorEntity): expiration_date = dt_util.parse_datetime(alert["expires"]) if expiration_date is not None and expiration_date > dt_util.utcnow(): - self._attr_extra_state_attributes = alert + self._attr_extra_state_attributes = { + key: ( + value.encode("utf-8", errors="replace").decode("utf-8") + if isinstance(value, str) + else value + ) + for key, value in alert.items() + } self._attr_is_on = True diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py index 3d8f93d014d..5db0ba81477 100644 --- a/homeassistant/components/meteoclimatic/const.py +++ b/homeassistant/components/meteoclimatic/const.py @@ -1,7 +1,5 @@ """Meteoclimatic component constants.""" -from __future__ import annotations - from datetime import timedelta from meteoclimatic import Condition diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index fc011a08216..4243f52e245 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -1,7 +1,5 @@ """The Met Office integration.""" -from __future__ import annotations - import asyncio from datapoint.Manager import Manager diff --git a/homeassistant/components/metoffice/config_flow.py b/homeassistant/components/metoffice/config_flow.py index 19da754fc6a..1101b278025 100644 --- a/homeassistant/components/metoffice/config_flow.py +++ b/homeassistant/components/metoffice/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Met Office integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/metoffice/coordinator.py b/homeassistant/components/metoffice/coordinator.py index 322c4d61819..1aa8c9acfe5 100644 --- a/homeassistant/components/metoffice/coordinator.py +++ b/homeassistant/components/metoffice/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Met Office integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Literal diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index e03face108b..6836f7036bc 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -1,7 +1,5 @@ """Helpers used for Met Office integration.""" -from __future__ import annotations - from typing import Any diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index e858a72c1c6..b6a3cf2a097 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -1,7 +1,5 @@ """Support for UK Met Office weather service.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -60,8 +58,7 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( key="weather", native_attr_name="significantWeatherCode", name="Weather", - icon="mdi:weather-sunny", # but will adapt to current conditions - entity_registry_enabled_default=True, + icon="mdi:weather-sunny", ), MetOfficeSensorEntityDescription( key="temperature", @@ -70,7 +67,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_registry_enabled_default=True, ), MetOfficeSensorEntityDescription( key="feels_like_temperature", @@ -79,7 +75,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - icon=None, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( @@ -92,7 +87,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( suggested_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=True, ), MetOfficeSensorEntityDescription( key="wind_direction", @@ -132,7 +126,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( name="UV index", native_unit_of_measurement=UV_INDEX, icon="mdi:weather-sunny-alert", - entity_registry_enabled_default=True, ), MetOfficeSensorEntityDescription( key="precipitation", @@ -141,7 +134,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( name="Probability of precipitation", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-rainy", - entity_registry_enabled_default=True, ), MetOfficeSensorEntityDescription( key="humidity", @@ -150,7 +142,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - icon=None, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 62202333f20..ba1b816673b 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,7 +1,5 @@ """Support for UK Met Office weather service.""" -from __future__ import annotations - from datetime import datetime from typing import Any, cast diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index b46d876cd51..74487001298 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -1,7 +1,5 @@ """Support for Ubiquiti mFi sensors.""" -from __future__ import annotations - import logging from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py index 1fbf7f8cb82..913c3193718 100644 --- a/homeassistant/components/mfi/switch.py +++ b/homeassistant/components/mfi/switch.py @@ -1,7 +1,5 @@ """Support for Ubiquiti mFi switches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/microbees/coordinator.py b/homeassistant/components/microbees/coordinator.py index 67580da50db..4883d9f2a81 100644 --- a/homeassistant/components/microbees/coordinator.py +++ b/homeassistant/components/microbees/coordinator.py @@ -1,7 +1,5 @@ """The microBees Coordinator.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/microbees/cover.py b/homeassistant/components/microbees/cover.py index b09797e57ba..28ea697d022 100644 --- a/homeassistant/components/microbees/cover.py +++ b/homeassistant/components/microbees/cover.py @@ -84,12 +84,12 @@ class MBCover(MicroBeesEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - sendCommand = await self.coordinator.microbees.sendCommand( + send_command = await self.coordinator.microbees.sendCommand( self.actuator_up_id, self.actuator_up.configuration.actuator_timing * 1000, ) - if not sendCommand: + if not send_command: raise HomeAssistantError(f"Failed to open {self.name}") self._attr_is_opening = True @@ -101,11 +101,11 @@ class MBCover(MicroBeesEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - sendCommand = await self.coordinator.microbees.sendCommand( + send_command = await self.coordinator.microbees.sendCommand( self.actuator_down_id, self.actuator_down.configuration.actuator_timing * 1000, ) - if not sendCommand: + if not send_command: raise HomeAssistantError(f"Failed to close {self.name}") self._attr_is_closing = True diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py index 4a791b0620f..3d4a60c81fc 100644 --- a/homeassistant/components/microbees/light.py +++ b/homeassistant/components/microbees/light.py @@ -56,10 +56,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity): """Turn on the light.""" if ATTR_RGBW_COLOR in kwargs: self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] - sendCommand = await self.coordinator.microbees.sendCommand( + send_command = await self.coordinator.microbees.sendCommand( self.actuator_id, 1, color=self._attr_rgbw_color ) - if not sendCommand: + if not send_command: raise HomeAssistantError(f"Failed to turn on {self.name}") self.actuator.value = True @@ -67,10 +67,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - sendCommand = await self.coordinator.microbees.sendCommand( + send_command = await self.coordinator.microbees.sendCommand( self.actuator_id, 0, color=self._attr_rgbw_color ) - if not sendCommand: + if not send_command: raise HomeAssistantError(f"Failed to turn off {self.name}") self.actuator.value = False diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 5a8d9c3dae0..6b4adb6ffc6 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -1,7 +1,5 @@ """Support for Microsoft face recognition.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine import json @@ -117,6 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entities[g_id] = MicrosoftFaceGroupEntity(face, g_id, name) await component.async_add_entities([entities[g_id]]) + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) @@ -134,6 +133,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity = entities.pop(g_id) await component.async_remove_entity(entity.entity_id) + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) @@ -147,6 +147,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await face.call_api("post", f"persongroups/{g_id}/train") + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Can't train group '%s' with error: %s", g_id, err) @@ -166,6 +167,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: face.store[g_id][name] = user_data["personId"] entities[g_id].async_write_ha_state() + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Can't create person '%s' with error: %s", name, err) @@ -184,6 +186,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: face.store[g_id].pop(name) entities[g_id].async_write_ha_state() + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) @@ -207,6 +210,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: image.content, binary=True, ) + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error( "Can't add an image of a person '%s' with error: %s", p_id, err diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index 57e785ad328..3b69cac143f 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -1,7 +1,5 @@ """Component that will help set the Microsoft face detect processing.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index ed793580e1b..91fc67c9d15 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -1,7 +1,5 @@ """Component that will help set the Microsoft face for verify processing.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 4758a947188..76500db05f2 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -1,7 +1,5 @@ """The Miele integration.""" -from __future__ import annotations - from aiohttp import ClientError, ClientResponseError from pymiele import MieleAPI diff --git a/homeassistant/components/miele/binary_sensor.py b/homeassistant/components/miele/binary_sensor.py index 1e713cd68df..4dd5ac97bc5 100644 --- a/homeassistant/components/miele/binary_sensor.py +++ b/homeassistant/components/miele/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Miele integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/miele/button.py b/homeassistant/components/miele/button.py index a9140c855b3..115d6c2ca98 100644 --- a/homeassistant/components/miele/button.py +++ b/homeassistant/components/miele/button.py @@ -1,7 +1,5 @@ """Platform for Miele button integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Final diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py index 09d16cb9e52..9e4135176bf 100644 --- a/homeassistant/components/miele/climate.py +++ b/homeassistant/components/miele/climate.py @@ -1,7 +1,5 @@ """Platform for Miele integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 2a3ea75a982..62b9671855c 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -20,7 +20,8 @@ LIGHT_ON = 1 LIGHT_OFF = 2 # API "no reading" sentinels. Most temperatures use centidegrees (-32768 -> -327.68 °C). -# Some devices report the int16 minimum already in degrees after scaling (-3276800 raw -> -32768 °C). +# Some devices report the int16 minimum already in degrees +# after scaling (-3276800 raw -> -32768 C). DISABLED_TEMP_ENTITIES = ( -32768 / 100, -32766 / 100, @@ -174,6 +175,8 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True): disinfecting = 285 flex_load_active = 11047 automatic_start = 11044 + paused = 11052 + cancelled = 11053 class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True): @@ -372,9 +375,11 @@ class ProgramPhaseSteamOvenCombi(MieleEnum, missing_to_none=True): energy_save = 3084 pre_heating = 3099 - steam_reduction = 3863 + steam_reduction = 3863, 7959 waiting_for_start = 7939 heating_up_phase = 7940 + drying = 7961 + rinse = 7962 class ProgramPhaseSteamOvenMicro(MieleEnum, missing_to_none=True): @@ -477,6 +482,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True): down_filled_items = 129 cottons_eco = 133 quick_power_wash = 146, 10031 + quick_intense = 177 eco_40_60 = 190, 10007 bed_linen = 10047 easy_care = 10016 @@ -508,7 +514,9 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): tall_items = 17, 42 glasses_warm = 19 quick_intense = 21 - normal = 30 + normal = 23, 30 + pre_wash = 24 + pot_rests_and_filters = 25 power_wash = 44, 204 comfort_wash = 203 comfort_wash_plus = 209 @@ -629,7 +637,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True): rinse = 333 shabbat_program = 335 yom_tov = 336 - hydroclean = 341 + hydroclean = 341, 2434 drying = 357, 2028 heat_crockery = 358 prove_dough = 359, 2023 diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index dde6efedd5a..e8b7e40f66c 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -1,8 +1,5 @@ """Coordinator module for Miele integration.""" -from __future__ import annotations - -import asyncio from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -79,35 +76,32 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): async def _async_update_data(self) -> MieleCoordinatorData: """Fetch data from the Miele API.""" - async with asyncio.timeout(10): - # Get devices - devices_json = await self.api.get_devices() - devices = { - device_id: MieleDevice(device) - for device_id, device in devices_json.items() - } - self.devices = devices - actions = {} + devices_json = await self.api.get_devices() + devices = { + device_id: MieleDevice(device) for device_id, device in devices_json.items() + } + self.devices = devices + actions = {} - for device_id in devices: - try: - actions_json = await self.api.get_actions(device_id) - except ClientResponseError as err: - _LOGGER.debug( - "Error fetching actions for device %s: Status: %s, Message: %s", - device_id, - str(err.status), - err.message, - ) - actions_json = {} - except TimeoutError: - _LOGGER.debug( - "Timeout fetching actions for device %s", - device_id, - ) - actions_json = {} - actions[device_id] = MieleAction(actions_json) - return MieleCoordinatorData(devices=devices, actions=actions) + for device_id in devices: + try: + actions_json = await self.api.get_actions(device_id) + except ClientResponseError as err: + _LOGGER.debug( + "Error fetching actions for device %s: Status: %s, Message: %s", + device_id, + str(err.status), + err.message, + ) + actions_json = {} + except TimeoutError: + _LOGGER.debug( + "Timeout fetching actions for device %s", + device_id, + ) + actions_json = {} + actions[device_id] = MieleAction(actions_json) + return MieleCoordinatorData(devices=devices, actions=actions) def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]: """Add devices.""" diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py index 4d7d629139a..4c4f71cb168 100644 --- a/homeassistant/components/miele/diagnostics.py +++ b/homeassistant/components/miele/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Miele.""" -from __future__ import annotations - import hashlib from typing import Any, cast @@ -38,19 +36,25 @@ async def async_get_config_entry_diagnostics( "devices": redact_identifiers( { device_id: device_data.raw - for device_id, device_data in config_entry.runtime_data.coordinator.data.devices.items() + for device_id, device_data in ( + config_entry.runtime_data.coordinator.data.devices.items() + ) } ), "filling_levels": redact_identifiers( { device_id: filling_level_data.raw - for device_id, filling_level_data in config_entry.runtime_data.aux_coordinator.data.filling_levels.items() + for device_id, filling_level_data in ( + config_entry.runtime_data.aux_coordinator.data.filling_levels.items() + ) } ), "actions": redact_identifiers( { device_id: action_data.raw - for device_id, action_data in config_entry.runtime_data.coordinator.data.actions.items() + for device_id, action_data in ( + config_entry.runtime_data.coordinator.data.actions.items() + ) } ), } diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py index ae500898b4e..890d27d13bb 100644 --- a/homeassistant/components/miele/fan.py +++ b/homeassistant/components/miele/fan.py @@ -1,7 +1,5 @@ """Platform for Miele fan entity.""" -from __future__ import annotations - from dataclasses import dataclass import logging import math @@ -167,12 +165,12 @@ class MieleFan(MieleEntity, FanEntity): try: await self.api.send_action(self._device_id, {POWER_ON: True}) except ClientResponseError as ex: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_state_error", translation_placeholders={ "entity": self.entity_id, - "err_status": str(ex.status), }, ) from ex @@ -185,12 +183,12 @@ class MieleFan(MieleEntity, FanEntity): try: await self.api.send_action(self._device_id, {POWER_OFF: True}) except ClientResponseError as ex: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_state_error", translation_placeholders={ "entity": self.entity_id, - "err_status": str(ex.status), }, ) from ex diff --git a/homeassistant/components/miele/light.py b/homeassistant/components/miele/light.py index 93856b8429c..8bb5cbf6b48 100644 --- a/homeassistant/components/miele/light.py +++ b/homeassistant/components/miele/light.py @@ -1,7 +1,5 @@ """Platform for Miele light entity.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index a6b82fc884b..c9f1a3df39a 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -9,7 +9,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "platinum", - "requirements": ["pymiele==0.6.1"], + "requirements": ["pymiele==0.6.2"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/homeassistant/components/miele/select.py b/homeassistant/components/miele/select.py index 7c756b129ea..aee6775a90a 100644 --- a/homeassistant/components/miele/select.py +++ b/homeassistant/components/miele/select.py @@ -1,7 +1,5 @@ """Platform for Miele select entity.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a723763ea35..1a15b28ed54 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Miele integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime, timedelta @@ -59,6 +57,7 @@ DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { "KM7575": 6, + "KM7576": 6, "KM7678": 6, "KM7697": 6, "KM7699": 5, @@ -848,8 +847,9 @@ async def async_setup_entry( and definition.description.value_fn(device) is None and definition.description.zone != 1 ): - # Optional temperature datapoints (extra fridge zones, oven food probe): only - # create the entity after the API first reports a valid reading, then keep it + # Optional temperature datapoints (extra fridge + # zones, oven food probe): only create the entity + # after the API first reports a valid reading, keep it # so state can return to unknown when the datapoint is inactive. return _is_entity_registered(unique_id) if ( @@ -1090,7 +1090,11 @@ class MieleStatusSensor(MieleSensor): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return StateStatus(self.device.state_status).name + return ( + StateStatus(self.device.state_status).name + if self._device_id in self.coordinator.data.devices + else None + ) @property def available(self) -> bool: @@ -1160,7 +1164,8 @@ class MieleTimeSensor(MieleRestorableSensor): current_value = self.entity_description.value_fn(self.device) current_status = StateStatus(self.device.state_status).name - # report end-specific value when program ends (some devices are immediately reporting 0...) + # report end-specific value when program ends + # (some devices are immediately reporting 0...) if ( current_status == StateStatus.program_ended.name and self.entity_description.end_value_fn is not None @@ -1173,7 +1178,8 @@ class MieleTimeSensor(MieleRestorableSensor): elif current_status == StateStatus.program_ended.name: pass - # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) + # force unknown when appliance is not working (some + # devices keep last value until a new cycle starts) elif current_status in ( StateStatus.off.name, StateStatus.on.name, @@ -1210,7 +1216,8 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor): ) or current_status == StateStatus.program_ended.name: return - # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) + # force unknown when appliance is not working (some + # devices keep last value until a new cycle starts) if current_status in ( StateStatus.off.name, StateStatus.on.name, @@ -1257,9 +1264,11 @@ class MieleConsumptionSensor(MieleRestorableSensor): self._is_reporting = False self._attr_native_value = None - # appliance might report the last value for consumption of previous cycle and it will report 0 - # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless - # we already saw a valid value in this cycle from cache + # appliance might report the last value for consumption + # of previous cycle and it will report 0 only after a + # while, so it is necessary to force 0 until we see + # the 0 value coming from API, unless we already saw + # a valid value in this cycle from cache elif ( current_status in (StateStatus.in_use.name, StateStatus.pause.name) and not self._is_reporting diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 8ce6cc1b81d..fa3d332fcbb 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -709,6 +709,7 @@ "pork_tenderloin_medaillons_4_cm": "Pork tenderloin (medaillons, 4 cm)", "pork_tenderloin_medaillons_5_cm": "Pork tenderloin (medaillons, 5 cm)", "pork_with_crackling": "Pork with crackling", + "pot_rests_and_filters": "Pot rests and filters", "potato_cheese_gratin": "Potato cheese gratin", "potato_dumplings_half_half_boil_in_bag": "Potato dumplings (half/half, boil-in-bag)", "potato_dumplings_half_half_deep_frozen": "Potato dumplings (half/half, deep-frozen)", @@ -751,6 +752,7 @@ "powerfresh": "PowerFresh", "prawns": "Prawns", "pre_ironing": "Pre-ironing", + "pre_wash": "Pre-wash", "proofing": "Proofing", "prove_15_min": "Prove for 15 min", "prove_30_min": "Prove for 30 min", @@ -982,6 +984,7 @@ "blocked_brushes": "Brushes blocked", "blocked_drive_wheels": "Drive wheels blocked", "blocked_front_wheel": "Front wheel blocked", + "cancelled": "Cancelled", "cleaning": "Cleaning", "comfort_cooling": "Comfort cooling", "cooling_down": "Cooling down", @@ -1024,6 +1027,7 @@ "normal": "Normal", "normal_plus": "Normal plus", "not_running": "Not running", + "paused": "Paused", "perfect_dry_active": "PerfectDry active", "pre_brewing": "Pre-brewing", "pre_dishwash": "Pre-cleaning", diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py index 9940304bd8c..c14146384ca 100644 --- a/homeassistant/components/miele/switch.py +++ b/homeassistant/components/miele/switch.py @@ -1,7 +1,5 @@ """Switch platform for Miele switch integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -190,9 +188,9 @@ class MielePowerSwitch(MieleSwitch): def available(self) -> bool: """Return the availability of the entity.""" - return ( + return super().available and ( self.action.power_off_enabled or self.action.power_on_enabled - ) and super().available + ) async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: """Set switch to mode.""" diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index a47bcdb1c32..9c9830fcf67 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -1,7 +1,5 @@ """Platform for Miele vacuum integration.""" -from __future__ import annotations - from dataclasses import dataclass from enum import IntEnum import logging @@ -29,8 +27,9 @@ PARALLEL_UPDATES = 1 _LOGGER = logging.getLogger(__name__) # The following const classes define program speeds and programs for the vacuum cleaner. -# Miele have used the same and overlapping names for fan_speeds and programs even -# if the contexts are different. This is an attempt to make it clearer in the integration. +# Miele have used the same and overlapping names for +# fan_speeds and programs even if the contexts are different. +# This is an attempt to make it clearer in the integration. class FanSpeed(IntEnum): @@ -181,9 +180,9 @@ class MieleVacuum(MieleEntity, StateVacuumEntity): def available(self) -> bool: """Return the availability of the entity.""" - return ( + return super().available and ( self.action.power_off_enabled or self.action.power_on_enabled - ) and super().available + ) async def send(self, device_id: str, action: dict[str, Any]) -> None: """Send action to the device.""" diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index bca394f0d38..e3f2b335760 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Mikrotik.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 772c956b54b..06d14250756 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -10,7 +10,6 @@ DEFAULT_DETECTION_TIME: Final = 300 ATTR_MANUFACTURER: Final = "Mikrotik" ATTR_SERIAL_NUMBER: Final = "serial-number" ATTR_FIRMWARE: Final = "current-firmware" -ATTR_MODEL: Final = "model" CONF_ARP_PING: Final = "arp_ping" CONF_FORCE_DHCP: Final = "force_dhcp" diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index a94d3b4b64e..4f9797e7209 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -1,7 +1,5 @@ """The Mikrotik router class.""" -from __future__ import annotations - from datetime import timedelta import logging import ssl @@ -11,7 +9,13 @@ import librouteros from librouteros.login import plain as login_plain, token as login_token from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + ATTR_MODEL, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,7 +23,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( ARP, ATTR_FIRMWARE, - ATTR_MODEL, ATTR_SERIAL_NUMBER, CAPSMAN, CONF_ARP_PING, diff --git a/homeassistant/components/mikrotik/device.py b/homeassistant/components/mikrotik/device.py index 7963c48d936..368a35297c1 100644 --- a/homeassistant/components/mikrotik/device.py +++ b/homeassistant/components/mikrotik/device.py @@ -1,7 +1,5 @@ """Network client device class.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index b166a3a182a..0147ed6e5fd 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,7 +1,5 @@ """Support for Mikrotik routers as device tracker.""" -from __future__ import annotations - from typing import Any from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index ce258712090..fe07132ff56 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,36 +1,35 @@ """The mill component.""" -from __future__ import annotations - from datetime import timedelta from mill import Mill from mill_local import Mill as MillLocal -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL -from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator +from .coordinator import ( + MillConfigEntry, + MillDataUpdateCoordinator, + MillHistoricDataUpdateCoordinator, +) PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool: """Set up the Mill heater.""" - hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}}) - if entry.data.get(CONNECTION_TYPE) == LOCAL: mill_data_connection = MillLocal( entry.data[CONF_IP_ADDRESS], websession=async_get_clientsession(hass), ) update_interval = timedelta(seconds=15) - key = entry.data[CONF_IP_ADDRESS] - conn_type = LOCAL else: mill_data_connection = Mill( entry.data[CONF_USERNAME], @@ -38,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession=async_get_clientsession(hass), ) update_interval = timedelta(seconds=30) - key = entry.data[CONF_USERNAME] - conn_type = CLOUD historic_data_coordinator = MillHistoricDataUpdateCoordinator( hass, @@ -58,12 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await data_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][conn_type][key] = data_coordinator + entry.runtime_data = data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 3a8535b811b..f29ee81ba1a 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -13,14 +13,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_IP_ADDRESS, - CONF_USERNAME, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -32,7 +25,6 @@ from .const import ( ATTR_COMFORT_TEMP, ATTR_ROOM_NAME, ATTR_SLEEP_TEMP, - CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL, @@ -41,7 +33,7 @@ from .const import ( MIN_TEMP, SERVICE_SET_ROOM_TEMP, ) -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity SET_ROOM_TEMP_SCHEMA = vol.Schema( @@ -56,17 +48,16 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill climate.""" + mill_data_coordinator = entry.runtime_data + if entry.data.get(CONNECTION_TYPE) == LOCAL: - mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]] async_add_entities([LocalMillHeater(mill_data_coordinator)]) return - mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] - entities = [ MillHeater(mill_data_coordinator, mill_device) for mill_device in mill_data_coordinator.data.values() @@ -84,6 +75,7 @@ async def async_setup_entry( room_name, sleep_temp, comfort_temp, away_temp ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_ROOM_TEMP, set_room_temp, schema=SET_ROOM_TEMP_SCHEMA ) @@ -205,12 +197,13 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + conn = self.coordinator.mill_data_connection if hvac_mode == HVACMode.HEAT: - await self.coordinator.mill_data_connection.set_operation_mode_control_individually() + await conn.set_operation_mode_control_individually() elif hvac_mode == HVACMode.OFF: - await self.coordinator.mill_data_connection.set_operation_mode_off() + await conn.set_operation_mode_off() elif hvac_mode == HVACMode.AUTO: - await self.coordinator.mill_data_connection.set_operation_mode_weekly_program() + await conn.set_operation_mode_weekly_program() await self.coordinator.async_request_refresh() @callback diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index 222e77efdf7..f4b95c01717 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the mill component.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import cast @@ -59,6 +57,9 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): ) +type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator] + + class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill historic data.""" diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py index 06056aba336..cee76a58611 100644 --- a/homeassistant/components/mill/entity.py +++ b/homeassistant/components/mill/entity.py @@ -1,7 +1,5 @@ """Base entity for Mill devices.""" -from __future__ import annotations - from abc import abstractmethod from mill import MillDevice diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index 8433a9853c6..237abb8cb7b 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -1,30 +1,25 @@ """Support for mill wifi-enabled home heaters.""" -from __future__ import annotations - from mill import Heater, MillDevice from homeassistant.components.number import NumberDeviceClass, NumberEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, UnitOfPower +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CLOUD, CONNECTION_TYPE, DOMAIN -from .coordinator import MillDataUpdateCoordinator +from .const import CLOUD, CONNECTION_TYPE +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill Number.""" if entry.data.get(CONNECTION_TYPE) == CLOUD: - mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][ - entry.data[CONF_USERNAME] - ] + mill_data_coordinator = entry.runtime_data async_add_entities( MillNumber(mill_data_coordinator, mill_device) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 3a47cb427d2..ab221263eaa 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -1,7 +1,5 @@ """Support for mill wifi-enabled home heaters.""" -from __future__ import annotations - import mill from homeassistant.components.sensor import ( @@ -10,12 +8,9 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_IP_ADDRESS, - CONF_USERNAME, PERCENTAGE, EntityCategory, UnitOfEnergy, @@ -30,11 +25,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( BATTERY, - CLOUD, CONNECTION_TYPE, CONSUMPTION_TODAY, CONSUMPTION_YEAR, - DOMAIN, ECO2, HUMIDITY, LOCAL, @@ -42,7 +35,7 @@ from .const import ( TEMPERATURE, TVOC, ) -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -147,13 +140,13 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill sensor.""" - if entry.data.get(CONNECTION_TYPE) == LOCAL: - mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]] + mill_data_coordinator = entry.runtime_data + if entry.data.get(CONNECTION_TYPE) == LOCAL: async_add_entities( LocalMillSensor( mill_data_coordinator, @@ -163,8 +156,6 @@ async def async_setup_entry( ) return - mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] - entities = [ MillSensor( mill_data_coordinator, diff --git a/homeassistant/components/min_max/config_flow.py b/homeassistant/components/min_max/config_flow.py index 2b7b38beb46..c5d68283213 100644 --- a/homeassistant/components/min_max/config_flow.py +++ b/homeassistant/components/min_max/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Min/Max integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 4664dd00d1b..3bcff4040bd 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -1,7 +1,5 @@ """Support for displaying minimal, maximal, mean or median values.""" -from __future__ import annotations - from datetime import datetime import logging import statistics diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index e74b78446e5..4ea5ac39c0b 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,7 +1,5 @@ """The Minecraft Server integration.""" -from __future__ import annotations - import logging from typing import Any @@ -112,12 +110,14 @@ async def async_migrate_entry( await api.async_initialize() except MinecraftServerAddressError: _LOGGER.exception( - "Can't migrate configuration entry due to error while parsing server address, try again later" + "Can't migrate configuration entry due to error" + " while parsing server address, try again later" ) return False _LOGGER.debug( - "Migrating config entry, replacing host '%s' and port '%s' with address '%s'", + "Migrating config entry, replacing host '%s' and" + " port '%s' with address '%s'", config_data[CONF_HOST], config_data[CONF_PORT], address, diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index cc35f0ff72b..ccd225aa02d 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -101,7 +101,7 @@ class MinecraftServer: ) async def async_is_online(self) -> bool: - """Check if the server is online, supporting both Java and Bedrock Edition servers.""" + """Check if the server is online.""" try: await self.async_get_data() except ( @@ -118,7 +118,7 @@ class MinecraftServer: return True async def async_get_data(self) -> MinecraftServerData: - """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" + """Get updated data from the server.""" status_response: ( BedrockStatusResponse | JavaStatusResponse | LegacyStatusResponse ) @@ -132,7 +132,8 @@ class MinecraftServer: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: raise MinecraftServerConnectionError( - f"Status request to '{self._address}' failed: {self._get_error_message(error)}" + f"Status request to '{self._address}' failed: " + f"{self._get_error_message(error)}" ) from error if isinstance(status_response, JavaStatusResponse): diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 4bcb5f6cb88..40370e8a740 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Minecraft Server integration.""" -from __future__ import annotations - import logging from typing import Any @@ -40,7 +38,8 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDRESS: address, } - # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. + # Some Bedrock Edition servers mimic a Java Edition + # server, therefore check for Bedrock Edition first. for server_type in MinecraftServerType: api = MinecraftServer(self.hass, server_type, address) diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 457b0700535..3f6349ed557 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,7 +1,5 @@ """The Minecraft Server integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 9a94fb4e168..3a6c070e6a4 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -28,7 +28,12 @@ class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION)})", + model=( + "Minecraft Server (" + f"{config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION)})" + ), name=coordinator.name, - sw_version=f"{coordinator.data.version} ({coordinator.data.protocol_version})", + sw_version=( + f"{coordinator.data.version} ({coordinator.data.protocol_version})" + ), ) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index f421be8cc83..9cac9f7a014 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["mcstatus==12.1.0"] + "requirements": ["mcstatus==13.1.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index c7eecec3f0d..d0cf7bb4f30 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -1,7 +1,5 @@ """The Minecraft Server sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index 18a82f3a8ed..672b60122cf 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -1,7 +1,5 @@ """Minio component.""" -from __future__ import annotations - import logging import os from queue import Queue @@ -9,7 +7,12 @@ import threading import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -19,8 +22,6 @@ from .minio_helper import MinioEventThread, create_minio_client _LOGGER = logging.getLogger(__name__) DOMAIN = "minio" -CONF_HOST = "host" -CONF_PORT = "port" CONF_ACCESS_KEY = "access_key" CONF_SECRET_KEY = "secret_key" CONF_SECURE = "secure" diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py index 6b0021406f7..5d3375ec248 100644 --- a/homeassistant/components/minio/minio_helper.py +++ b/homeassistant/components/minio/minio_helper.py @@ -1,7 +1,5 @@ """Minio helper methods.""" -from __future__ import annotations - from collections.abc import Iterable import json import logging diff --git a/homeassistant/components/mitsubishi_comfort/__init__.py b/homeassistant/components/mitsubishi_comfort/__init__.py new file mode 100644 index 00000000000..b837945832d --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -0,0 +1,130 @@ +"""Mitsubishi Comfort integration for Home Assistant.""" + +import asyncio +import logging + +from mitsubishi_comfort import ( + DeviceInfo, + IndoorUnit, + KumoStation, + MitsubishiCloudAccount, +) +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ADDRESSES, + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_RESPONSE_TIMEOUT, + DOMAIN, + PLATFORMS, +) +from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _make_device( + info: DeviceInfo, + serial: str, + address: str, + session, +) -> IndoorUnit | KumoStation: + """Create the appropriate device instance from DeviceInfo.""" + cls = IndoorUnit if info.is_indoor_unit else KumoStation + return cls( + name=info.label, + address=address, + password_b64=info.password, + crypto_serial_hex=info.crypto_serial, + serial=serial, + connect_timeout=DEFAULT_CONNECT_TIMEOUT, + response_timeout=DEFAULT_RESPONSE_TIMEOUT, + session=session, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> bool: + """Set up Mitsubishi Comfort from a config entry.""" + session = async_get_clientsession(hass) + account = MitsubishiCloudAccount( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session + ) + + try: + await account.login() + devices = await account.discover_devices() + except AuthenticationError as err: + raise ConfigEntryError("Mitsubishi cloud authentication failed") from err + except DeviceConnectionError as err: + raise ConfigEntryNotReady("Cannot reach Mitsubishi cloud") from err + + if not devices: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_devices", + ) + + # The cloud provides each device's MAC but never its LAN IP. Register every + # device with its MAC so the manifest's "registered_devices" DHCP matcher + # tracks it; DHCP discovery then supplies the IP via async_step_dhcp. + device_registry = dr.async_get(hass) + owned_macs = {dr.format_mac(info.mac) for info in devices.values()} + for serial, info in devices.items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, serial)}, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))}, + manufacturer="Mitsubishi", + name=info.label, + serial_number=serial, + ) + + # Resolved IPs are stored keyed by MAC. Drop any for devices that are no + # longer on the account. + stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {}) + addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs} + if addresses != stored: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_ADDRESSES: addresses} + ) + + coordinators: dict[str, MitsubishiComfortCoordinator] = {} + for serial, info in devices.items(): + address = addresses.get(dr.format_mac(info.mac)) + if not address or not info.password or not info.crypto_serial: + # No LAN address yet: the device is registered, so DHCP discovery + # supplies its IP and reloads the entry to add it. + _LOGGER.debug("Device %s has no known LAN address yet", info.label) + continue + device = _make_device(info, serial, address, session) + coordinators[serial] = MitsubishiComfortCoordinator( + hass, entry, device, info.mac + ) + + await asyncio.gather( + *(c.async_config_entry_first_refresh() for c in coordinators.values()) + ) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await asyncio.gather( + *(c.device.close() for c in entry.runtime_data.values()), + return_exceptions=True, + ) + return unload_ok diff --git a/homeassistant/components/mitsubishi_comfort/climate.py b/homeassistant/components/mitsubishi_comfort/climate.py new file mode 100644 index 00000000000..fcbd1165f13 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/climate.py @@ -0,0 +1,287 @@ +"""Climate entity for Mitsubishi Comfort integration.""" + +from typing import Any + +from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator +from .entity import MitsubishiComfortEntity + +_MODE_TO_HVAC: dict[str, HVACMode] = { + "off": HVACMode.OFF, + "cool": HVACMode.COOL, + "heat": HVACMode.HEAT, + "dry": HVACMode.DRY, + "vent": HVACMode.FAN_ONLY, + "auto": HVACMode.HEAT_COOL, + "autoCool": HVACMode.HEAT_COOL, + "autoHeat": HVACMode.HEAT_COOL, +} + +_HVAC_TO_MODE: dict[HVACMode, Mode] = { + HVACMode.OFF: Mode.OFF, + HVACMode.COOL: Mode.COOL, + HVACMode.HEAT: Mode.HEAT, + HVACMode.DRY: Mode.DRY, + HVACMode.FAN_ONLY: Mode.FAN, + HVACMode.HEAT_COOL: Mode.AUTO, +} + +_LIB_MODE_TO_HVAC: dict[Mode, HVACMode] = {v: k for k, v in _HVAC_TO_MODE.items()} + +_MODE_TO_ACTION: dict[str, HVACAction] = { + "off": HVACAction.OFF, + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "dry": HVACAction.DRYING, + "vent": HVACAction.FAN, + "auto": HVACAction.IDLE, + "autoCool": HVACAction.COOLING, + "autoHeat": HVACAction.HEATING, +} + +_FAN_SPEED_MAP: dict[str, FanSpeed] = {s.value: s for s in FanSpeed} +_VANE_DIR_MAP: dict[str, VaneDirection] = {d.value: d for d in VaneDirection} + +_OPT_MODE = "mode" +_OPT_COOL_SETPOINT = "cool_setpoint" +_OPT_HEAT_SETPOINT = "heat_setpoint" +_OPT_FAN_SPEED = "fan_speed" +_OPT_VANE_DIRECTION = "vane_direction" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MitsubishiComfortConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Mitsubishi Comfort climate entities.""" + coordinators = entry.runtime_data + async_add_entities( + MitsubishiComfortClimate(coordinator) + for coordinator in coordinators.values() + if isinstance(coordinator.device, IndoorUnit) + ) + + +class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity): + """Climate entity for a Mitsubishi indoor unit.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False + + def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = self._device.serial + self._optimistic: dict[str, Any] = {} + + def _handle_coordinator_update(self) -> None: + """Clear optimistic state when real data arrives from device.""" + self._optimistic.clear() + super()._handle_coordinator_update() + + @property + def _effective_mode(self) -> str | None: + return self._optimistic.get(_OPT_MODE, self._device.status.mode) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + mode = self._effective_mode + return _MODE_TO_HVAC.get(mode) if mode else None + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + mode = self._effective_mode + if mode and self._device.status.standby: + return HVACAction.IDLE + return _MODE_TO_ACTION.get(mode) if mode else None + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVAC modes.""" + return [ + _LIB_MODE_TO_HVAC[m] + for m in self._device.supported_modes + if m in _LIB_MODE_TO_HVAC + ] + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._device.status.room_temperature + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self._device.status.current_humidity + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + mode = self._effective_mode + if mode in ("cool", "autoCool"): + return self._optimistic.get( + _OPT_COOL_SETPOINT, self._device.status.cool_setpoint + ) + if mode in ("heat", "autoHeat"): + return self._optimistic.get( + _OPT_HEAT_SETPOINT, self._device.status.heat_setpoint + ) + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature.""" + if self._effective_mode in ("auto", "autoCool", "autoHeat"): + return self._optimistic.get( + _OPT_COOL_SETPOINT, self._device.status.cool_setpoint + ) + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature.""" + if self._effective_mode in ("auto", "autoCool", "autoHeat"): + return self._optimistic.get( + _OPT_HEAT_SETPOINT, self._device.status.heat_setpoint + ) + return None + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + return self._optimistic.get(_OPT_FAN_SPEED, self._device.status.fan_speed) + + @property + def fan_modes(self) -> list[str]: + """Return the list of available fan modes.""" + return [s.value for s in self._device.supported_fan_speeds] + + @property + def swing_mode(self) -> str | None: + """Return the current swing mode.""" + return self._optimistic.get( + _OPT_VANE_DIRECTION, self._device.status.vane_direction + ) + + @property + def swing_modes(self) -> list[str]: + """Return the list of available swing modes.""" + return [d.value for d in self._device.supported_vane_directions] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self._effective_mode in ("heat", "autoHeat"): + if self._device.status.min_heat_setpoint is not None: + return self._device.status.min_heat_setpoint + if self._device.status.min_cool_setpoint is not None: + return self._device.status.min_cool_setpoint + return super().min_temp + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self._effective_mode in ("heat", "autoHeat"): + if self._device.status.max_heat_setpoint is not None: + return self._device.status.max_heat_setpoint + if self._device.status.max_cool_setpoint is not None: + return self._device.status.max_cool_setpoint + return super().max_temp + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + ) + if Mode.AUTO in self._device.supported_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if self._device.supported_vane_directions: + features |= ClimateEntityFeature.SWING_MODE + return features + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + lib_mode = _HVAC_TO_MODE.get(hvac_mode) + if lib_mode is None: + return + result = await self._device.set_mode(lib_mode) + if result.success: + self._optimistic[_OPT_MODE] = result.value + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + mode = self._effective_mode + wrote = False + + if ATTR_TARGET_TEMP_HIGH in kwargs: + result = await self._device.set_cool_setpoint(kwargs[ATTR_TARGET_TEMP_HIGH]) + if result.success: + self._optimistic[_OPT_COOL_SETPOINT] = result.value + wrote = True + + if ATTR_TARGET_TEMP_LOW in kwargs: + result = await self._device.set_heat_setpoint(kwargs[ATTR_TARGET_TEMP_LOW]) + if result.success: + self._optimistic[_OPT_HEAT_SETPOINT] = result.value + wrote = True + + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + if mode in ("cool", "autoCool"): + result = await self._device.set_cool_setpoint(temp) + if result.success: + self._optimistic[_OPT_COOL_SETPOINT] = result.value + wrote = True + elif mode in ("heat", "autoHeat"): + result = await self._device.set_heat_setpoint(temp) + if result.success: + self._optimistic[_OPT_HEAT_SETPOINT] = result.value + wrote = True + + if wrote: + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + speed = _FAN_SPEED_MAP.get(fan_mode) + if speed is None: + return + result = await self._device.set_fan_speed(speed) + if result.success: + self._optimistic[_OPT_FAN_SPEED] = result.value + self.async_write_ha_state() + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode.""" + direction = _VANE_DIR_MAP.get(swing_mode) + if direction is None: + return + result = await self._device.set_vane_direction(direction) + if result.success: + self._optimistic[_OPT_VANE_DIRECTION] = result.value + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py new file mode 100644 index 00000000000..c2b23cb9c40 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -0,0 +1,113 @@ +"""Config flow for Mitsubishi Comfort integration.""" + +import logging +from typing import Any + +from mitsubishi_comfort import MitsubishiCloudAccount +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import CONF_ADDRESSES, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Mitsubishi Comfort.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user setup step.""" + errors: dict[str, str] = {} + + if user_input is not None: + account = MitsubishiCloudAccount( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + + devices: dict = {} + try: + await account.login() + devices = await account.discover_devices() + except AuthenticationError: + errors["base"] = "invalid_auth" + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during setup") + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id(account.user_id) + self._abort_if_unique_id_configured() + + if not devices: + errors["base"] = "no_devices" + else: + return self.async_create_entry( + title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})", + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a registered device discovered on the local network via DHCP. + + The cloud API never returns a device's LAN IP, so DHCP discovery is the + source of addresses. Each device is registered with its MAC during setup, + so "registered_devices" discovery only fires for our own devices: record + the IP on the owning entry and reload to set the device up or recover a + changed IP. + """ + mac = dr.format_mac(discovery_info.macaddress) + device = dr.async_get(self.hass).async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + entry = next( + ( + entry + for entry in self._async_current_entries(include_ignore=False) + if device is not None and entry.entry_id in device.config_entries + ), + None, + ) + if entry is None: + return self.async_abort(reason="already_configured") + + addresses = entry.data.get(CONF_ADDRESSES, {}) + if addresses.get(mac) != discovery_info.ip: + self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_ADDRESSES: {**addresses, mac: discovery_info.ip}, + }, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/mitsubishi_comfort/const.py b/homeassistant/components/mitsubishi_comfort/const.py new file mode 100644 index 00000000000..5d5760da33d --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/const.py @@ -0,0 +1,19 @@ +"""Constants for the Mitsubishi Comfort integration.""" + +from datetime import timedelta +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "mitsubishi_comfort" +PLATFORMS: Final = [Platform.CLIMATE] + +# Config entry data key holding the per-device LAN address cache, keyed by the +# device's formatted MAC. The cloud API only returns each device's MAC, never +# its LAN IP, so addresses are resolved from DHCP discovery and persisted here +# to survive restarts without re-discovery. +CONF_ADDRESSES: Final = "addresses" + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_CONNECT_TIMEOUT: Final = 1.2 +DEFAULT_RESPONSE_TIMEOUT: Final = 8.0 diff --git a/homeassistant/components/mitsubishi_comfort/coordinator.py b/homeassistant/components/mitsubishi_comfort/coordinator.py new file mode 100644 index 00000000000..38d642baf1e --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinator for Mitsubishi Comfort devices.""" + +import logging + +from mitsubishi_comfort import IndoorUnit, KumoStation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type MitsubishiComfortConfigEntry = ConfigEntry[dict[str, MitsubishiComfortCoordinator]] + + +class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStation]): + """Coordinator to poll a single Mitsubishi device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: MitsubishiComfortConfigEntry, + device: IndoorUnit | KumoStation, + mac: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"mitsubishi_comfort_{device.serial}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.device = device + self.mac = mac + self.data = device + + async def _async_update_data(self) -> IndoorUnit | KumoStation: + """Poll the device and return it.""" + try: + success = await self.device.update_status() + except Exception as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"device_name": self.device.name}, + ) from err + if not success: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"device_name": self.device.name}, + ) + return self.device diff --git a/homeassistant/components/mitsubishi_comfort/entity.py b/homeassistant/components/mitsubishi_comfort/entity.py new file mode 100644 index 00000000000..7599c0ff2f4 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/entity.py @@ -0,0 +1,34 @@ +"""Base entity for Mitsubishi Comfort integration.""" + +from mitsubishi_comfort import IndoorUnit, KumoStation + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MitsubishiComfortCoordinator + + +class MitsubishiComfortEntity(CoordinatorEntity[MitsubishiComfortCoordinator]): + """Base class for all Mitsubishi Comfort entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + device = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}, + name=device.name, + manufacturer="Mitsubishi", + serial_number=device.serial, + sw_version=device.status.firmware_version, + hw_version=device.status.hardware_version, + ) + + @property + def _device(self) -> IndoorUnit | KumoStation: + """Return the underlying device from coordinator data.""" + return self.coordinator.data diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json new file mode 100644 index 00000000000..4bb6c975909 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mitsubishi_comfort", + "name": "Mitsubishi Comfort", + "codeowners": ["@nikolairahimi"], + "config_flow": true, + "dhcp": [{ "registered_devices": true }], + "documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort", + "integration_type": "hub", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["mitsubishi-comfort==0.3.0"] +} diff --git a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml new file mode 100644 index 00000000000..79acc62a031 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions registered. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions registered. + reauthentication-flow: todo + parallel-updates: todo + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + + # Gold + entity-translations: todo + entity-device-class: todo + devices: done + entity-category: + status: exempt + comment: Single climate entity per device, no diagnostic entities yet. + entity-disabled-by-default: + status: exempt + comment: Single climate entity per device, enabled by default. + discovery: todo + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: done + repair-issues: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-data-update: done + docs-known-limitations: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mitsubishi_comfort/strings.json b/homeassistant/components/mitsubishi_comfort/strings.json new file mode 100644 index 00000000000..18dcd5dcdf0 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_devices": "No devices were found on this account", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password for your Kumo Cloud account.", + "username": "The email address for your Kumo Cloud account." + } + } + } + }, + "exceptions": { + "communication_error": { + "message": "Error communicating with {device_name}" + }, + "no_devices": { + "message": "No devices were found in your Mitsubishi Comfort account" + }, + "update_failed": { + "message": "{device_name} returned no data" + } + } +} diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index c60f1c4d760..a507cd1af49 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -1,7 +1,5 @@ """Support for IP Cameras.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterator from contextlib import suppress @@ -148,6 +146,7 @@ class MjpegCamera(Camera): return await response.read() + # pylint: disable-next=home-assistant-action-swallowed-exception except TimeoutError: LOGGER.error("Timeout getting camera image from %s", self.name) diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index 5afd796f73f..eb33a40300a 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the MJPEG IP Camera integration.""" -from __future__ import annotations - from collections.abc import Mapping from http import HTTPStatus from typing import Any @@ -59,6 +57,8 @@ def async_get_schema( if show_name: schema = { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=defaults.get(CONF_NAME)): str, **schema, } diff --git a/homeassistant/components/moat/__init__.py b/homeassistant/components/moat/__init__.py index 1e8b0c06759..a7b178767f4 100644 --- a/homeassistant/components/moat/__init__.py +++ b/homeassistant/components/moat/__init__.py @@ -1,7 +1,5 @@ """The Moat Bluetooth BLE integration.""" -from __future__ import annotations - import logging from moat_ble import MoatBluetoothDeviceData diff --git a/homeassistant/components/moat/config_flow.py b/homeassistant/components/moat/config_flow.py index 078e0f6e460..f9bf01d7f5e 100644 --- a/homeassistant/components/moat/config_flow.py +++ b/homeassistant/components/moat/config_flow.py @@ -1,7 +1,5 @@ """Config flow for moat ble integration.""" -from __future__ import annotations - from typing import Any from moat_ble import MoatBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py index 5442f1bec2e..de39e8e9cba 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -1,7 +1,5 @@ """Support for moat ble sensors.""" -from __future__ import annotations - from moat_ble import DeviceClass, DeviceKey, SensorUpdate, Units from homeassistant.components.bluetooth.passive_update_processor import ( @@ -113,7 +111,9 @@ async def async_setup_entry( MoatBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class MoatBluetoothSensorEntity( diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 2711f945788..79e785d5cbf 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,4 +1,5 @@ """Integrates Native Apps to Home Assistant.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from contextlib import suppress from functools import partial @@ -11,7 +12,13 @@ from homeassistant.components.webhook import ( async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, Platform +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + CONF_WEBHOOK_ID, + Platform, +) from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import ( config_validation as cv, @@ -33,8 +40,6 @@ from . import ( # noqa: F401 ) from .const import ( ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_OS_VERSION, CONF_CLOUDHOOK_URL, CONF_USER_ID, @@ -55,7 +60,12 @@ from .timers import async_handle_timer_event from .util import async_create_cloud_hook, supports_push from .webhook import handle_webhook -PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NOTIFY, + Platform.SENSOR, +] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -168,7 +178,8 @@ async def _async_setup_cloudhook( ): await async_create_cloud_hook(hass, webhook_id, entry) elif CONF_CLOUDHOOK_URL in entry.data: - # If we have a cloudhook but no longer logged in to the cloud, remove it from the entry + # If we have a cloudhook but no longer logged in + # to the cloud, remove it from the entry clean_cloudhook() entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598b..9ce9884fb49 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -27,8 +27,6 @@ ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" ATTR_APP_VERSION = "app_version" ATTR_DEVICE_NAME = "device_name" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption" ATTR_OS_NAME = "os_name" ATTR_OS_VERSION = "os_version" @@ -82,6 +80,7 @@ ATTR_SENSOR_UOM = "unit_of_measurement" SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update" SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}" +SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification" ATTR_CAMERA_ENTITY_ID = "camera_entity_id" @@ -89,8 +88,9 @@ SCHEMA_APP_DATA = vol.Schema( { vol.Inclusive(ATTR_PUSH_TOKEN, "push_cloud"): cv.string, vol.Inclusive(ATTR_PUSH_URL, "push_cloud"): cv.url, - # Set to True to indicate that this registration will connect via websocket channel - # to receive push notifications. + # Set to True to indicate that this registration + # will connect via websocket channel to receive + # push notifications. vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean, }, extra=vol.ALLOW_EXTRA, diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py index dccff926b34..e8580d695dc 100644 --- a/homeassistant/components/mobile_app/device_action.py +++ b/homeassistant/components/mobile_app/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Mobile App.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components import notify diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 3e2c6b9f1d0..6dee47191bc 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,14 +1,21 @@ """Device tracker for Mobile app.""" -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Self + +import voluptuous as vol from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_GPS, + ATTR_IN_ZONES, ATTR_LOCATION_NAME, TrackerEntity, ) from homeassistant.components.zone import ( + DOMAIN as ZONE_DOMAIN, ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT, HOME_ZONE, ) @@ -22,10 +29,11 @@ from homeassistant.const import ( STATE_HOME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from .const import ( ATTR_ALTITUDE, @@ -37,8 +45,50 @@ from .const import ( ) from .helpers import device_info +_LOGGER = logging.getLogger(__name__) + ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY) +LOCATION_UPDATE_SCHEMA = vol.All( + cv.key_dependency(ATTR_GPS, ATTR_GPS_ACCURACY), + vol.Schema( + { + vol.Optional(ATTR_LOCATION_NAME): cv.string, + vol.Optional(ATTR_GPS): cv.gps, + vol.Optional(ATTR_GPS_ACCURACY): cv.positive_float, + vol.Optional(ATTR_BATTERY): cv.positive_int, + vol.Optional(ATTR_SPEED): cv.positive_int, + vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), + vol.Optional(ATTR_COURSE): cv.positive_int, + vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, + vol.Optional(ATTR_IN_ZONES): cv.entities_domain(ZONE_DOMAIN), + }, + ), +) + + +@dataclass +class MobileAppDeviceTrackerExtraStoredData(ExtraStoredData): + """Object to hold mobile app device tracker data to be restored.""" + + data: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the stored data.""" + return {"data": self.data} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored mobile app entity data from a dict.""" + if (data := restored.get("data")) is None: + return None + try: + validated = LOCATION_UPDATE_SCHEMA(data) + except vol.Invalid as err: + _LOGGER.debug("Discarding invalid restored device tracker data: %s", err) + return None + return cls(validated) + async def async_setup_entry( hass: HomeAssistant, @@ -53,11 +103,11 @@ async def async_setup_entry( class MobileAppEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, entry, data=None): + def __init__(self, entry: ConfigEntry) -> None: """Set up Mobile app entity.""" self._entry = entry - self._data = data - self._dispatch_unsub = None + self._data: dict[str, Any] = {} + self._dispatch_unsub: Callable[[], None] | None = None @property def unique_id(self) -> str: @@ -79,6 +129,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): return attrs + @property + def in_zones(self) -> list[str] | None: + """Return the zones the device is currently in.""" + return self._data.get(ATTR_IN_ZONES) + @property def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" @@ -103,6 +158,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" + if ATTR_IN_ZONES in self._data: + # New app sends in_zones as well as location_name. Prioritize in_zones + # and only use location_name for backwards compatibility with old + # app versions. + return None if location_name := self._data.get(ATTR_LOCATION_NAME): if location_name == HOME_ZONE: return STATE_HOME @@ -132,12 +192,19 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): self.update_data, ) - # Don't restore if we got set up with data. - if self._data is not None: + if (extra_data := await self.async_get_last_extra_data()) is not None: + if ( + restored := MobileAppDeviceTrackerExtraStoredData.from_dict( + extra_data.as_dict() + ) + ) is not None: + self._data = restored.data return + # Fallback for entities saved before MobileAppDeviceTrackerExtraStoredData + # was introduced: reconstruct from the previous state's attributes. + # This can be removed in HA Core 2026.12. if (state := await self.async_get_last_state()) is None: - self._data = {} return attr = state.attributes @@ -149,6 +216,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) self._data = data + @property + def extra_restore_state_data(self) -> MobileAppDeviceTrackerExtraStoredData: + """Return the entity data to be restored.""" + return MobileAppDeviceTrackerExtraStoredData(self._data) + async def async_will_remove_from_hass(self) -> None: """Call when entity is being removed from hass.""" await super().async_will_remove_from_hass() @@ -158,7 +230,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): self._dispatch_unsub = None @callback - def update_data(self, data): + def update_data(self, data: dict[str, Any]) -> None: """Mark the device as seen.""" self._data = data self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index e97431baa13..32662b88356 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,7 +1,5 @@ """An entity class for mobile_app.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry @@ -85,7 +83,8 @@ class MobileAppEntity(RestoreEntity): """Restore previous state.""" config = self._config - # Only restore state if we don't have one already, since it can be set by a pending update + # Only restore state if we don't have one already, + # since it can be set by a pending update if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN): config[ATTR_SENSOR_STATE] = last_state.state config[ATTR_SENSOR_ATTRIBUTES] = { @@ -110,6 +109,8 @@ class MobileAppEntity(RestoreEntity): def _apply_pending_update(self) -> None: """Restore any pending update for this entity.""" entity_type = self._config[ATTR_SENSOR_TYPE] + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data pending_updates = self.hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type] if update := pending_updates.pop(self._attr_unique_id, None): _LOGGER.debug( diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 41cafa99e43..4b9399f8cf7 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -1,7 +1,5 @@ """Helpers for mobile_app.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from http import HTTPStatus import logging @@ -11,7 +9,12 @@ from aiohttp.web import Response, json_response from nacl.encoding import Base64Encoder, HexEncoder, RawEncoder from nacl.secret import SecretBox -from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + CONTENT_TYPE_JSON, +) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.json import json_bytes @@ -23,8 +26,6 @@ from .const import ( ATTR_APP_NAME, ATTR_APP_VERSION, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_NO_LEGACY_ENCRYPTION, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, @@ -170,6 +171,8 @@ def safe_registration(registration: dict) -> dict: def savable_state(hass: HomeAssistant) -> dict: """Return a clean object containing things that should be saved.""" return { + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], } diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 7bcbb336496..8851b1d640d 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -1,7 +1,5 @@ """Provides an HTTP API for mobile_app.""" -from __future__ import annotations - from contextlib import suppress from http import HTTPStatus import secrets @@ -13,7 +11,12 @@ import voluptuous as vol from homeassistant.components import cloud from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + CONF_WEBHOOK_ID, +) from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -23,8 +26,6 @@ from .const import ( ATTR_APP_NAME, ATTR_APP_VERSION, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_OS_NAME, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, diff --git a/homeassistant/components/mobile_app/icons.json b/homeassistant/components/mobile_app/icons.json new file mode 100644 index 00000000000..e4a00bd8427 --- /dev/null +++ b/homeassistant/components/mobile_app/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "notify": { + "notify": { + "default": "mdi:cellphone-message" + } + } + } +} diff --git a/homeassistant/components/mobile_app/logbook.py b/homeassistant/components/mobile_app/logbook.py index d9f7f4f04e1..8a36eaabd53 100644 --- a/homeassistant/components/mobile_app/logbook.py +++ b/homeassistant/components/mobile_app/logbook.py @@ -1,7 +1,5 @@ """Describe mobile_app logbook events.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 085c80afbeb..282b1c89e98 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -1,6 +1,5 @@ """Support for mobile_app push notifications.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from functools import partial @@ -8,7 +7,7 @@ from http import HTTPStatus import logging from typing import Any -import aiohttp +from aiohttp import ClientError, ClientSession from homeassistant.components.notify import ( ATTR_DATA, @@ -17,10 +16,19 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -42,12 +50,89 @@ from .const import ( DATA_NOTIFY, DATA_PUSH_CHANNEL, DOMAIN, + SIGNAL_RECORD_NOTIFICATION, ) +from .helpers import device_info +from .push_notification import PushChannel from .util import supports_push _LOGGER = logging.getLogger(__name__) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Mobile app notify platform.""" + if supports_push(hass, entry.data[ATTR_WEBHOOK_ID]): + async_add_entities( + [MobileAppNotifyEntity(entry, async_get_clientsession(hass))] + ) + + +class MobileAppNotifyEntity(NotifyEntity): + """Representation of a Mobile app notify entity.""" + + _attr_has_entity_name = True + _attr_translation_key = "notify" + _attr_name = None + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__(self, entry: ConfigEntry, session: ClientSession) -> None: + """Initialize the notify entity.""" + + self._attr_unique_id = entry.data[ATTR_DEVICE_ID] + self._attr_device_info = device_info(entry.data) + self._config_entry = entry + self._session = session + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message via notify.send_message action.""" + + data: dict[str, Any] = {} + data[ATTR_MESSAGE] = message + if title is not None: + data[ATTR_TITLE] = title + + # Sends notification via local push if available + # and fallback to cloud push if fails + if (webhook_id := self._config_entry.data[ATTR_WEBHOOK_ID]) in self.hass.data[ + DOMAIN + ][DATA_PUSH_CHANNEL]: + push_channel: PushChannel = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL][ + webhook_id + ] + push_channel.async_send_notification( + data, + partial(_send_message, self._session, self._config_entry), + ) + # Sends notification via cloud push notification service + elif ATTR_PUSH_URL in self._config_entry.data[ATTR_APP_DATA]: + await _send_message(self._session, self._config_entry, data) + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_connected_for_local_push_notifications", + translation_placeholders={"device_name": self._config_entry.title}, + ) + + @callback + def _async_handle_notification(self, webhook_id: str) -> None: + """Handle notifications triggered externally.""" + if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]: + self._async_record_notification() + + async def async_added_to_hass(self) -> None: + """Register callback.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification + ) + ) + + def push_registrations(hass: HomeAssistant) -> dict[str, str]: """Return a dictionary of push enabled registrations.""" targets = {} @@ -61,14 +146,14 @@ def push_registrations(hass: HomeAssistant) -> dict[str, str]: return targets -def log_rate_limits(hass, device_name, resp, level=logging.INFO): +def log_rate_limits(device_name, resp, level=logging.INFO): """Output rate limit log line at given level.""" if ATTR_PUSH_RATE_LIMITS not in resp: return rate_limits = resp[ATTR_PUSH_RATE_LIMITS] - resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT] - resetsAtTime = dt_util.parse_datetime(resetsAt) - dt_util.utcnow() + resets_at = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT] + resets_at_time = dt_util.parse_datetime(resets_at) - dt_util.utcnow() rate_limit_msg = ( "mobile_app push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " @@ -81,7 +166,7 @@ def log_rate_limits(hass, device_name, resp, level=logging.INFO): rate_limits[ATTR_PUSH_RATE_LIMITS_SUCCESSFUL], rate_limits[ATTR_PUSH_RATE_LIMITS_MAXIMUM], rate_limits[ATTR_PUSH_RATE_LIMITS_ERRORS], - str(resetsAtTime).split(".", maxsplit=1)[0], + str(resets_at_time).split(".", maxsplit=1)[0], ) @@ -118,87 +203,121 @@ class MobileAppNotificationService(BaseNotificationService): if (data_arg := kwargs.get(ATTR_DATA)) is not None: data[ATTR_DATA] = data_arg - local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL] + local_push_channels: dict[str, PushChannel] = self.hass.data[DOMAIN][ + DATA_PUSH_CHANNEL + ] failed_targets = [] for target in targets: - registration = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target].data + entry: ConfigEntry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target] if target in local_push_channels: local_push_channels[target].async_send_notification( data, - partial( - self._async_send_remote_message_target, target, registration - ), + partial(self._async_send_remote_message_target, entry), ) + async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target) continue # Test if local push only. - if ATTR_PUSH_URL not in registration[ATTR_APP_DATA]: + if ATTR_PUSH_URL not in entry.data[ATTR_APP_DATA]: failed_targets.append(target) continue - await self._async_send_remote_message_target(target, registration, data) + await self._async_send_remote_message_target(entry, data) + async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target) if failed_targets: raise HomeAssistantError( - f"Device(s) with webhook id(s) {', '.join(failed_targets)} not connected to local push notifications" + "Device(s) with webhook id(s)" + f" {', '.join(failed_targets)}" + " not connected to local push notifications" ) - async def _async_send_remote_message_target(self, target, registration, data): + async def _async_send_remote_message_target( + self, entry: ConfigEntry, data: dict[str, Any] + ): """Send a message to a target.""" - app_data = registration[ATTR_APP_DATA] - push_token = app_data[ATTR_PUSH_TOKEN] - push_url = app_data[ATTR_PUSH_URL] - - target_data = dict(data) - target_data[ATTR_PUSH_TOKEN] = push_token - - reg_info = { - ATTR_APP_ID: registration[ATTR_APP_ID], - ATTR_APP_VERSION: registration[ATTR_APP_VERSION], - ATTR_WEBHOOK_ID: target, - } - if ATTR_OS_VERSION in registration: - reg_info[ATTR_OS_VERSION] = registration[ATTR_OS_VERSION] - - target_data["registration_info"] = reg_info - try: - async with asyncio.timeout(10): - response = await async_get_clientsession(self.hass).post( - push_url, json=target_data - ) - result = await response.json() - - if response.status in ( - HTTPStatus.OK, - HTTPStatus.CREATED, - HTTPStatus.ACCEPTED, - ): - log_rate_limits(self.hass, registration[ATTR_DEVICE_NAME], result) - return - - fallback_error = result.get("errorMessage", "Unknown error") - fallback_message = ( - f"Internal server error, please try again later: {fallback_error}" - ) - message = result.get("message", fallback_message) - - if "message" in result: - if message[-1] not in [".", "?", "!"]: - message += "." - message += " This message is generated externally to Home Assistant." - - if response.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.warning(message) - log_rate_limits( - self.hass, registration[ATTR_DEVICE_NAME], result, logging.WARNING - ) + await _send_message(async_get_clientsession(self.hass), entry, data) + except HomeAssistantError as e: + if e.translation_key == "rate_limit_exceeded_sending_notification": + _LOGGER.warning(str(e)) else: - _LOGGER.error(message) + _LOGGER.error(str(e)) - except TimeoutError: - _LOGGER.error("Timeout sending notification to %s", push_url) - except aiohttp.ClientError as err: - _LOGGER.error("Error sending notification to %s: %r", push_url, err) + +async def _send_message( + session: ClientSession, entry: ConfigEntry, data: dict[str, Any] +) -> None: + """Shared internal helper to send messages via cloud push notification services.""" + reg_info = { + ATTR_APP_ID: entry.data[ATTR_APP_ID], + ATTR_APP_VERSION: entry.data[ATTR_APP_VERSION], + ATTR_WEBHOOK_ID: entry.data[ATTR_WEBHOOK_ID], + } + if ATTR_OS_VERSION in entry.data: + reg_info[ATTR_OS_VERSION] = entry.data[ATTR_OS_VERSION] + + try: + async with asyncio.timeout(10): + response = await session.post( + entry.data[ATTR_APP_DATA][ATTR_PUSH_URL], + json={ + **data, + ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], + "registration_info": reg_info, + }, + ) + result: dict[str, Any] = await response.json() + + log_rate_limits(entry.title, result, logging.DEBUG) + + if response.status in ( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + ): + return + + fallback_error = result.get("errorMessage", "Unknown error") + fallback_message = ( + f"Internal server error, please try again later: {fallback_error}" + ) + message = result.get("message", fallback_message) + + if "message" in result: + if message[-1] not in [".", "?", "!"]: + message += "." + message += " This message is generated externally to Home Assistant." + _LOGGER.debug("Error sending notification to %s: %s", entry.title, message) + + if response.status == HTTPStatus.TOO_MANY_REQUESTS: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rate_limit_exceeded_sending_notification", + translation_placeholders={"device_name": entry.title}, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_sending_notification", + translation_placeholders={"device_name": entry.title}, + ) + except TimeoutError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_sending_notification", + translation_placeholders={"device_name": entry.title}, + ) from e + except ClientError as e: + _LOGGER.debug( + "Error sending notification to %s [%s]:", + entry.title, + entry.data[ATTR_APP_DATA][ATTR_PUSH_URL], + exc_info=True, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_sending_notification", + translation_placeholders={"device_name": entry.title}, + ) from e diff --git a/homeassistant/components/mobile_app/push_notification.py b/homeassistant/components/mobile_app/push_notification.py index d295a844878..e7b467f235d 100644 --- a/homeassistant/components/mobile_app/push_notification.py +++ b/homeassistant/components/mobile_app/push_notification.py @@ -1,7 +1,5 @@ """Push notification handling.""" -from __future__ import annotations - import asyncio from collections.abc import Callable diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 65770b99aad..36dd7ba55b6 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for mobile_app.""" -from __future__ import annotations - from datetime import date, datetime from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json index 2d49f8e3be1..60ee8750c02 100644 --- a/homeassistant/components/mobile_app/strings.json +++ b/homeassistant/components/mobile_app/strings.json @@ -18,5 +18,19 @@ "title": "Title" } }, + "exceptions": { + "device_not_connected_for_local_push_notifications": { + "message": "Device {device_name} is not connected for local push notifications" + }, + "error_sending_notification": { + "message": "Error sending notification to {device_name}" + }, + "rate_limit_exceeded_sending_notification": { + "message": "Rate limit exceeded sending notification to {device_name}" + }, + "timeout_sending_notification": { + "message": "Timeout sending notification to {device_name}" + } + }, "title": "Mobile App" } diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index f139a203c34..da6c6fa6ac0 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -1,6 +1,5 @@ """Mobile app utility functions.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from typing import TYPE_CHECKING diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index cbbcd7710ee..0121f5558ec 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -1,6 +1,5 @@ """Webhook handlers for mobile_app.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine @@ -25,11 +24,6 @@ from homeassistant.components import ( ) from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.camera import CameraEntityFeature -from homeassistant.components.device_tracker import ( - ATTR_BATTERY, - ATTR_GPS, - ATTR_LOCATION_NAME, -) from homeassistant.components.frontend import MANIFEST_JSON from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN @@ -37,7 +31,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, - ATTR_GPS_ACCURACY, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, @@ -58,16 +53,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry from .const import ( - ATTR_ALTITUDE, ATTR_APP_DATA, ATTR_APP_VERSION, ATTR_CAMERA_ENTITY_ID, - ATTR_COURSE, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_NO_LEGACY_ENCRYPTION, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, @@ -82,11 +73,9 @@ from .const import ( ATTR_SENSOR_TYPE_SENSOR, ATTR_SENSOR_UNIQUE_ID, ATTR_SENSOR_UOM, - ATTR_SPEED, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, - ATTR_VERTICAL_ACCURACY, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, @@ -109,6 +98,7 @@ from .const import ( SIGNAL_LOCATION_UPDATE, SIGNAL_SENSOR_UPDATE, ) +from .device_tracker import LOCATION_UPDATE_SCHEMA from .helpers import ( async_is_local_only_user, decrypt_payload, @@ -406,23 +396,7 @@ async def webhook_render_template( @WEBHOOK_COMMANDS.register("update_location") -@validate_schema( - vol.All( - cv.key_dependency(ATTR_GPS, ATTR_GPS_ACCURACY), - vol.Schema( - { - vol.Optional(ATTR_LOCATION_NAME): cv.string, - vol.Optional(ATTR_GPS): cv.gps, - vol.Optional(ATTR_GPS_ACCURACY): cv.positive_int, - vol.Optional(ATTR_BATTERY): cv.positive_int, - vol.Optional(ATTR_SPEED): cv.positive_int, - vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), - vol.Optional(ATTR_COURSE): cv.positive_int, - vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, - }, - ), - ) -) +@validate_schema(LOCATION_UPDATE_SCHEMA) async def webhook_update_location( hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] ) -> Response: @@ -715,8 +689,9 @@ def _async_update_sensor_entity( # Replace existing pending update with the latest sensor data. hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type][unique_store_key] = data - # The signal might not be handled if the entity was just enabled, but the data is stored - # in pending updates and will be applied on entity initialization. + # The signal might not be handled if the entity was + # just enabled, but the data is stored in pending updates + # and will be applied on entity initialization. async_dispatcher_send( hass, f"{SIGNAL_SENSOR_UPDATE}-{entity_type}-{unique_store_key}" ) diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py index e862e4c8bd5..18963d91c26 100644 --- a/homeassistant/components/mobile_app/websocket_api.py +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -1,6 +1,5 @@ """Mobile app websocket API.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import wraps from typing import Any @@ -27,7 +26,8 @@ def _ensure_webhook_access(func): @callback @wraps(func) def with_webhook_access(hass, connection, msg): - # Validate that the webhook ID is registered to the user of the websocket connection + # Validate that the webhook ID is registered to + # the user of the websocket connection config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].get(msg["webhook_id"]) if config_entry is None: diff --git a/homeassistant/components/mochad/light.py b/homeassistant/components/mochad/light.py index fe5a8ccd07d..1961d7b3a00 100644 --- a/homeassistant/components/mochad/light.py +++ b/homeassistant/components/mochad/light.py @@ -1,7 +1,5 @@ """Support for X10 dimmer over Mochad.""" -from __future__ import annotations - import logging from typing import Any @@ -120,6 +118,7 @@ class MochadLight(LightEntity): self._adjust_brightness(brightness) self._attr_brightness = brightness self._attr_is_on = True + # pylint: disable-next=home-assistant-action-swallowed-exception except (MochadException, OSError) as exc: _LOGGER.error("Error with mochad communication: %s", exc) @@ -137,5 +136,6 @@ class MochadLight(LightEntity): if self._brightness_levels == 31: self._attr_brightness = 0 self._attr_is_on = False + # pylint: disable-next=home-assistant-action-swallowed-exception except (MochadException, OSError) as exc: _LOGGER.error("Error with mochad communication: %s", exc) diff --git a/homeassistant/components/mochad/switch.py b/homeassistant/components/mochad/switch.py index beb12d9d409..00333b8bc3f 100644 --- a/homeassistant/components/mochad/switch.py +++ b/homeassistant/components/mochad/switch.py @@ -1,7 +1,5 @@ """Support for X10 switch over Mochad.""" -from __future__ import annotations - import logging from typing import Any @@ -80,6 +78,7 @@ class MochadSwitch(SwitchEntity): if self._comm_type == "pl": self._controller.read_data() self._attr_is_on = True + # pylint: disable-next=home-assistant-action-swallowed-exception except (MochadException, OSError) as exc: _LOGGER.error("Error with mochad communication: %s", exc) @@ -96,6 +95,7 @@ class MochadSwitch(SwitchEntity): if self._comm_type == "pl": self._controller.read_data() self._attr_is_on = False + # pylint: disable-next=home-assistant-action-swallowed-exception except (MochadException, OSError) as exc: _LOGGER.error("Error with mochad communication: %s", exc) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index b32b7de1a91..8699fd84364 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -1,7 +1,5 @@ """Support for Modbus.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index afcab812e06..6a081d7b80a 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Modbus Coil and Discrete Input sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 3ae9285078e..e82b67590b6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -1,7 +1,5 @@ """Support for Generic Modbus Thermostats.""" -from __future__ import annotations - import struct from typing import Any, cast @@ -571,7 +569,10 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): break if self._attr_swing_mode is STATE_UNKNOWN: - _err = f"{self.name}: No answer received from Swing mode register. State is Unknown" + _err = ( + f"{self.name}: No answer received from" + " Swing mode register. State is Unknown" + ) _LOGGER.error(_err) # Read the on/off register if defined. If the value in this diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 21d04d2ffc4..2c294bcf0c9 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -1,7 +1,5 @@ """Support for Modbus covers.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import CoverEntity, CoverEntityFeature, CoverState @@ -64,8 +62,9 @@ class ModbusCover(ModbusBaseEntity, CoverEntity, RestoreEntity): self._attr_is_closed = False - # If we read cover status from coil, and not from optional status register, - # we interpret boolean value False as closed cover, and value True as open cover. + # If we read cover status from coil, and not from + # optional status register, we interpret boolean value + # False as closed cover, and value True as open cover. # Intermediate states are not supported in such a setup. if self._input_type == CALL_TYPE_COIL: self._write_type = CALL_TYPE_WRITE_COIL diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index db5460cd956..b0d1c6a6762 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -1,7 +1,5 @@ """Base implementation for all modbus platforms.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable import copy diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index 3602fbc5879..467d798ce7d 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -1,7 +1,5 @@ """Support for Modbus fans.""" -from __future__ import annotations - from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 4c27ffb456b..7b5e6479a61 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -1,7 +1,5 @@ """Support for Modbus lights.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ( @@ -193,7 +191,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity): ) def _convert_modbus_percent_to_temperature(self, percent: int) -> int: - """Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К).""" + """Convert Modbus scale (0-100) to color temp in Kelvin (2000-7000 K).""" return round( self._attr_min_color_temp_kelvin + ( diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 5f376806d7c..f40ea16a4f2 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -1,7 +1,5 @@ """Support for Modbus.""" -from __future__ import annotations - import asyncio from collections import namedtuple from typing import Any @@ -318,10 +316,12 @@ class ModbusHub: break except ModbusException as exception_error: self._log_error( - f"{self.name} connect failed, please check your configuration ({exception_error!s})" + f"{self.name} connect failed, please check" + f" your configuration ({exception_error!s})" ) _LOGGER.info( - f"modbus {self.name} connect NOT a success ! retrying in {PRIMARY_RECONNECT_DELAY} seconds" + f"modbus {self.name} connect NOT a success !" + f" retrying in {PRIMARY_RECONNECT_DELAY} seconds" ) await asyncio.sleep(PRIMARY_RECONNECT_DELAY) @@ -401,7 +401,10 @@ class ModbusHub: self._log_error(error) return None if result.isError(): - error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" + error = ( + f"Error: device: {slave} address: {address}" + " -> pymodbus returned isError True" + ) self._log_error(error) return None return result diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index a7e973b7b47..a06edb75a2c 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,5 @@ """Support for Modbus Register sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 9fc3115901d..43f268cd69b 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -1,7 +1,5 @@ """Support for Modbus switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index e2833f06ec2..c322e000ad1 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -1,7 +1,5 @@ """Validate Modbus configuration.""" -from __future__ import annotations - from collections import namedtuple import logging import struct @@ -166,7 +164,10 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: ): if entry[0] is None: if entry[1] == DEMANDED: - error = f"{name}: `{entry[2]}` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + error = ( + f"{name}: `{entry[2]}` missing," + f" demanded with `{CONF_DATA_TYPE}: {data_type}`" + ) raise vol.Invalid(error) elif entry[1] == ILLEGAL: error = f"{name}: `{entry[2]}` illegal with `{CONF_DATA_TYPE}: {data_type}`" @@ -182,7 +183,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: bytecount = count * 2 if bytecount != size: raise vol.Invalid( - f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes" + f"{name}: Size of structure is {size} bytes" + f" but `{CONF_COUNT}: {count}` is {bytecount} bytes" ) else: if data_type != DataType.STRING: @@ -201,7 +203,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: def hvac_fixedsize_reglist_validator(value: Any) -> list: - """Check the number of registers for target temp. and coerce it to a list, if valid.""" + """Check the number of registers for target temp and coerce to a list.""" if isinstance(value, int): value = [value] * len(HVACMode) return list(value) @@ -216,7 +218,8 @@ def hvac_fixedsize_reglist_validator(value: Any) -> list: return list(value) raise vol.Invalid( - f"Invalid target temp register. Required type: integer, allowed 1 or list of {len(HVACMode)} registers" + "Invalid target temp register. Required type: integer," + f" allowed 1 or list of {len(HVACMode)} registers" ) @@ -240,7 +243,10 @@ def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: errors = [] for key, value in config[CONF_FAN_MODE_VALUES].items(): if value in fan_modes: - warn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + warn = ( + f"Modbus fan mode {key} has a duplicate value" + f" {value}, not loaded, values must be unique!" + ) _LOGGER.warning(warn) errors.append(key) else: @@ -259,7 +265,7 @@ def not_zero_value(val: float, errMsg: str) -> float: def ensure_and_check_conflicting_scales_and_offsets(config: dict[str, Any]) -> dict: - """Check for conflicts in scale/offset and ensure target/current temp scale/offset is set.""" + """Check for conflicts in scale/offset and ensure target/current temp is set.""" config_keys = [ (CONF_SCALE, CONF_TARGET_TEMP_SCALE, CONF_CURRENT_TEMP_SCALE, DEFAULT_SCALE), ( @@ -273,10 +279,14 @@ def ensure_and_check_conflicting_scales_and_offsets(config: dict[str, Any]) -> d for generic_key, target_key, current_key, default_value in config_keys: if generic_key in config and (target_key in config or current_key in config): raise vol.Invalid( - f"Cannot use both '{generic_key}' and temperature-specific parameters " - f"('{target_key}' or '{current_key}') in the same configuration. " - f"Either the '{generic_key}' parameter (which applies to both temperatures) " - "or the new temperature-specific parameters, but not both." + f"Cannot use both '{generic_key}' and" + " temperature-specific parameters" + f" ('{target_key}' or '{current_key}')" + " in the same configuration." + f" Either the '{generic_key}' parameter" + " (which applies to both temperatures)" + " or the new temperature-specific" + " parameters, but not both." ) if generic_key in config: value = config.pop(generic_key) @@ -297,7 +307,11 @@ def duplicate_swing_mode_validator(config: dict[str, Any]) -> dict: errors = [] for key, value in config[CONF_SWING_MODE_VALUES].items(): if value in swing_modes: - warn = f"Modbus swing mode {key} has a duplicate value {value}, not loaded, values must be unique!" + warn = ( + f"Modbus swing mode {key} has a duplicate" + f" value {value}, not loaded," + " values must be unique!" + ) _LOGGER.warning(warn) errors.append(key) else: @@ -318,7 +332,9 @@ def register_int_list_validator(value: Any) -> Any: return value raise vol.Invalid( - f"Invalid {CONF_ADDRESS} register for fan/swing mode. Required type: positive integer, allowed 1 or list of 1 register." + f"Invalid {CONF_ADDRESS} register for fan/swing mode." + " Required type: positive integer," + " allowed 1 or list of 1 register." ) @@ -449,7 +465,8 @@ def check_config(hass: HomeAssistant, config: dict) -> dict: "", "", ], - f"Modbus {hub[CONF_NAME]} contain no entities, causing instability, entry not loaded", + f"Modbus {hub[CONF_NAME]} contain no entities," + " causing instability, entry not loaded", ) del config[hub_inx] continue diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 5df2d67695f..80f0bee17d4 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -1,7 +1,5 @@ """Support for Phone Modem button.""" -from __future__ import annotations - from phone_modem import PhoneModem from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 237fafa69d7..46447ff3f65 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -1,12 +1,8 @@ """Config flow for Modem Caller ID integration.""" -from __future__ import annotations - from typing import Any from phone_modem import PhoneModem -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -19,9 +15,11 @@ from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS DATA_SCHEMA = vol.Schema({"name": str, "device": str}) -def _generate_unique_id(port: ListPortInfo) -> str: +def _generate_unique_id(port: usb.USBDevice | usb.SerialDevice) -> str: """Generate unique id from usb attributes.""" - return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}" + vid = port.vid if isinstance(port, usb.USBDevice) else None + pid = port.pid if isinstance(port, usb.USBDevice) else None + return f"{vid}:{pid}_{port.serial_number}_{port.manufacturer}_{port.description}" class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): @@ -34,7 +32,12 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" dev_path = discovery_info.device - unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" + unique_id = ( + f"{discovery_info.vid}:{discovery_info.pid}" + f"_{discovery_info.serial_number}" + f"_{discovery_info.manufacturer}" + f"_{discovery_info.description}" + ) if ( await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) is None @@ -62,30 +65,28 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = {} if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) existing_devices = [ entry.data[CONF_DEVICE] for entry in self._async_current_entries() ] - unused_ports = [ + port_map = { usb.human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - port.vid, - port.pid, - ) + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, + ): port for port in ports if port.device not in existing_devices - ] - if not unused_ports: + } + if not port_map: return self.async_abort(reason="no_devices_found") if user_input is not None: - port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))] - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, port.device - ) + port = port_map[user_input[CONF_DEVICE]] + dev_path = port.device errors = await self.validate_device_errors( dev_path=dev_path, unique_id=_generate_unique_id(port) ) @@ -95,7 +96,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): data={CONF_DEVICE: dev_path}, ) user_input = user_input or {} - schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))}) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def validate_device_errors( diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index d9d77dfac2f..893e2d58368 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,7 +1,5 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" -from __future__ import annotations - from phone_modem import PhoneModem from homeassistant.components.sensor import RestoreSensor diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index 80041f62c44..18e12af570e 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -1,7 +1,5 @@ """The Modern Forms integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any, Concatenate diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index 5bfad9b9ff4..62f4c27f578 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Modern Forms Binary Sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index d10c7604722..a846befa86b 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Modern Forms.""" -from __future__ import annotations - from typing import Any from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py index 492235cbe35..49cc19c2629 100644 --- a/homeassistant/components/modern_forms/coordinator.py +++ b/homeassistant/components/modern_forms/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Modern Forms integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/modern_forms/diagnostics.py b/homeassistant/components/modern_forms/diagnostics.py index 6761adb7c97..ac08b701e38 100644 --- a/homeassistant/components/modern_forms/diagnostics.py +++ b/homeassistant/components/modern_forms/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Modern Forms.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index 0fab00f8f22..c066d850374 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -1,7 +1,5 @@ """The Modern Forms integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 82f7fb111a2..5220f1a124a 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -1,7 +1,5 @@ """Support for Modern Forms Fan Fans.""" -from __future__ import annotations - from typing import Any from aiomodernforms.const import FAN_POWER_OFF, FAN_POWER_ON @@ -110,11 +108,13 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): """Return the state of the fan.""" return bool(self.coordinator.data.state.fan_on) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.coordinator.modern_forms.fan(direction=direction) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -123,6 +123,7 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): else: await self.async_turn_off() + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_on( self, @@ -139,11 +140,13 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): ) await self.coordinator.modern_forms.fan(**data) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self.coordinator.modern_forms.fan(on=FAN_POWER_OFF) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_set_fan_sleep_timer( self, @@ -152,6 +155,7 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): """Set a Modern Forms light sleep timer.""" await self.coordinator.modern_forms.fan(sleep=sleep_time * 60) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_clear_fan_sleep_timer( self, diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 213e14b31a9..e2327f3ed07 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -1,7 +1,5 @@ """Support for Modern Forms Fan lights.""" -from __future__ import annotations - from typing import Any from aiomodernforms.const import LIGHT_POWER_OFF, LIGHT_POWER_ON @@ -102,11 +100,13 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): """Return the state of the light.""" return bool(self.coordinator.data.state.light_on) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.modern_forms.light(on=LIGHT_POWER_OFF) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -119,6 +119,7 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): await self.coordinator.modern_forms.light(**data) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_set_light_sleep_timer( self, @@ -127,6 +128,7 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): """Set a Modern Forms light sleep timer.""" await self.coordinator.modern_forms.light(sleep=sleep_time * 60) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_clear_light_sleep_timer( self, diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 75ba56a974f..aa28b47b639 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -1,7 +1,5 @@ """Support for Modern Forms switches.""" -from __future__ import annotations - from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index 003baa203df..eb131fd2b1c 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -1,7 +1,5 @@ """Support for Modern Forms switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity @@ -64,11 +62,13 @@ class ModernFormsAwaySwitch(ModernFormsSwitch): """Return the state of the switch.""" return bool(self.coordinator.data.state.away_mode_enabled) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Modern Forms Away mode switch.""" await self.coordinator.modern_forms.away(away=False) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Modern Forms Away mode switch.""" @@ -95,11 +95,13 @@ class ModernFormsAdaptiveLearningSwitch(ModernFormsSwitch): """Return the state of the switch.""" return bool(self.coordinator.data.state.adaptive_learning_enabled) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Modern Forms Adaptive Learning switch.""" await self.coordinator.modern_forms.adaptive_learning(adaptive_learning=False) + # pylint: disable-next=home-assistant-action-swallowed-exception @modernforms_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Modern Forms Adaptive Learning switch.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 1e4d0f73126..13edbb87cc1 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -1,7 +1,5 @@ """Support for the Moehlenhoff Alpha2.""" -from __future__ import annotations - from moehlenhoff_alpha2 import Alpha2Base from homeassistant.const import CONF_HOST, Platform diff --git a/homeassistant/components/moehlenhoff_alpha2/coordinator.py b/homeassistant/components/moehlenhoff_alpha2/coordinator.py index 5ea78fdf204..9bcad3a3099 100644 --- a/homeassistant/components/moehlenhoff_alpha2/coordinator.py +++ b/homeassistant/components/moehlenhoff_alpha2/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Moehlenhoff Alpha2.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/moisture/__init__.py b/homeassistant/components/moisture/__init__.py index a90352418a2..d2336d71856 100644 --- a/homeassistant/components/moisture/__init__.py +++ b/homeassistant/components/moisture/__init__.py @@ -1,7 +1,5 @@ """Integration for moisture triggers and conditions.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/moisture/condition.py b/homeassistant/components/moisture/condition.py index 2c789480d8d..8304981f0b0 100644 --- a/homeassistant/components/moisture/condition.py +++ b/homeassistant/components/moisture/condition.py @@ -1,7 +1,5 @@ """Provides conditions for moisture.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 2bdf154950c..ff84dfa0e44 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -8,11 +8,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number @@ -39,6 +41,7 @@ is_value: device_class: moisture fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index d125ccf9a5b..72ef64f7cf9 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is detected" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is not detected" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::moisture::common::condition_threshold_name%]" } @@ -37,21 +48,6 @@ "name": "Moisture level" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Moisture", "triggers": { "changed": { @@ -68,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" } }, "name": "Moisture cleared" @@ -78,6 +77,9 @@ "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::moisture::common::trigger_threshold_name%]" } @@ -89,6 +91,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" } }, "name": "Moisture detected" diff --git a/homeassistant/components/moisture/trigger.py b/homeassistant/components/moisture/trigger.py index 08c14ecf0eb..6a47266ead0 100644 --- a/homeassistant/components/moisture/trigger.py +++ b/homeassistant/components/moisture/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for moisture.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index a8225e53b7e..2fd70a1d972 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number @@ -57,6 +58,7 @@ crossed_threshold: target: *trigger_numerical_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index 1c22b219217..5150927e4c0 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1,7 +1,5 @@ """Calculates mold growth indication from temperature and humidity.""" -from __future__ import annotations - from collections.abc import Callable import logging diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index 9d8a95c4716..6b30ebc9e1a 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Mold indicator.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/mold_indicator/const.py b/homeassistant/components/mold_indicator/const.py index 15fdf51bce3..ed65527da5b 100644 --- a/homeassistant/components/mold_indicator/const.py +++ b/homeassistant/components/mold_indicator/const.py @@ -1,7 +1,5 @@ """Constants for Mold indicator component.""" -from __future__ import annotations - DOMAIN = "mold_indicator" CONF_CALIBRATION_FACTOR = "calibration_factor" diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 7cdd3bd3111..e5473a69aba 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -1,7 +1,5 @@ """Calculates mold growth indication from temperature and humidity.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import logging import math diff --git a/homeassistant/components/monarch_money/__init__.py b/homeassistant/components/monarch_money/__init__.py index 8b7cfa6aa5b..d5e6e1148fa 100644 --- a/homeassistant/components/monarch_money/__init__.py +++ b/homeassistant/components/monarch_money/__init__.py @@ -1,7 +1,5 @@ """The Monarch Money integration.""" -from __future__ import annotations - from typedmonarchmoney import TypedMonarchMoney from homeassistant.const import CONF_TOKEN, Platform diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index e6ab84a4e74..c9d428e9d43 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Monarch Money integration.""" -from __future__ import annotations - import logging from typing import Any @@ -56,7 +54,8 @@ async def validate_login( ) -> dict[str, Any]: """Validate the user input allows us to connect. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Upon success a session will be saved + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Upon success a session will be saved """ if not email: @@ -70,7 +69,8 @@ async def validate_login( try: await monarch_client.multi_factor_authenticate(email, password, mfa_code) except KeyError as err: - # A bug in the backing lib that I don't control throws a KeyError if the MFA code is wrong + # A bug in the backing lib that I don't control + # throws a KeyError if the MFA code is wrong LOGGER.debug("Bad MFA Code") raise BadMFA from err else: diff --git a/homeassistant/components/monarch_money/entity.py b/homeassistant/components/monarch_money/entity.py index 49a24385782..2df2829337b 100644 --- a/homeassistant/components/monarch_money/entity.py +++ b/homeassistant/components/monarch_money/entity.py @@ -66,7 +66,11 @@ class MonarchMoneyAccountEntity(MonarchMoneyEntityBase): name=f"{account.institution_name} {account.name}", entry_type=DeviceEntryType.SERVICE, manufacturer=account.data_provider, - model=f"{account.institution_name} - {account.type_name} - {account.subtype_name}", + model=( + f"{account.institution_name}" + f" - {account.type_name}" + f" - {account.subtype_name}" + ), configuration_url=account.institution_url, ) diff --git a/homeassistant/components/monarch_money/quality_scale.yaml b/homeassistant/components/monarch_money/quality_scale.yaml new file mode 100644 index 00000000000..39d63b28e0e --- /dev/null +++ b/homeassistant/components/monarch_money/quality_scale.yaml @@ -0,0 +1,171 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + The config flow step is missing data_description translations for: email, + password, mfa_code, mfa_secret. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: todo + comment: | + The documentation page does not include a section describing how to remove + the integration. + entity-event-setup: + status: exempt + comment: | + Entities use CoordinatorEntity and do not subscribe to any additional events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: todo + comment: | + The documentation page does not describe the configuration parameters + (email, password, MFA code) in detail. + docs-installation-parameters: + status: todo + comment: | + The documentation page does not describe each installation parameter + individually (email, password, MFA secret/code). + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: todo + comment: | + The sensor platform does not define a PARALLEL_UPDATES constant. + reauthentication-flow: + status: todo + comment: | + The config flow does not implement async_step_reauth to handle expired or + revoked tokens without requiring the user to delete and re-add the entry. + test-coverage: + status: todo + comment: | + Only a single snapshot test exists for the sensor platform. Overall test + coverage has not been verified to exceed 95%. + + # Gold + devices: done + diagnostics: + status: todo + comment: | + No diagnostics.py file exists for this integration. + discovery-update-info: + status: exempt + comment: | + This is a cloud service integration; network-level discovery is not applicable. + discovery: + status: exempt + comment: | + This is a cloud service integration; DHCP/SSDP/Zeroconf/USB discovery is not + applicable. + docs-data-update: + status: todo + comment: | + The documentation does not describe the 4-hour polling interval or how data + is fetched and updated. + docs-examples: + status: todo + comment: | + The documentation does not include any usage examples (e.g. automation or + dashboard snippets). + docs-known-limitations: + status: todo + comment: | + The documentation does not include a known limitations section. + docs-supported-devices: + status: todo + comment: | + The documentation does not enumerate the supported Monarch Money account + types and subtypes. + docs-supported-functions: + status: todo + comment: | + The documentation does not fully describe all available sensors (cashflow + income, expense, savings, savings rate, balance, value, age). + docs-troubleshooting: + status: todo + comment: | + The documentation does not include a troubleshooting section. + docs-use-cases: + status: todo + comment: | + The documentation does not include a use-cases section describing what users + can accomplish with this integration. + dynamic-devices: + status: todo + comment: | + Entities are created once at setup. There is no mechanism to discover and add + new devices (Monarch Money accounts) that are created after the initial setup, + or to remove devices whose accounts have been closed. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: todo + comment: | + Less important sensors such as the account age/timestamp diagnostic sensor + are not disabled by default. + entity-translations: + status: done + comment: | + translation_key is set on all entity descriptions in sensor.py + (value, balance, age, sum_income, sum_expense, savings, savings_rate). + exception-translations: + status: todo + comment: | + Exceptions raised in the config flow (e.g. invalid auth, connection errors) + do not use translated messages via HomeAssistantError or similar. + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + The config flow does not implement async_step_reconfigure. + repair-issues: + status: todo + comment: | + No repair issues are raised. With no reauthentication flow, token expiry + has no user-facing repair path. + stale-devices: + status: todo + comment: | + The integration does not remove device registry entries when Monarch Money + accounts are deleted or become unavailable. + + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: | + TypedMonarchMoney does not accept an aiohttp.ClientSession parameter; + the integration cannot inject HA's managed session. + strict-typing: + status: todo + comment: | + No .strict-typing marker file exists and the integration has not been + verified to pass mypy --strict. diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py index 1597d9820a1..d3cd6b72874 100644 --- a/homeassistant/components/monarch_money/sensor.py +++ b/homeassistant/components/monarch_money/sensor.py @@ -176,7 +176,7 @@ class MonarchMoneySensor(MonarchMoneyAccountEntity, SensorEntity): @property def entity_picture(self) -> str | None: - """Return the picture of the account as provided by monarch money if it exists.""" + """Return the picture of the account if it exists.""" if self.entity_description.picture_fn is not None: return self.entity_description.picture_fn(self.account_data) return None diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 1f5df2ca194..68537168172 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -1,7 +1,5 @@ """The Monoprice 6-Zone Amplifier integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -12,13 +10,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOT_FIRST_RUN +from .const import CONF_NOT_FIRST_RUN, DOMAIN +from .services import async_setup_services PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type MonopriceConfigEntry = ConfigEntry[MonopriceRuntimeData] @@ -30,6 +33,12 @@ class MonopriceRuntimeData: first_run: bool +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) -> bool: """Set up Monoprice 6-Zone Amplifier from a config entry.""" port = entry.data[CONF_PORT] @@ -69,8 +78,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) - def _cleanup(monoprice) -> None: """Destroy the Monoprice object. - Destroying the Monoprice closes the serial connection, do it in an executor so the garbage - collection does not block. + Destroying the Monoprice closes the serial connection, + do it in an executor so the garbage collection + does not block. """ del monoprice diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index b2619623a07..c428c977eb5 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Monoprice 6-Zone Amplifier integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 4561f29ba56..4a72aff7660 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -13,12 +13,11 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MonopriceConfigEntry -from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT +from .const import CONF_SOURCES, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -72,39 +71,6 @@ async def async_setup_entry( # only call update before add if it's the first run so we can try to detect zones async_add_entities(entities, config_entry.runtime_data.first_run) - platform = entity_platform.async_get_current_platform() - - def _call_service(entities, service_call): - for entity in entities: - if service_call.service == SERVICE_SNAPSHOT: - entity.snapshot() - elif service_call.service == SERVICE_RESTORE: - entity.restore() - - @service.verify_domain_control(DOMAIN) - async def async_service_handle(service_call: core.ServiceCall) -> None: - """Handle for services.""" - entities = await platform.async_extract_from_service(service_call) - - if not entities: - return - - hass.async_add_executor_job(_call_service, entities, service_call) - - hass.services.async_register( - DOMAIN, - SERVICE_SNAPSHOT, - async_service_handle, - schema=cv.make_entity_service_schema({}), - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RESTORE, - async_service_handle, - schema=cv.make_entity_service_schema({}), - ) - class MonopriceZone(MediaPlayerEntity): """Representation of a Monoprice amplifier zone.""" @@ -164,7 +130,7 @@ class MonopriceZone(MediaPlayerEntity): @property def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" + """Return if the entity should be enabled when first added.""" return self._zone_id < 20 or self._update_success @property @@ -180,7 +146,6 @@ class MonopriceZone(MediaPlayerEntity): """Restore saved state.""" if self._snapshot: self._monoprice.restore_zone(self._snapshot) - self.schedule_update_ha_state(True) def select_source(self, source: str) -> None: """Set input source.""" diff --git a/homeassistant/components/monoprice/services.py b/homeassistant/components/monoprice/services.py new file mode 100644 index 00000000000..9adac03047c --- /dev/null +++ b/homeassistant/components/monoprice/services.py @@ -0,0 +1,28 @@ +"""Services for the monoprice integration.""" + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SNAPSHOT, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="snapshot", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_RESTORE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="restore", + ) diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index b0a516ae8ad..5b302e437ad 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -1,7 +1,5 @@ """The Monzo integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/monzo/config_flow.py b/homeassistant/components/monzo/config_flow.py index 32bb29dafd7..a794b6c1674 100644 --- a/homeassistant/components/monzo/config_flow.py +++ b/homeassistant/components/monzo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Monzo.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 68da9b256ad..2a16329d137 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -1,7 +1,5 @@ """The Monzo integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/monzo/entity.py b/homeassistant/components/monzo/entity.py index bf83e3a9bfb..50f94265d59 100644 --- a/homeassistant/components/monzo/entity.py +++ b/homeassistant/components/monzo/entity.py @@ -1,7 +1,5 @@ """Base entity for Monzo.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index e7e644e93fe..a6b711f4c42 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/moon/config_flow.py b/homeassistant/components/moon/config_flow.py index d8aa082ee3a..4b645eeeaa5 100644 --- a/homeassistant/components/moon/config_flow.py +++ b/homeassistant/components/moon/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Moon integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 12d0ff3ed41..3f7f25eb814 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -1,7 +1,5 @@ """Support for tracking the moon phases.""" -from __future__ import annotations - from astral import moon from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/mopeka/__init__.py b/homeassistant/components/mopeka/__init__.py index d73ece581d7..551cbefb4f1 100644 --- a/homeassistant/components/mopeka/__init__.py +++ b/homeassistant/components/mopeka/__init__.py @@ -1,7 +1,5 @@ """The Mopeka integration.""" -from __future__ import annotations - import logging from mopeka_iot_ble import MediumType, MopekaIOTBluetoothDeviceData diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index e5b7d5d7dd2..2561ac67bc7 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -1,7 +1,5 @@ """Config flow for mopeka integration.""" -from __future__ import annotations - from enum import Enum from typing import Any diff --git a/homeassistant/components/mopeka/device.py b/homeassistant/components/mopeka/device.py index b1b01c07957..f060fd86fe4 100644 --- a/homeassistant/components/mopeka/device.py +++ b/homeassistant/components/mopeka/device.py @@ -1,7 +1,5 @@ """Support for Mopeka devices.""" -from __future__ import annotations - from mopeka_iot_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index 53c93f771f2..e629f988025 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -1,7 +1,5 @@ """Support for Mopeka sensors.""" -from __future__ import annotations - from mopeka_iot_ble import SensorUpdate from homeassistant.components.bluetooth.passive_update_processor import ( @@ -125,7 +123,9 @@ async def async_setup_entry( MopekaBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class MopekaBluetoothSensorEntity( diff --git a/homeassistant/components/motion/__init__.py b/homeassistant/components/motion/__init__.py index 218a103eea4..ff55eac020f 100644 --- a/homeassistant/components/motion/__init__.py +++ b/homeassistant/components/motion/__init__.py @@ -1,7 +1,5 @@ """Integration for motion triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/motion/conditions.yaml b/homeassistant/components/motion/conditions.yaml index 5b9ef602e79..4e6848a8f6a 100644 --- a/homeassistant/components/motion/conditions.yaml +++ b/homeassistant/components/motion/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_detected: fields: *condition_common_fields diff --git a/homeassistant/components/motion/strings.json b/homeassistant/components/motion/strings.json index 44f8703d83d..4ce7c7fa2d1 100644 --- a/homeassistant/components/motion/strings.json +++ b/homeassistant/components/motion/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_detected": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::condition_for_name%]" } }, "name": "Motion is detected" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::condition_for_name%]" } }, "name": "Motion is not detected" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Motion", "triggers": { "cleared": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::trigger_for_name%]" } }, "name": "Motion cleared" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::trigger_for_name%]" } }, "name": "Motion detected" diff --git a/homeassistant/components/motion/triggers.yaml b/homeassistant/components/motion/triggers.yaml index 1be6124ed17..8ec50a1d0fa 100644 --- a/homeassistant/components/motion/triggers.yaml +++ b/homeassistant/components/motion/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: detected: fields: *trigger_common_fields diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index a13a73e6f90..79380be47d3 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,4 +1,5 @@ """The motion_blinds component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import logging diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index f590f50694c..d0b548c58f0 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -1,7 +1,5 @@ """Support for Motionblinds button entity using their WLAN API.""" -from __future__ import annotations - from motionblinds.motion_blinds import LimitStatus, MotionBlind from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 59a65aab001..1a8ed33edcc 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Motionblinds using their WLAN API.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index 6614b666538..7b7955fea03 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Motionblinds integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 342a00686d6..de89d3d5f25 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -1,7 +1,5 @@ """Support for Motionblinds using their WLAN API.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 9b52cbb01f5..2dd603cb14c 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -1,7 +1,5 @@ """Support for Motionblinds using their WLAN API.""" -from __future__ import annotations - from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway from motionblinds.motion_blinds import MotionBlind @@ -135,7 +133,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind self._blind.angle == prev_angle for prev_angle in self._previous_angles ) ): - # keep updating the position @self._update_interval_moving until the position does not change. + # keep updating the position @self._update_interval_moving + # until the position does not change. self._requesting_position = async_call_later( self.hass, self._update_interval_moving, @@ -147,7 +146,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind self._requesting_position = None async def async_request_position_till_stop(self, delay: int | None = None) -> None: - """Request the position of the blind every self._update_interval_moving seconds until it stops moving.""" + """Request the position of the blind at intervals until it stops moving.""" if delay is None: delay = self._update_interval_moving diff --git a/homeassistant/components/motionblinds_ble/__init__.py b/homeassistant/components/motionblinds_ble/__init__.py index a278a19046f..38d53eb7573 100644 --- a/homeassistant/components/motionblinds_ble/__init__.py +++ b/homeassistant/components/motionblinds_ble/__init__.py @@ -1,7 +1,5 @@ """Motionblinds Bluetooth integration.""" -from __future__ import annotations - from functools import partial import logging diff --git a/homeassistant/components/motionblinds_ble/button.py b/homeassistant/components/motionblinds_ble/button.py index 22fc5a2e329..33f62f01f27 100644 --- a/homeassistant/components/motionblinds_ble/button.py +++ b/homeassistant/components/motionblinds_ble/button.py @@ -1,7 +1,5 @@ """Button entities for the Motionblinds Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py index a147b6f71d2..c1d1aae273e 100644 --- a/homeassistant/components/motionblinds_ble/config_flow.py +++ b/homeassistant/components/motionblinds_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Motionblinds Bluetooth integration.""" -from __future__ import annotations - import logging import re from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/motionblinds_ble/cover.py b/homeassistant/components/motionblinds_ble/cover.py index a96427aabbd..90bc2443384 100644 --- a/homeassistant/components/motionblinds_ble/cover.py +++ b/homeassistant/components/motionblinds_ble/cover.py @@ -1,7 +1,5 @@ """Cover entities for the Motionblinds Bluetooth integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/motionblinds_ble/diagnostics.py b/homeassistant/components/motionblinds_ble/diagnostics.py index d693c3358f4..0c5b70b5bf5 100644 --- a/homeassistant/components/motionblinds_ble/diagnostics.py +++ b/homeassistant/components/motionblinds_ble/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Motionblinds Bluetooth.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/motionblinds_ble/entity.py b/homeassistant/components/motionblinds_ble/entity.py index 7c2e68f9f72..9dfc36aecea 100644 --- a/homeassistant/components/motionblinds_ble/entity.py +++ b/homeassistant/components/motionblinds_ble/entity.py @@ -44,6 +44,6 @@ class MotionblindsBLEEntity(Entity): ) async def async_update(self) -> None: - """Update state, called by HA if there is a poll interval and by the service homeassistant.update_entity.""" + """Update state, called by HA on poll or homeassistant.update_entity.""" _LOGGER.debug("(%s) Updating entity", self.entry.data[CONF_MAC_CODE]) await self.device.status_query() diff --git a/homeassistant/components/motionblinds_ble/select.py b/homeassistant/components/motionblinds_ble/select.py index a3d7c378798..7a6c6f8a286 100644 --- a/homeassistant/components/motionblinds_ble/select.py +++ b/homeassistant/components/motionblinds_ble/select.py @@ -1,7 +1,5 @@ """Select entities for the Motionblinds Bluetooth integration.""" -from __future__ import annotations - import logging from motionblindsble.const import MotionBlindType, MotionSpeedLevel diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index c90998a0c4a..914c7d2e272 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -1,7 +1,5 @@ """Sensor entities for the Motionblinds BLE integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 37ffe9bbd01..8a7352ea9f6 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -1,7 +1,5 @@ """The motionEye integration.""" -from __future__ import annotations - from collections.abc import Callable import contextlib from http import HTTPStatus @@ -439,7 +437,7 @@ def _get_media_event_data( if ( not config_entry_id or not (entry := hass.config_entries.async_get_entry(config_entry_id)) - or entry.state != ConfigEntryState.LOADED + or entry.state is not ConfigEntryState.LOADED ): return {} diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index f18891c1d8c..783883fe938 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -1,7 +1,5 @@ """The motionEye integration.""" -from __future__ import annotations - from collections.abc import Mapping from contextlib import suppress from typing import Any @@ -31,6 +29,7 @@ from homeassistant.components.mjpeg import ( MjpegCamera, ) from homeassistant.const import ( + CONF_ACTION, CONF_AUTHENTICATION, CONF_NAME, CONF_PASSWORD, @@ -45,7 +44,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras from .const import ( - CONF_ACTION, CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index d8036f8758f..1e4c23609c1 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -1,7 +1,5 @@ """Config flow for motionEye integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -20,6 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -29,6 +28,7 @@ from . import create_motioneye_client from .const import ( CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, + CONF_MORE_OPTIONS, CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, @@ -194,38 +194,47 @@ class MotionEyeOptionsFlow(OptionsFlowWithReload): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) + user_input.update(more_options) return self.async_create_entry(title="", data=user_input) - schema: dict[vol.Marker, type] = { - vol.Required( - CONF_WEBHOOK_SET, - default=self.config_entry.options.get( - CONF_WEBHOOK_SET, - DEFAULT_WEBHOOK_SET, - ), - ): bool, - vol.Required( - CONF_WEBHOOK_SET_OVERWRITE, - default=self.config_entry.options.get( - CONF_WEBHOOK_SET_OVERWRITE, - DEFAULT_WEBHOOK_SET_OVERWRITE, - ), - ): bool, - } + # The input URL is not validated as being a URL, to allow for the possibility + # the template input won't be a valid URL until after it's rendered + description: dict[str, str] | None = None + if CONF_STREAM_URL_TEMPLATE in self.config_entry.options: + description = { + "suggested_value": self.config_entry.options[CONF_STREAM_URL_TEMPLATE] + } - if self.show_advanced_options: - # The input URL is not validated as being a URL, to allow for the possibility - # the template input won't be a valid URL until after it's rendered - description: dict[str, str] | None = None - if CONF_STREAM_URL_TEMPLATE in self.config_entry.options: - description = { - "suggested_value": self.config_entry.options[ - CONF_STREAM_URL_TEMPLATE - ] + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_WEBHOOK_SET, + default=self.config_entry.options.get( + CONF_WEBHOOK_SET, + DEFAULT_WEBHOOK_SET, + ), + ): bool, + vol.Required( + CONF_WEBHOOK_SET_OVERWRITE, + default=self.config_entry.options.get( + CONF_WEBHOOK_SET_OVERWRITE, + DEFAULT_WEBHOOK_SET_OVERWRITE, + ), + ): bool, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Optional( + CONF_STREAM_URL_TEMPLATE, + description=description, + ): str, + } + ), + SectionConfig(collapsed=True), + ), } - - schema[vol.Optional(CONF_STREAM_URL_TEMPLATE, description=description)] = ( - str - ) - - return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) + ), + ) diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index 14ecde90ea2..9754d0cdf77 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -29,9 +29,9 @@ DOMAIN: Final = "motioneye" ATTR_EVENT_TYPE: Final = "event_type" ATTR_WEBHOOK_ID: Final = "webhook_id" -CONF_ACTION: Final = "action" CONF_ADMIN_PASSWORD: Final = "admin_password" CONF_ADMIN_USERNAME: Final = "admin_username" +CONF_MORE_OPTIONS: Final = "more_options" CONF_STREAM_URL_TEMPLATE: Final = "stream_url_template" CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username" CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password" diff --git a/homeassistant/components/motioneye/coordinator.py b/homeassistant/components/motioneye/coordinator.py index 601b132da12..1631d2efd92 100644 --- a/homeassistant/components/motioneye/coordinator.py +++ b/homeassistant/components/motioneye/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the motionEye integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py index e3c5a19d8fa..fa6aff06c8d 100644 --- a/homeassistant/components/motioneye/entity.py +++ b/homeassistant/components/motioneye/entity.py @@ -1,7 +1,5 @@ """The motionEye integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 26674a6b627..461d8483b60 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -1,7 +1,5 @@ """motionEye Media Source Implementation.""" -from __future__ import annotations - import logging from pathlib import PurePath from typing import cast @@ -124,7 +122,7 @@ class MotionEyeMediaSource(MediaSource): def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry: """Get a config entry from a URL.""" entry = self.hass.config_entries.async_get_entry(config_id) - if not entry or entry.state != ConfigEntryState.LOADED: + if not entry or entry.state is not ConfigEntryState.LOADED: raise MediaSourceError(f"Unable to find config entry with id: {config_id}") return entry diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index a8b14017de6..efc16060860 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for motionEye.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/motioneye/services.yaml b/homeassistant/components/motioneye/services.yaml index 483a92635e7..68133344b29 100644 --- a/homeassistant/components/motioneye/services.yaml +++ b/homeassistant/components/motioneye/services.yaml @@ -6,7 +6,6 @@ set_text_overlay: fields: left_text: required: false - advanced: false example: "timestamp" default: "" selector: @@ -18,7 +17,6 @@ set_text_overlay: - "custom-text" custom_left_text: required: false - advanced: false example: "Hello on the left!" default: "" selector: @@ -26,7 +24,6 @@ set_text_overlay: multiline: true right_text: required: false - advanced: false example: "timestamp" default: "" selector: @@ -38,7 +35,6 @@ set_text_overlay: - "custom-text" custom_right_text: required: false - advanced: false example: "Hello on the right!" default: "" selector: @@ -53,7 +49,6 @@ action: fields: action: required: true - advanced: false example: "snapshot" default: "" selector: diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index f612a149a42..f342bf09fab 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -57,9 +57,16 @@ "step": { "init": { "data": { - "stream_url_template": "Stream URL template", "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", "webhook_set_overwrite": "Overwrite unrecognized webhooks" + }, + "sections": { + "more_options": { + "data": { + "stream_url_template": "Stream URL template" + }, + "name": "More options" + } } } } diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 09aea463838..9b4fced8bca 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -1,7 +1,5 @@ """Switch platform for motionEye.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -29,7 +27,6 @@ MOTIONEYE_SWITCHES = [ SwitchEntityDescription( key=KEY_MOTION_DETECTION, translation_key="motion_detection", - entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -47,13 +44,11 @@ MOTIONEYE_SWITCHES = [ SwitchEntityDescription( key=KEY_STILL_IMAGES, translation_key="still_images", - entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_MOVIES, translation_key="movies", - entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 9c2ac6fa180..a9cbc191f1c 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -1,7 +1,5 @@ """The Vogel's MotionMount integration.""" -from __future__ import annotations - import socket import motionmount @@ -47,7 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MotionMountConfigEntry) # and update the config entry so we do not mix up devices. await mm.disconnect() raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + f"Unexpected device found at {host};" + f" expected {entry.unique_id}, found {found_mac}" ) # Check we're properly authenticated or be able to become so diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index c7cb76779de..e7994bdd416 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -25,11 +25,20 @@ _LOGGER = logging.getLogger(__name__) # A MotionMount can be in four states: -# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac -# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails -# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC) -# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac -# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount +# 1. Old CE and old Pro FW -> +# It doesn't supply any kind of mac +# 2. Old CE but new Pro FW -> +# It supplies its mac using DNS-SD, +# but a read of the mac fails +# 3. New CE but old Pro FW -> +# It doesn't supply the mac using DNS-SD +# but we can read it (returning the EMPTY_MAC) +# 4. New CE and new Pro FW -> +# Both DNS-SD and a read gives us the mac +# If we can't get the mac, we use +# DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can +# always configure a single MotionMount. Most +# households will only have a single MotionMount class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Vogel's MotionMount config flow.""" diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 8b210931eaf..33ea8de5ba7 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 916e037a6e5..0d80352fbf5 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -111,7 +111,8 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): if self.mm.is_moving: return self._attr_current_option - # When the mount isn't moving we select the option that matches the current position + # When the mount isn't moving we select the option + # that matches the current position self._attr_current_option = None if self.mm.extension == 0 and self.mm.turn == 0: self._attr_current_option = self._attr_options[0] # Select Wall preset diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 28fe921d9ac..15277c8b8fe 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -54,7 +54,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry ) -> None: - """Initialize sensor entiry.""" + """Initialize sensor entity.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-error-status" diff --git a/homeassistant/components/mpd/__init__.py b/homeassistant/components/mpd/__init__.py index 01ea159cf02..a2759d0ce51 100644 --- a/homeassistant/components/mpd/__init__.py +++ b/homeassistant/components/mpd/__init__.py @@ -1,7 +1,5 @@ """The Music Player Daemon integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 8a33e6ff6c2..0cf856c4672 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -1,7 +1,5 @@ """Support to interact with a Music Player Daemon.""" -from __future__ import annotations - import asyncio from contextlib import asynccontextmanager, suppress from datetime import timedelta @@ -294,8 +292,9 @@ class MpdDevice(MediaPlayerEntity): bytes(response["binary"]) ).hexdigest()[:16] else: - # If there is no image, this hash has to be None, else the media player component - # assumes there is an image and returns an error trying to load it and the + # If there is no image, this hash has to be None, + # else the media player component assumes there is an + # image and returns an error trying to load it and the # frontend media control card breaks. self._media_image_hash = None @@ -324,7 +323,8 @@ class MpdDevice(MediaPlayerEntity): error, ) - # read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded + # read artwork contained in the media directory + # (cover.{jpg,png,tiff,bmp}) if none is embedded if can_albumart and not response: try: with suppress(mpd.ConnectionError): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index fb3d84041be..47c6cf27bc2 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1,7 +1,5 @@ """Support for MQTT message handling.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import datetime @@ -13,7 +11,13 @@ import voluptuous as vol from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD +from homeassistant.const import ( + CONF_DISCOVERY, + CONF_PLATFORM, + CONF_PORT, + CONF_PROTOCOL, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -29,6 +33,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -46,10 +51,12 @@ from .client import ( async_subscribe_internal, publish, subscribe, + try_connection, ) from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE from .const import ( + ATTR_MESSAGE_EXPIRY_INTERVAL, ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -74,6 +81,7 @@ from .const import ( CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, + DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, @@ -81,6 +89,8 @@ from .const import ( ENTITY_PLATFORMS, ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, + PROTOCOL_5, + PROTOCOL_311, TEMPLATE_ERRORS, Platform, ) @@ -240,6 +250,7 @@ MQTT_PUBLISH_SCHEMA = vol.Schema( vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(ATTR_MESSAGE_EXPIRY_INTERVAL): cv.positive_time_period_dict, }, required=True, ) @@ -283,12 +294,13 @@ async def async_check_config_schema( message = conf_util.format_schema_error( hass, exc, domain, config, integration.documentation ) + # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - message, translation_domain=DOMAIN, - translation_key="invalid_platform_config", + translation_key="invalid_platform_config_message", translation_placeholders={ "domain": domain, + "message": message, }, ) from exc @@ -343,12 +355,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False) qos: int = call.data[ATTR_QOS] retain: bool = call.data[ATTR_RETAIN] + message_expiry_interval: int | None = ( + int(call.data[ATTR_MESSAGE_EXPIRY_INTERVAL].total_seconds()) + if ATTR_MESSAGE_EXPIRY_INTERVAL in call.data + else None + ) if evaluate_payload: # Convert quoted binary literal to raw data payload = convert_outgoing_mqtt_payload(payload) - await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) + await mqtt_data.client.async_publish( + msg_topic, + payload, + qos, + retain, + message_expiry_interval=message_expiry_interval, + ) hass.services.async_register( DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA @@ -386,6 +409,56 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ), ) + + async def _reload_config(call: ServiceCall) -> None: + """Reload the platforms.""" + entry: ConfigEntry = next(iter(hass.config_entries.async_entries(DOMAIN))) + mqtt_data = hass.data[DATA_MQTT] + + # Fetch updated manually configured items and validate + try: + config_yaml = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True + ) + except ConfigValidationError as ex: + raise ServiceValidationError( + translation_domain=ex.translation_domain, + translation_key=ex.translation_key, + translation_placeholders=ex.translation_placeholders, + ) from ex + + new_config: list[ConfigType] = config_yaml.get(DOMAIN, []) + platforms_used = platforms_from_config(new_config) + new_platforms = platforms_used - mqtt_data.platforms_loaded + await async_forward_entry_setup_and_setup_discovery(hass, entry, new_platforms) + # Check the schema before continuing reload + await async_check_config_schema(hass, config_yaml) + + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) + + mqtt_data.config = new_config + + # Reload the modern yaml platforms + mqtt_platforms = async_get_platforms(hass, DOMAIN) + tasks = [ + create_eager_task(entity.async_remove()) + for mqtt_platform in mqtt_platforms + for entity in list(mqtt_platform.entities.values()) + if getattr(entity, "_discovery_data", None) is None + and mqtt_platform.config_entry + and mqtt_platform.domain in ENTITY_PLATFORMS + ] + await asyncio.gather(*tasks) + + for component in mqtt_data.reload_handlers.values(): + component() + + # Fire event + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + return True @@ -426,6 +499,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data: MqttData + if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5: + # Automatically migrate the broker protocol to v5 if possible + # Can be removed with HA Core 2027.1 + new_entry_data = entry.data.copy() + new_entry_data[CONF_PROTOCOL] = PROTOCOL_5 + # Create temporary certificate files from entry + await async_create_certificate_temp_files(hass, new_entry_data) + # Try the connection with protocol version 5 + # And update the protocol if successful + if await hass.async_add_executor_job( + try_connection, + {CONF_PORT: DEFAULT_PORT} | new_entry_data, + ): + hass.config_entries.async_update_entry( + entry, + data=new_entry_data, + ) + ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration") + _LOGGER.info( + "The MQTT protocol version was successfully updated to version 5" + ) + else: + broker: str = entry.data[CONF_BROKER] + async_create_issue( + hass, + DOMAIN, + "protocol_5_migration", + issue_domain=DOMAIN, + is_fixable=False, + breaks_in_ha_version="2027.1.0", + severity=IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/mqtt/" + "#mqtt-protocol", + data={ + "entry_id": entry.entry_id, + "broker": broker, + "protocol": protocol, + }, + translation_placeholders={"broker": broker, "protocol": protocol}, + translation_key="protocol_5_migration", + ) + async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration @@ -480,54 +595,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await mqtt_data.client.async_connect(client_available) # setup platforms and discovery - async def _reload_config(call: ServiceCall) -> None: - """Reload the platforms.""" - # Fetch updated manually configured items and validate - try: - config_yaml = await async_integration_yaml_config( - hass, DOMAIN, raise_on_failure=True - ) - except ConfigValidationError as ex: - raise ServiceValidationError( - translation_domain=ex.translation_domain, - translation_key=ex.translation_key, - translation_placeholders=ex.translation_placeholders, - ) from ex - - new_config: list[ConfigType] = config_yaml.get(DOMAIN, []) - platforms_used = platforms_from_config(new_config) - new_platforms = platforms_used - mqtt_data.platforms_loaded - await async_forward_entry_setup_and_setup_discovery(hass, entry, new_platforms) - # Check the schema before continuing reload - await async_check_config_schema(hass, config_yaml) - - # Remove repair issues - _async_remove_mqtt_issues(hass, mqtt_data) - - mqtt_data.config = new_config - - # Reload the modern yaml platforms - mqtt_platforms = async_get_platforms(hass, DOMAIN) - tasks = [ - create_eager_task(entity.async_remove()) - for mqtt_platform in mqtt_platforms - for entity in list(mqtt_platform.entities.values()) - if getattr(entity, "_discovery_data", None) is None - and mqtt_platform.config_entry - and mqtt_platform.domain in ENTITY_PLATFORMS - ] - await asyncio.gather(*tasks) - - for component in mqtt_data.reload_handlers.values(): - component() - - # Fire event - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) - await async_forward_entry_setup_and_setup_discovery(hass, entry, platforms_used) - # Setup reload service after all platforms have loaded - if not hass.services.has_service(DOMAIN, SERVICE_RELOAD): - async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + # Setup discovery if conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): await discovery.async_start( diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6bd2bb47923..986aa46515c 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -108,6 +108,7 @@ ABBREVIATIONS = { "mode_stat_t": "mode_state_topic", "mode_stat_tpl": "mode_state_template", "modes": "modes", + "msg_exp_int": "message_expiry_interval", "name": "name", "o": "origin", "off_dly": "off_delay", @@ -257,6 +258,7 @@ ABBREVIATIONS = { "tit": "title", "t": "topic", "trns": "transition", + "tz": "timezone", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/addon.py b/homeassistant/components/mqtt/addon.py index 3ac6748033f..14850009a5d 100644 --- a/homeassistant/components/mqtt/addon.py +++ b/homeassistant/components/mqtt/addon.py @@ -3,8 +3,6 @@ Currently only supports the official mosquitto add-on. """ -from __future__ import annotations - from homeassistant.components.hassio import AddonManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 72b92cdcb9d..121b4a954e0 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,7 +1,5 @@ """Control a MQTT alarm.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 0467eb3a289..38f0dccd224 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -1,7 +1,5 @@ """Async wrappings for mqtt client.""" -from __future__ import annotations - from functools import lru_cache from types import TracebackType from typing import Self diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 0ac3cb7f786..923759f77b5 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -1,7 +1,5 @@ """Support for MQTT binary sensors.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import Any @@ -127,8 +125,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): ) async def async_will_remove_from_hass(self) -> None: - """Remove exprire triggers.""" - # Clean up expire triggers + """Clean up expire triggers.""" if self._expiration_trigger: _LOGGER.debug("Clean up expire after trigger for %s", self.entity_id) self._expiration_trigger() diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index f5821896071..04022166be2 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -1,7 +1,5 @@ """Support for MQTT buttons.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components import button diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index d3615edcbba..fe35094ca82 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,7 +1,5 @@ """Camera that loads a picture from an MQTT topic.""" -from __future__ import annotations - from base64 import b64decode import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index cbfaca71acf..ff2003513bb 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -1,7 +1,5 @@ """Support for MQTT message handling.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable @@ -11,6 +9,7 @@ from functools import lru_cache, partial from itertools import chain, groupby import logging from operator import attrgetter +import queue import socket import ssl import time @@ -18,6 +17,8 @@ from typing import TYPE_CHECKING, Any from uuid import uuid4 import certifi +import paho.mqtt.client as mqtt +from paho.mqtt.matcher import MQTTMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -37,19 +38,20 @@ from homeassistant.core import ( callback, get_hassjob_callable_job_type, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.collection import chunked_or_all from homeassistant.util.logging import catch_log_exception, log_exception +from .async_client import AsyncMQTTClient from .const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, @@ -66,7 +68,6 @@ from .const import ( DEFAULT_ENCODING, DEFAULT_KEEPALIVE, DEFAULT_PORT, - DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, @@ -77,6 +78,7 @@ from .const import ( MQTT_PROCESSED_SUBSCRIPTIONS, PROTOCOL_5, PROTOCOL_31, + PROTOCOL_311, TRANSPORT_WEBSOCKETS, ) from .models import ( @@ -89,15 +91,10 @@ from .models import ( ) from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled -if TYPE_CHECKING: - # Only import for paho-mqtt type checking here, imports are done locally - # because integrations should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt - - from .async_client import AsyncMQTTClient - _LOGGER = logging.getLogger(__name__) +MQTT_TIMEOUT = 5 + MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB @@ -116,7 +113,6 @@ TIMEOUT_ACK = 10 SUBSCRIBE_TIMEOUT = 10 RECONNECT_INTERVAL_SECONDS = 10 -MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1 MAX_SUBSCRIBES_PER_CALL = 500 MAX_UNSUBSCRIBES_PER_CALL = 500 @@ -131,26 +127,40 @@ def publish( hass: HomeAssistant, topic: str, payload: PublishPayloadType, - qos: int | None = 0, - retain: bool | None = False, + qos: int = 0, + retain: bool = False, encoding: str | None = DEFAULT_ENCODING, + *, + message_expiry_interval: int | None = None, ) -> None: """Publish message to a MQTT topic.""" - hass.create_task(async_publish(hass, topic, payload, qos, retain, encoding)) + hass.create_task( + async_publish( + hass, + topic, + payload, + qos, + retain, + encoding, + message_expiry_interval=message_expiry_interval, + ) + ) async def async_publish( hass: HomeAssistant, topic: str, payload: PublishPayloadType, - qos: int | None = 0, - retain: bool | None = False, + qos: int = 0, + retain: bool = False, encoding: str | None = DEFAULT_ENCODING, + *, + message_expiry_interval: int | None = None, ) -> None: """Publish message to a MQTT topic.""" if not mqtt_config_entry_enabled(hass): + # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( - f"Cannot publish to topic '{topic}', MQTT is not enabled", translation_key="mqtt_not_setup_cannot_publish", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, @@ -184,8 +194,27 @@ async def async_publish( ) return + # Passing None for qos or retain args was deprecated. + # Custom integrations should update there code. + # Check for fallback to `None` values can be removed with HA Core 2027.6 + if qos is None or retain is None: + report_usage( # type: ignore[unreachable] + "that calls the MQTT publish API with `None` for qos or retain. " + "The `qos` argument must be an `int`, " + "and the `retain` argument must be a `bool`", + breaks_in_ha_version="2027.6.0", + core_behavior=ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) + qos = qos or 0 + retain = retain or False + await mqtt_data.client.async_publish( - topic, outgoing_payload, qos or 0, retain or False + topic, + outgoing_payload, + qos, + retain, + message_expiry_interval=message_expiry_interval, ) @@ -221,7 +250,6 @@ def async_on_subscribe_done( ) -@bind_hass async def async_subscribe( hass: HomeAssistant, topic: str, @@ -256,24 +284,23 @@ def async_subscribe_internal( try: mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: + # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly", translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) from exc client = mqtt_data.client if not mqtt_config_entry_enabled(hass): + # pylint: disable-next=home-assistant-exception-message-with-translation raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', MQTT is not enabled", - translation_key="mqtt_not_setup_cannot_subscribe", + translation_key="mqtt_not_enabled_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) return client.async_subscribe(topic, msg_callback, qos, encoding, job_type) -@bind_hass def subscribe( hass: HomeAssistant, topic: str, @@ -288,10 +315,11 @@ def subscribe( def remove() -> None: """Remove listener convert.""" - # MQTT messages tend to be high volume, - # and since they come in via a thread and need to be processed in the event loop, - # we want to avoid hass.add_job since most of the time is spent calling - # inspect to figure out how to run the callback. + # MQTT messages tend to be high volume, and since they + # come in via a thread and need to be processed in the + # event loop, we want to avoid hass.add_job since most + # of the time is spent calling inspect to figure out + # how to run the callback. hass.loop.call_soon_threadsafe(async_remove) return remove @@ -305,8 +333,9 @@ class Subscription: is_simple_match: bool complex_matcher: Callable[[str], bool] | None job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] - qos: int = 0 - encoding: str | None = "utf-8" + qos: int + encoding: str | None + subscription_id: int class MqttClientSetup: @@ -328,15 +357,12 @@ class MqttClientSetup: The setup of the MQTT client should be run in an executor job, because it accesses files, so it does IO. """ - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - from paho.mqtt import client as mqtt # noqa: PLC0415 - - from .async_client import AsyncMQTTClient # noqa: PLC0415 - config = self._config clean_session: bool | None = None - if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31: + # If no protocol setting is set in the config entry data + # we assume the config was migrated from YAML, and the + # protocol version is defaulting to legacy version 3.1.1. + if (protocol := config.get(CONF_PROTOCOL, PROTOCOL_311)) == PROTOCOL_31: proto = mqtt.MQTTv31 clean_session = True elif protocol == PROTOCOL_5: @@ -410,12 +436,47 @@ class MqttClientSetup: return self._client +def try_connection( + user_input: dict[str, Any], +) -> bool: + """Test if we can connect to an MQTT broker.""" + mqtt_client_setup = MqttClientSetup(user_input) + mqtt_client_setup.setup() + client = mqtt_client_setup.client + + result: queue.Queue[bool] = queue.Queue(maxsize=1) + + def on_connect( + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, + ) -> None: + """Handle connection result.""" + result.put(not reason_code.is_failure) + + client.on_connect = on_connect + + client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT]) + client.loop_start() + + try: + return result.get(timeout=MQTT_TIMEOUT) + except queue.Empty: + return False + finally: + client.disconnect() + client.loop_stop() + + class MQTT: """Home Assistant MQTT client.""" _mqttc: AsyncMQTTClient _last_subscribe: float _mqtt_data: MqttData + _supports_subscription_identifiers: bool = False def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, conf: ConfigType @@ -425,7 +486,10 @@ class MQTT: self.loop = hass.loop self.config_entry = config_entry self.conf = conf - self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5 + # If no protocol setting is set in the config entry data + # we assume the config was migrated from YAML, and the + # protocol version is defaulting to legacy version 3.1.1. + self.is_mqttv5 = conf.get(CONF_PROTOCOL, PROTOCOL_311) == PROTOCOL_5 self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( set @@ -560,7 +624,6 @@ class MQTT: """Start the misc periodic.""" assert self._misc_timer is None, "Misc periodic already started" _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) - import paho.mqtt.client as mqtt # noqa: PLC0415 # Inner function to avoid having to check late import # each time the function is called. @@ -688,23 +751,42 @@ class MQTT: return topic in self._pending_subscriptions async def async_publish( - self, topic: str, payload: PublishPayloadType, qos: int, retain: bool + self, + topic: str, + payload: PublishPayloadType, + qos: int, + retain: bool, + *, + message_expiry_interval: int | None = None, ) -> None: """Publish a MQTT message.""" - msg_info = self._mqttc.publish(topic, payload, qos, retain) + properties = mqtt.Properties(mqtt.PacketTypes.PUBLISH) # type: ignore[no-untyped-call] + if message_expiry_interval is not None: + if not self.is_mqttv5: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mqtt_message_expiry_interval_not_supported", + translation_placeholders={ + "topic": topic, + "protocol": self.conf.get(CONF_PROTOCOL, PROTOCOL_311), + }, + ) + properties.MessageExpiryInterval = message_expiry_interval + msg_info = self._mqttc.publish(topic, payload, qos, retain, properties) _LOGGER.debug( - "Transmitting%s message on %s: '%s', mid: %s, qos: %s", + "Transmitting%s message on %s: '%s', mid: %s, qos: %s," + " message_expiry_interval: %s", " retained" if retain else "", topic, payload, msg_info.mid, qos, + message_expiry_interval, ) await self._async_wait_for_mid_or_raise(msg_info.mid, msg_info.rc) async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" - import paho.mqtt.client as mqtt # noqa: PLC0415 result: int | None = None self._available_future = client_available @@ -716,7 +798,7 @@ class MQTT: keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), # See: # https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html - # `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or + # `clean_start` (bool) - (MQTT v5.0 only) `True`, `False` or # `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag # always, never or on the first successful connect only, # respectively. MQTT session data (such as outstanding messages and @@ -762,7 +844,6 @@ class MQTT: async def _reconnect_loop(self) -> None: """Reconnect to the MQTT server.""" - import paho.mqtt.client as mqtt # noqa: PLC0415 while True: if not self.connected: @@ -811,7 +892,24 @@ class MQTT: ) -> None: """Restore tracked subscriptions after reload.""" for subscription in subscriptions: - self._async_track_subscription(subscription) + subscription_id = ( + 1 + if subscription.is_simple_match + else self._mqtt_data.subscription_id_generator.get_subscription_id( + subscription.topic + ) + ) + self._async_track_subscription( + Subscription( + subscription.topic, + subscription.is_simple_match, + subscription.complex_matcher, + subscription.job, + subscription.qos, + subscription.encoding, + subscription_id, + ) + ) self._matching_subscriptions.cache_clear() @callback @@ -916,7 +1014,17 @@ class MQTT: is_simple_match = not ("+" in topic or "#" in topic) matcher = None if is_simple_match else _matcher_for_topic(topic) - subscription = Subscription(topic, is_simple_match, matcher, job, qos, encoding) + if is_simple_match: + subscription_id = 1 + else: + subscription_id = self._mqtt_data.subscription_id_generator.get_or_generate( + topic + ) + + subscription = Subscription( + topic, is_simple_match, matcher, job, qos, encoding, subscription_id + ) + self._async_track_subscription(subscription) self._matching_subscriptions.cache_clear() @@ -935,15 +1043,15 @@ class MQTT: del self._retained_topics[subscription] # Only unsubscribe if currently connected if self.connected: - self._async_unsubscribe(subscription.topic) + self._async_unsubscribe(subscription.topic, subscription.subscription_id) @callback - def _async_unsubscribe(self, topic: str) -> None: + def _async_unsubscribe(self, topic: str, subscription_id: int) -> None: """Unsubscribe from a topic.""" if self.is_active_subscription(topic): if self._max_qos[topic] == 0: return - subs = self._matching_subscriptions(topic) + subs = self._matching_subscriptions(topic, (subscription_id,)) self._max_qos[topic] = max(sub.qos for sub in subs) # Other subscriptions on topic remaining - don't unsubscribe. return @@ -969,33 +1077,60 @@ class MQTT: # # Since we do not know if a published value is retained we need to # (re)subscribe, to ensure retained messages are replayed - if not self._pending_subscriptions: return # Split out the wildcard subscriptions, we subscribe to them one by one + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) pending_subscriptions: dict[str, int] = self._pending_subscriptions pending_wildcard_subscriptions = { subscription.topic: pending_subscriptions.pop(subscription.topic) for subscription in self._wildcard_subscriptions if subscription.topic in pending_subscriptions } + subscribe_chain = chunked_or_all( + pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL + ) + if self._supports_subscription_identifiers and pending_subscriptions: + bulk_properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call] + bulk_properties.SubscriptionIdentifier = 1 + else: + bulk_properties = None self._pending_subscriptions = {} - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + for topic, qos in pending_wildcard_subscriptions.items(): + if self._supports_subscription_identifiers: + properties = mqtt.Properties(packetType=mqtt.PacketTypes.SUBSCRIBE) # type: ignore[no-untyped-call] + properties.SubscriptionIdentifier = ( + self._mqtt_data.subscription_id_generator.get_subscription_id(topic) + ) + else: + properties = None - for chunk in chain( - chunked_or_all( - pending_wildcard_subscriptions.items(), MAX_WILDCARD_SUBSCRIBES_PER_CALL - ), - chunked_or_all(pending_subscriptions.items(), MAX_SUBSCRIBES_PER_CALL), - ): + result, mid = self._mqttc.subscribe(topic, qos, properties=properties) + if debug_enabled: + _LOGGER.debug( + "Subscribing with mid: %s to topic %s " + "with qos: %s and properties: %s", + mid, + topic, + qos, + properties, + ) + self._last_subscribe = time.monotonic() + + await self._async_wait_for_mid_or_raise(mid, result) + async_dispatcher_send( + self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, [(topic, qos)] + ) + + for chunk in subscribe_chain: chunk_list = list(chunk) if not chunk_list: continue - result, mid = self._mqttc.subscribe(chunk_list) + result, mid = self._mqttc.subscribe(chunk_list, properties=bulk_properties) if debug_enabled: _LOGGER.debug( @@ -1026,6 +1161,10 @@ class MQTT: await self._async_wait_for_mid_or_raise(mid, result) + # Remove stored subscription identifiers for topics that were just unsubscribed + for topic in topics: + self._mqtt_data.subscription_id_generator.release(topic) + async def _async_resubscribe_and_publish_birth_message( self, birth_message: PublishMessage ) -> None: @@ -1053,13 +1192,34 @@ class MQTT: _userdata: None, _connect_flags: mqtt.ConnectFlags, reason_code: mqtt.ReasonCode, - _properties: mqtt.Properties | None = None, + properties: mqtt.Properties | None = None, ) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth message. """ + if self.is_mqttv5: + # Check if the server explicitly disabled Subscription Identifiers + if ( + properties is not None + and hasattr(properties, "SubscriptionIdentifierAvailable") + and properties.SubscriptionIdentifierAvailable == 0 + ): + _LOGGER.warning( + "Your MQTT broker reports it does not support " + "Subscription Identifiers, see " + "https://docs.oasis-open.org/" + "mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901092. " + "Please use a supported MQTT broker; got broker properties: %s", + properties, + ) + self._supports_subscription_identifiers = False + else: + self._supports_subscription_identifiers = True + else: + self._supports_subscription_identifiers = False + if reason_code.is_failure: # 24: Continue authentication # 25: Re-authenticate @@ -1124,9 +1284,15 @@ class MQTT: ) @lru_cache(None) # pylint: disable=method-cache-max-size-none - def _matching_subscriptions(self, topic: str) -> list[Subscription]: + def _matching_subscriptions( + self, topic: str, identifiers: tuple[int, ...] | None + ) -> list[Subscription]: subscriptions: list[Subscription] = [] - if topic in self._simple_subscriptions: + if topic in self._simple_subscriptions and ( + identifiers is None or 1 in identifiers + ): + # The subscription identifier is always 1 for simple subscriptions, + # so only include them when no identifiers are provided or 1 matches. subscriptions.extend(self._simple_subscriptions[topic]) subscriptions.extend( subscription @@ -1134,6 +1300,7 @@ class MQTT: # mypy doesn't know that complex_matcher is always set when # is_simple_match is False if subscription.complex_matcher(topic) # type: ignore[misc] + and (identifiers is None or subscription.subscription_id in identifiers) ) return subscriptions @@ -1141,6 +1308,18 @@ class MQTT: def _async_mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: + identifiers: tuple[int, ...] | None = None + if self._supports_subscription_identifiers: + # It is possible we have multiple messages if there + # are overlapping wildcard subscriptions. + # So we assigned all wildcard subscriptions with a + # unique SubscriptionIdentifier. Simple subscriptions are assigned + # with SubscriptionIdentifier 1. + if TYPE_CHECKING: + assert msg.properties is not None + assert hasattr(msg.properties, "SubscriptionIdentifier") + with contextlib.suppress(AttributeError): + identifiers = tuple(msg.properties.SubscriptionIdentifier) try: # msg.topic is a property that decodes the topic to a string # every time it is accessed. Save the result to avoid @@ -1157,16 +1336,16 @@ class MQTT: ) return _LOGGER.debug( - "Received%s message on %s (qos=%s): %s", + "Received%s message on %s (qos=%s) IDs=%s: %s", " retained" if msg.retain else "", topic, msg.qos, + identifiers, msg.payload[0:8192], ) - subscriptions = self._matching_subscriptions(topic) msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {} - for subscription in subscriptions: + for subscription in self._matching_subscriptions(topic, identifiers): if msg.retain: retained_topics = self._retained_topics[subscription] # Skip if the subscription already received a retained message @@ -1264,9 +1443,6 @@ class MQTT: @callback def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: """Handle a callback exception.""" - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # noqa: PLC0415 _LOGGER.warning( "Error returned from MQTT server: %s", @@ -1311,8 +1487,6 @@ class MQTT: ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: - import paho.mqtt.client as mqtt # noqa: PLC0415 - raise HomeAssistantError( translation_domain=DOMAIN, translation_key="mqtt_broker_error", @@ -1359,8 +1533,6 @@ class MQTT: def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: - from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415 - matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 52db0bd25da..86ac1a75183 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -1,7 +1,5 @@ """Support for MQTT climate devices.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable from functools import partial diff --git a/homeassistant/components/mqtt/config.py b/homeassistant/components/mqtt/config.py index 1bf592032ad..32346971138 100644 --- a/homeassistant/components/mqtt/config.py +++ b/homeassistant/components/mqtt/config.py @@ -1,7 +1,5 @@ """Support for MQTT message handling.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8cbc9e1625a..eb3fcf29c0d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MQTT.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping @@ -10,7 +8,6 @@ from dataclasses import dataclass from enum import IntEnum import json import logging -import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -73,13 +70,6 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.const import ( - ATTR_CONFIGURATION_URL, - ATTR_HW_VERSION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, - ATTR_SW_VERSION, CONF_BRIGHTNESS, CONF_CLIENT_ID, CONF_CODE, @@ -90,8 +80,11 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_HOST, CONF_MODE, + CONF_MODEL, + CONF_MODEL_ID, CONF_NAME, CONF_OPTIMISTIC, + CONF_OPTIONS, CONF_PASSWORD, CONF_PAYLOAD, CONF_PAYLOAD_OFF, @@ -120,6 +113,8 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps from homeassistant.helpers.selector import ( BooleanSelector, + DurationSelector, + DurationSelectorConfig, FileSelector, FileSelectorConfig, NumberSelector, @@ -141,7 +136,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.util.unit_conversion import TemperatureConverter from .addon import get_addon_manager -from .client import MqttClientSetup +from .client import try_connection from .const import ( ALARM_CONTROL_PANEL_SUPPORTED_FEATURES, ATTR_PAYLOAD, @@ -181,6 +176,7 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CONFIGURATION_URL, CONF_CONTENT_TYPE, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, @@ -191,6 +187,7 @@ from .const import ( CONF_DIRECTION_STATE_TOPIC, CONF_DIRECTION_VALUE_TEMPLATE, CONF_DISCOVERY_PREFIX, + CONF_DISCOVERY_QOS, CONF_EFFECT_COMMAND_TEMPLATE, CONF_EFFECT_COMMAND_TOPIC, CONF_EFFECT_LIST, @@ -220,12 +217,15 @@ from .const import ( CONF_HUMIDITY_MIN, CONF_HUMIDITY_STATE_TEMPLATE, CONF_HUMIDITY_STATE_TOPIC, + CONF_HW_VERSION, CONF_IMAGE_ENCODING, CONF_IMAGE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_MANUFACTURER, CONF_MAX, CONF_MAX_KELVIN, + CONF_MESSAGE_EXPIRY_INTERVAL, CONF_MIN, CONF_MIN_KELVIN, CONF_MODE_COMMAND_TEMPLATE, @@ -235,7 +235,6 @@ from .const import ( CONF_MODE_STATE_TOPIC, CONF_OFF_DELAY, CONF_ON_COMMAND_TYPE, - CONF_OPTIONS, CONF_OSCILLATION_COMMAND_TEMPLATE, CONF_OSCILLATION_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, @@ -316,6 +315,7 @@ from .const import ( CONF_SUPPORT_VOLUME_SET, CONF_SUPPORTED_COLOR_MODES, CONF_SUPPORTED_FEATURES, + CONF_SW_VERSION, CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, CONF_SWING_HORIZONTAL_MODE_LIST, @@ -351,6 +351,7 @@ from .const import ( CONF_TILT_STATE_OPTIMISTIC, CONF_TILT_STATUS_TEMPLATE, CONF_TILT_STATUS_TOPIC, + CONF_TIMEZONE, CONF_TLS_INSECURE, CONF_TRANSITION, CONF_TRANSPORT, @@ -440,8 +441,6 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5 CONF_CLIENT_KEY_PASSWORD = "client_key_password" -MQTT_TIMEOUT = 5 - ADVANCED_OPTIONS = "advanced_options" SET_CA_CERT = "set_ca_cert" SET_CLIENT_CERT = "set_client_cert" @@ -458,6 +457,8 @@ SUBENTRY_PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.FAN, Platform.IMAGE, Platform.LIGHT, @@ -469,6 +470,7 @@ SUBENTRY_PLATFORMS = [ Platform.SIREN, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.VALVE, Platform.WATER_HEATER, ] @@ -477,11 +479,15 @@ _CODE_VALIDATION_MODE = { "remote_code": REMOTE_CODE, "remote_code_text": REMOTE_CODE_TEXT, } -EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY, CONF_UNIT_OF_MEASUREMENT} PWD_NOT_CHANGED = "__**password_not_changed**__" DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/" USER_DOCUMENTATION_URL = "https://www.home-assistant.io/" +TZ_ZONE_ABBR_URL = ( + "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + "#Time_zone_abbreviations" +) INTEGRATION_URL = f"{USER_DOCUMENTATION_URL}integrations/{DOMAIN}/" TEMPLATING_URL = f"{USER_DOCUMENTATION_URL}docs/configuration/templating/" @@ -501,6 +507,7 @@ TRANSLATION_DESCRIPTION_PLACEHOLDERS = { "available_state_classes_url": AVAILABLE_STATE_CLASSES_URL, "naming_entities_url": NAMING_ENTITIES_URL, "registry_properties_url": REGISTRY_PROPERTIES_URL, + "tz_abbr_url": TZ_ZONE_ABBR_URL, } # Common selectors @@ -1133,11 +1140,13 @@ def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]: errors[CONF_MIN] = "max_below_min" errors[CONF_MAX] = "max_below_min" + if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) == "None": + unit_of_measurement = None + if ( (device_class := config.get(CONF_DEVICE_CLASS)) is not None and device_class in NUMBER_DEVICE_CLASS_UNITS - and config.get(CONF_UNIT_OF_MEASUREMENT) - not in NUMBER_DEVICE_CLASS_UNITS[device_class] + and unit_of_measurement not in NUMBER_DEVICE_CLASS_UNITS[device_class] ): errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" @@ -1166,6 +1175,7 @@ def validate_sensor_platform_config( ): errors[CONF_OPTIONS] = "options_with_enum_device_class" + unit_of_measurement: str | None = None if ( device_class in DEVICE_CLASS_UNITS and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None @@ -1175,6 +1185,10 @@ def validate_sensor_platform_config( errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" return errors + if unit_of_measurement == "None": + unit_of_measurement = None + config.pop(CONF_UNIT_OF_MEASUREMENT) + if ( device_class is not None and device_class in DEVICE_CLASS_UNITS @@ -1227,6 +1241,8 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.BUTTON: None, Platform.CLIMATE: validate_climate_platform_config, Platform.COVER: validate_cover_platform_config, + Platform.DATE: None, + Platform.DATETIME: None, Platform.FAN: validate_fan_platform_config, Platform.IMAGE: None, Platform.LIGHT: validate_light_platform_config, @@ -1238,6 +1254,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.SIREN: None, Platform.SWITCH: None, Platform.TEXT: validate_text_platform_config, + Platform.TIME: None, Platform.VALVE: None, Platform.WATER_HEATER: validate_water_heater_platform_config, } @@ -1403,6 +1420,8 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = { required=False, ), }, + Platform.DATE: {}, + Platform.DATETIME: {}, Platform.FAN: { "fan_feature_speed": PlatformField( selector=BOOLEAN_SELECTOR, @@ -1507,6 +1526,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = { ), }, Platform.TEXT: {}, + Platform.TIME: {}, Platform.VALVE: { CONF_DEVICE_CLASS: PlatformField( selector=VALVE_DEVICE_CLASS_SELECTOR, required=False, default=None @@ -2356,7 +2376,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { section="cover_tilt_settings", ), }, - Platform.FAN: { + Platform.DATE: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -2381,6 +2401,61 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { validator=validate(cv.template), error="invalid_template", ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, + Platform.DATETIME: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_TIMEZONE: PlatformField(selector=TEXT_SELECTOR, required=False), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, + Platform.FAN: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_STATE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), CONF_PAYLOAD_OFF: PlatformField( selector=TEXT_SELECTOR, required=False, @@ -3319,7 +3394,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { validator=valid_subscribe_topic, error="invalid_subscribe_topic", ), - CONF_VALUE_TEMPLATE: PlatformField( + CONF_STATE_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, validator=validate(cv.template), @@ -3463,6 +3538,33 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { section="text_advanced_settings", ), }, + Platform.TIME: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, Platform.VALVE: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -3694,17 +3796,17 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { }, } MQTT_DEVICE_PLATFORM_FIELDS = { - ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), - ATTR_SW_VERSION: PlatformField( + CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), + CONF_SW_VERSION: PlatformField( selector=TEXT_SELECTOR, required=False, section="advanced_settings" ), - ATTR_HW_VERSION: PlatformField( + CONF_HW_VERSION: PlatformField( selector=TEXT_SELECTOR, required=False, section="advanced_settings" ), - ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_CONFIGURATION_URL: PlatformField( + CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), + CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), + CONF_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False), + CONF_CONFIGURATION_URL: PlatformField( selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" ), CONF_QOS: PlatformField( @@ -3714,6 +3816,11 @@ MQTT_DEVICE_PLATFORM_FIELDS = { default=DEFAULT_QOS, section="mqtt_settings", ), + CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField( + selector=DurationSelector(DurationSelectorConfig(enable_day=True)), + required=False, + section="mqtt_settings", + ), } @@ -3829,7 +3936,7 @@ def data_schema_from_fields( if not data_schema_element: # Do not show empty sections continue - # Collapse if values are changed or required fields need to be set + # Collapse if no values are changed and no required fields need to be set collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED @@ -4068,6 +4175,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): config: dict[str, Any] = { CONF_BROKER: addon_discovery_config[CONF_HOST], CONF_PORT: addon_discovery_config[CONF_PORT], + CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME), CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD), CONF_DISCOVERY: DEFAULT_DISCOVERY, @@ -4146,11 +4254,11 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._addon_manager.addon_name}, ) from err - if addon_info.state == AddonState.RUNNING: + if addon_info.state is AddonState.RUNNING: # Finish setup using discovery info return await self.async_step_setup_entry_from_discovery() - if addon_info.state == AddonState.NOT_RUNNING: + if addon_info.state is AddonState.NOT_RUNNING: return await self.async_step_start_addon() # Install the add-on and start it @@ -4296,6 +4404,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: data: dict[str, Any] = self._hassio_discovery.copy() data[CONF_BROKER] = data.pop(CONF_HOST) + data[CONF_PROTOCOL] = DEFAULT_PROTOCOL can_connect = await self.hass.async_add_executor_job( try_connection, data, @@ -4307,6 +4416,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data={ CONF_BROKER: data[CONF_BROKER], CONF_PORT: data[CONF_PORT], + CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), CONF_DISCOVERY: DEFAULT_DISCOVERY, @@ -4373,6 +4483,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "bad_discovery_prefix", valid_publish_topic, ) + options_config[CONF_DISCOVERY_QOS] = int(user_input[CONF_DISCOVERY_QOS]) if "birth_topic" in user_input: _validate( CONF_BIRTH_MESSAGE, @@ -4406,6 +4517,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): } discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + discovery_qos = options_config.get(CONF_DISCOVERY_QOS, DEFAULT_QOS) # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() @@ -4413,6 +4525,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[vol.Optional(CONF_DISCOVERY_PREFIX, default=discovery_prefix)] = ( PUBLISH_TOPIC_SELECTOR ) + fields[vol.Optional("discovery_qos", default=discovery_qos)] = QOS_SELECTOR # Birth message is disabled if CONF_BIRTH_MESSAGE = {} fields[ @@ -4533,7 +4646,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self, data_schema: vol.Schema ) -> dict[str, Any]: """Get suggestions from device data based on the data schema.""" - device_data = self._subentry_data["device"] + device_data = deepcopy(self._subentry_data["device"]) + device_data.update(device_data.get("mqtt_settings", {})) return { field_key: self.get_suggested_values_from_device_data(value.schema) if isinstance(value, section) @@ -4607,7 +4721,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): if reconfig := (self._component_id is not None): component_data = self._subentry_data["components"][self._component_id] name: str | None = component_data.get(CONF_NAME) - platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " + platform_label = f"{component_data[CONF_PLATFORM]} " entity_name_label = f" ({name})" if name is not None else "" data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) if user_input is not None: @@ -4984,7 +5098,9 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) mqtt_yaml_config.append({platform: component_config}) @@ -5033,7 +5149,9 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self._subentry_data["device"].get("mqtt_settings", {}).copy() ) for field in EXCLUDE_FROM_CONFIG_IF_NONE: - if field in component_config and component_config[field] is None: + if field in component_config and ( + component_config[field] is None or component_config[field] == "None" + ): component_config.pop(field) discovery_payload["cmps"][component_id] = component_config @@ -5140,7 +5258,7 @@ def _validate_pki_file( return True -async def async_get_broker_settings( # noqa: C901 +async def async_get_broker_settings( flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -5169,6 +5287,8 @@ async def async_get_broker_settings( # noqa: C901 ) -> bool: """Additional validation on broker settings for better error messages.""" + if CONF_PROTOCOL not in validated_user_input: + validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL # Get current certificate settings from config entry certificate: str | None = ( "auto" @@ -5264,15 +5384,11 @@ async def async_get_broker_settings( # noqa: C901 errors["base"] = error return False - if SET_CA_CERT in validated_user_input: - del validated_user_input[SET_CA_CERT] - if SET_CLIENT_CERT in validated_user_input: - del validated_user_input[SET_CLIENT_CERT] + validated_user_input.pop(SET_CA_CERT, None) + validated_user_input.pop(SET_CLIENT_CERT, None) if validated_user_input.get(CONF_TRANSPORT, TRANSPORT_TCP) == TRANSPORT_TCP: - if CONF_WS_PATH in validated_user_input: - del validated_user_input[CONF_WS_PATH] - if CONF_WS_HEADERS in validated_user_input: - del validated_user_input[CONF_WS_HEADERS] + validated_user_input.pop(CONF_WS_PATH, None) + validated_user_input.pop(CONF_WS_HEADERS, None) return True try: validated_user_input[CONF_WS_HEADERS] = json_loads( @@ -5336,7 +5452,6 @@ async def async_get_broker_settings( # noqa: C901 or current_client_certificate or current_client_key or current_tls_insecure - or current_protocol != DEFAULT_PROTOCOL or current_config.get(SET_CA_CERT, "off") != "off" or current_config.get(SET_CLIENT_CERT) or current_transport == TRANSPORT_WEBSOCKETS @@ -5345,6 +5460,12 @@ async def async_get_broker_settings( # noqa: C901 # Build form fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR + fields[ + vol.Optional( + CONF_PROTOCOL, + description={"suggested_value": current_protocol}, + ) + ] = PROTOCOL_SELECTOR fields[ vol.Optional( CONF_USERNAME, @@ -5357,12 +5478,9 @@ async def async_get_broker_settings( # noqa: C901 description={"suggested_value": current_pass}, ) ] = PASSWORD_SELECTOR - # show advanced options checkbox if requested and - # advanced options are enabled - # or when the defaults of advanced options are overridden + # show advanced options checkbox if no defaults + # of the advanced options are overridden if not advanced_broker_options: - if not flow.show_advanced_options: - return False fields[ vol.Optional( ADVANCED_OPTIONS, @@ -5438,12 +5556,6 @@ async def async_get_broker_settings( # noqa: C901 description={"suggested_value": current_tls_insecure}, ) ] = BOOLEAN_SELECTOR - fields[ - vol.Optional( - CONF_PROTOCOL, - description={"suggested_value": current_protocol}, - ) - ] = PROTOCOL_SELECTOR fields[ vol.Optional( CONF_TRANSPORT, @@ -5464,44 +5576,6 @@ async def async_get_broker_settings( # noqa: C901 return False -def try_connection( - user_input: dict[str, Any], -) -> bool: - """Test if we can connect to an MQTT broker.""" - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # noqa: PLC0415 - - mqtt_client_setup = MqttClientSetup(user_input) - mqtt_client_setup.setup() - client = mqtt_client_setup.client - - result: queue.Queue[bool] = queue.Queue(maxsize=1) - - def on_connect( - _mqttc: mqtt.Client, - _userdata: None, - _connect_flags: mqtt.ConnectFlags, - reason_code: mqtt.ReasonCode, - _properties: mqtt.Properties | None = None, - ) -> None: - """Handle connection result.""" - result.put(not reason_code.is_failure) - - client.on_connect = on_connect - - client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT]) - client.loop_start() - - try: - return result.get(timeout=MQTT_TIMEOUT) - except queue.Empty: - return False - finally: - client.disconnect() - client.loop_stop() - - def check_certicate_chain() -> str | None: """Check the MQTT certificates.""" if client_certificate := get_file_path(CONF_CLIENT_CERT): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7244a41e975..fe539d84a8b 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -1,34 +1,10 @@ """Support for MQTT platform config setup.""" -from __future__ import annotations - import voluptuous as vol -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_DISCOVERY, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv -from .const import ( - CONF_BIRTH_MESSAGE, - CONF_BROKER, - CONF_CERTIFICATE, - CONF_CLIENT_CERT, - CONF_CLIENT_KEY, - CONF_DISCOVERY_PREFIX, - CONF_KEEPALIVE, - CONF_TLS_INSECURE, - CONF_WILL_MESSAGE, -) - -DEFAULT_TLS_PROTOCOL = "auto" - CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]), @@ -37,6 +13,8 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All(cv.ensure_list, [dict]), + Platform.DATE.value: vol.All(cv.ensure_list, [dict]), + Platform.DATETIME.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), @@ -53,35 +31,10 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), + Platform.TIME.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) - - -CLIENT_KEY_AUTH_MSG = ( - "client_key and client_cert must both be present in the MQTT broker configuration" -) - -DEPRECATED_CONFIG_KEYS = [ - CONF_BIRTH_MESSAGE, - CONF_BROKER, - CONF_CLIENT_ID, - CONF_DISCOVERY, - CONF_DISCOVERY_PREFIX, - CONF_KEEPALIVE, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_TLS_INSECURE, - CONF_USERNAME, - CONF_WILL_MESSAGE, -] - -DEPRECATED_CERTIFICATE_CONFIG_KEYS = [ - CONF_CERTIFICATE, - CONF_CLIENT_CERT, - CONF_CLIENT_KEY, -] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 57d335685eb..268441ca85c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -11,10 +11,10 @@ from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" ATTR_DISCOVERY_PAYLOAD = "discovery_payload" ATTR_DISCOVERY_TOPIC = "discovery_topic" +ATTR_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval" ATTR_PAYLOAD = "payload" ATTR_QOS = "qos" ATTR_RETAIN = "retain" -ATTR_SERIAL_NUMBER = "serial_number" ATTR_TOPIC = "topic" AVAILABILITY_ALL = "all" @@ -42,19 +42,21 @@ CONF_COMMAND_TOPIC = "command_topic" CONF_CONTENT_TYPE = "content_type" CONF_DEFAULT_ENTITY_ID = "default_entity_id" CONF_DISCOVERY_PREFIX = "discovery_prefix" +CONF_DISCOVERY_QOS = "discovery_qos" CONF_ENCODING = "encoding" CONF_IMAGE_ENCODING = "image_encoding" CONF_IMAGE_TOPIC = "image_topic" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_KEEPALIVE = "keepalive" -CONF_OPTIONS = "options" +CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval" CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_SCHEMA = "schema" CONF_STATE_TOPIC = "state_topic" CONF_STATE_VALUE_TEMPLATE = "state_value_template" +CONF_TIMEZONE = "timezone" CONF_TOPIC = "topic" CONF_TRANSPORT = "transport" CONF_WS_PATH = "ws_path" @@ -347,14 +349,14 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" PROTOCOL_5 = "5" -SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311, PROTOCOL_5] +SUPPORTED_PROTOCOLS = [PROTOCOL_5, PROTOCOL_311, PROTOCOL_31] TRANSPORT_TCP = "tcp" TRANSPORT_WEBSOCKETS = "websockets" DEFAULT_PORT = 1883 DEFAULT_KEEPALIVE = 60 -DEFAULT_PROTOCOL = PROTOCOL_311 +DEFAULT_PROTOCOL = PROTOCOL_5 DEFAULT_TRANSPORT = TRANSPORT_TCP DEFAULT_BIRTH = { @@ -401,6 +403,8 @@ ENTITY_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -417,6 +421,7 @@ ENTITY_PLATFORMS = [ Platform.SIREN, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, @@ -432,6 +437,8 @@ SUPPORTED_COMPONENTS = ( "camera", "climate", "cover", + "date", + "datetime", "device_automation", "device_tracker", "event", @@ -450,6 +457,7 @@ SUPPORTED_COMPONENTS = ( "switch", "tag", "text", + "time", "update", "vacuum", "valve", diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 201f28099c8..ad489e7de4b 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -1,7 +1,5 @@ """Support for MQTT cover devices.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any diff --git a/homeassistant/components/mqtt/date.py b/homeassistant/components/mqtt/date.py new file mode 100644 index 00000000000..8cd13822a07 --- /dev/null +++ b/homeassistant/components/mqtt/date.py @@ -0,0 +1,154 @@ +"""Support for MQTT date platform.""" + +from collections.abc import Callable +import datetime +import logging +from typing import Any + +from dateutil.parser import ParserError, parse +import voluptuous as vol + +from homeassistant.components import date +from homeassistant.components.date import DateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Date" + +MQTT_TIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT date through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttDateEntity, + date.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttDateEntity(MqttEntity, DateEntity): + """Representation of the MQTT date entity.""" + + _attr_native_value: datetime.date | None = None + _attributes_extra_blocked = MQTT_TIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = date.ENTITY_ID_FORMAT + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received date expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + else: + self._attr_native_value = value.date() + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime.date) -> None: + """Change the date.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/datetime.py b/homeassistant/components/mqtt/datetime.py new file mode 100644 index 00000000000..3519261cfe6 --- /dev/null +++ b/homeassistant/components/mqtt/datetime.py @@ -0,0 +1,199 @@ +"""Support for MQTT datetime platform.""" + +from collections.abc import Callable +import datetime as datetime_library +import logging +from typing import Any +from zoneinfo import ZoneInfo + +from dateutil.parser import ParserError, parse +from dateutil.tz import UTC +import voluptuous as vol + +from homeassistant.components import datetime +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType +from homeassistant.util.dt import async_get_time_zone + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + CONF_TIMEZONE, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Date/Time" + +MQTT_DATETIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_TIMEZONE): str, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT datetime through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttDateTime, + datetime.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttDateTime(MqttEntity, DateTimeEntity): + """Representation of the MQTT datetime entity.""" + + _attr_native_value: datetime_library.datetime | None = None + _attributes_extra_blocked = MQTT_DATETIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = datetime.ENTITY_ID_FORMAT + _zone_info: ZoneInfo | None = None + _time_zone_delta: datetime_library.timedelta | None + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._timezone_config = config.get(CONF_TIMEZONE) + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + async def _async_finish_update_config(self) -> None: + """Called after added to hass and after discovery update.""" + self._zone_info = None + if timezone := self._config.get(CONF_TIMEZONE): + self._zone_info = await async_get_time_zone(timezone) + if not self._zone_info: + _LOGGER.warning( + "Ignoring invalid timezone identifier for entity %s, got '%s'", + self.entity_id, + timezone, + ) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received date/time expression on topic" + " %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + + if self._zone_info is not None: + if value.tzinfo is None: + # Convert to UTC + value = value.replace(tzinfo=self._zone_info).astimezone(UTC) + else: + _LOGGER.warning( + "Date/time expression on topic %s for entity %s was not expected " + "to have timezone info, as this is configured explicitly, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + elif value.tzinfo is None: + _LOGGER.warning( + "Date/time expression without required timezone info received " + "on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + return + self._attr_native_value = value + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime_library.datetime) -> None: + """Change the date and time.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 2985e6d7707..5c6deda7cd3 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -1,7 +1,5 @@ """Helper to handle a set of topics to subscribe to.""" -from __future__ import annotations - from collections import deque from dataclasses import dataclass import datetime as dt @@ -19,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType STORED_MESSAGES = 10 -@dataclass +@dataclass(frozen=True, slots=True) class TimestampedPublishMessage: """MQTT Message.""" @@ -28,6 +26,8 @@ class TimestampedPublishMessage: qos: int retain: bool timestamp: float + encoding: str | None + kwargs: dict[str, Any] def log_message( @@ -37,6 +37,8 @@ def log_message( payload: PublishPayloadType, qos: int, retain: bool, + encoding: str | None, + **kwargs: Any, ) -> None: """Log an outgoing MQTT message.""" entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault( @@ -44,10 +46,16 @@ def log_message( ) if topic not in entity_info["transmitted"]: entity_info["transmitted"][topic] = { - "messages": deque([], STORED_MESSAGES), + "messages": deque(maxlen=STORED_MESSAGES), } msg = TimestampedPublishMessage( - topic, payload, qos, retain, timestamp=time.monotonic() + topic, + payload, + qos, + retain, + timestamp=time.monotonic(), + encoding=encoding, + kwargs=kwargs, ) entity_info["transmitted"][topic]["messages"].append(msg) @@ -63,7 +71,7 @@ def add_subscription( if subscription not in entity_info["subscriptions"]: entity_info["subscriptions"][subscription] = { "count": 1, - "messages": deque([], STORED_MESSAGES), + "messages": deque(maxlen=STORED_MESSAGES), } else: entity_info["subscriptions"][subscription]["count"] += 1 diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 2738332bb15..54ac398d027 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -1,7 +1,5 @@ """Provides device automations for MQTT.""" -from __future__ import annotations - import functools import voluptuous as vol diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 4bb23a9fa7e..857b81470cd 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -1,7 +1,5 @@ """Support for tracking MQTT enabled devices identified.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -55,7 +53,9 @@ def valid_config(config: ConfigType) -> ConfigType: """Check if there is a state topic or json_attributes_topic.""" if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config: raise vol.Invalid( - f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}" + f"Invalid device tracker config, missing" + f" {CONF_STATE_TOPIC} or" + f" {CONF_JSON_ATTRS_TOPIC}, got: {config}" ) return config @@ -175,10 +175,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._attr_latitude = None self._attr_longitude = None _LOGGER.warning( - "Extra state attributes received at % and template %s " + "Extra state attributes received at %s and template %s " "contain invalid or incomplete location info. Got %s", - self._config.get(CONF_JSON_ATTRS_TEMPLATE), self._config.get(CONF_JSON_ATTRS_TOPIC), + self._config.get(CONF_JSON_ATTRS_TEMPLATE), extra_state_attributes, ) @@ -190,11 +190,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._attr_location_accuracy = gps_accuracy else: _LOGGER.warning( - "Extra state attributes received at % and template %s " + "Extra state attributes received at %s and template %s " "contain invalid GPS accuracy setting, " "gps_accuracy was set to 0 as the default. Got %s", - self._config.get(CONF_JSON_ATTRS_TEMPLATE), self._config.get(CONF_JSON_ATTRS_TOPIC), + self._config.get(CONF_JSON_ATTRS_TEMPLATE), extra_state_attributes, ) self._attr_location_accuracy = 0 diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 8665ac26961..8267614c053 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for MQTT.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field import logging diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 4cd331ecaad..68d4b2fb9c7 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for MQTT.""" -from __future__ import annotations - from typing import Any from homeassistant.components import device_tracker diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 4ebdbbb6236..f6464790f0b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -1,7 +1,5 @@ """Support for MQTT discovery.""" -from __future__ import annotations - import asyncio from collections import deque from dataclasses import dataclass @@ -21,7 +19,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + entity_registry as er, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -40,6 +42,7 @@ from .const import ( ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, CONF_COMPONENTS, + CONF_DISCOVERY_QOS, CONF_ORIGIN, CONF_TOPIC, DOMAIN, @@ -47,7 +50,10 @@ from .const import ( ) from .models import DATA_MQTT, MqttComponentConfig, MqttOriginInfo, ReceiveMessage from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS -from .util import async_forward_entry_setup_and_setup_discovery +from .util import ( + async_cleanup_device_registry, + async_forward_entry_setup_and_setup_discovery, +) ABBREVIATIONS_SET = set(ABBREVIATIONS) DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS) @@ -337,9 +343,11 @@ def _merge_common_device_options( CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, + CONF_ENCODING, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_STATE_TOPIC, + CONF_QOS Common options in the body of the device based config are inherited into the component. Unless the option is explicitly specified at component level, in that case the option at component level will override the common option. @@ -547,7 +555,7 @@ async def async_start( # noqa: C901 MQTT_DISCOVERY_DONE.format(*discovery_hash), discovery_done, ), - "pending": deque([]), + "pending": deque(), } if component not in mqtt_data.platforms_loaded and payload: @@ -557,7 +565,10 @@ async def async_start( # noqa: C901 ) elif already_discovered: # Dispatch update - message = f"Component has already been discovered: {component} {discovery_id}, sending update" + message = ( + f"Component has already been discovered:" + f" {component} {discovery_id}, sending update" + ) async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload @@ -565,17 +576,39 @@ async def async_start( # noqa: C901 elif payload: _async_add_component(payload) else: - # Unhandled discovery message + entity_registry = er.async_get(hass) + if ( + ( + entity_hash := mqtt_data.discovery_discovered_and_disabled.pop( + discovery_hash, None + ) + ) + and (entity_id := entity_registry.entities.get_entity_id(entity_hash)) + and (entity_entry := entity_registry.async_get(entity_id)) + ): + # Cleanup discovered disabled entity / device + entity_registry.async_remove(entity_id) + hass.async_create_task( + async_cleanup_device_registry( + hass, + device_id=entity_entry.device_id, + config_entry_id=entity_entry.config_entry_id, + ), + name=f"Check for cleanup device registry for {entity_id}", + ) + + # Finish handling discovery message async_dispatcher_send( hass, MQTT_DISCOVERY_DONE.format(*discovery_hash), None ) + discovery_qos: int = config_entry.options.get(CONF_DISCOVERY_QOS, 0) mqtt_data.discovery_unsubscribe = [ async_subscribe_internal( hass, topic, async_discovery_message_received, - 0, + discovery_qos, job_type=HassJobType.Callback, ) # Subscribe first for platform discovery wildcard topics first, @@ -680,7 +713,7 @@ async def async_start( # noqa: C901 hass, topic, functools.partial(async_integration_message_received, integration), - 0, + discovery_qos, job_type=HassJobType.Coroutinefunction, ) for integration, topics in mqtt_integrations.items() diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 86fd7cd5824..7915e336b52 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1,7 +1,5 @@ """MQTT (entity) component mixins and helpers.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from functools import partial @@ -29,6 +27,7 @@ from homeassistant.const import ( CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -85,6 +84,7 @@ from .const import ( CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, + CONF_MESSAGE_EXPIRY_INTERVAL, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, @@ -95,7 +95,6 @@ from .const import ( CONF_SW_VERSION, CONF_TOPIC, CONF_VIA_DEVICE, - DEFAULT_ENCODING, DOMAIN, MQTT_CONNECTION_STATE, ) @@ -125,7 +124,11 @@ from .subscription import ( async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import learn_more_url, mqtt_config_entry_enabled +from .util import ( + async_cleanup_device_registry, + learn_more_url, + mqtt_config_entry_enabled, +) _LOGGER = logging.getLogger(__name__) @@ -150,6 +153,8 @@ MQTT_ATTRIBUTES_BLOCKED = { "unit_of_measurement", } +PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,) + @callback def async_handle_schema_error( @@ -238,7 +243,7 @@ def async_setup_non_entity_entry_helper( @callback -def async_setup_entity_entry_helper( +def async_setup_entity_entry_helper( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -387,6 +392,8 @@ def async_setup_entity_entry_helper( and component_config[CONF_ENTITY_CATEGORY] is None ): component_config.pop(CONF_ENTITY_CATEGORY) + if component_config.get(CONF_UNIT_OF_MEASUREMENT) == "None": + component_config.pop(CONF_UNIT_OF_MEASUREMENT) try: config = platform_schema_modern(component_config) @@ -523,7 +530,7 @@ class MqttAttributesMixin(Entity): self._attributes_message_received, { "_attr_extra_state_attributes", - "_attr_gps_accuracy", + "_attr_location_accuracy", "_attr_latitude", "_attr_location_name", "_attr_longitude", @@ -713,34 +720,6 @@ class MqttAvailabilityMixin(Entity): return self._available_latest -async def cleanup_device_registry( - hass: HomeAssistant, device_id: str | None, config_entry_id: str | None -) -> None: - """Clean up the device registry after MQTT removal. - - Remove MQTT from the device registry entry if there are no remaining - entities, triggers or tags. - """ - # Local import to avoid circular dependencies - from . import device_trigger, tag # noqa: PLC0415 - - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - if ( - device_id - and device_id not in device_registry.deleted_devices - and config_entry_id - and not er.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=False - ) - and not await device_trigger.async_get_triggers(hass, device_id) - and not tag.async_has_tags(hass, device_id) - ): - device_registry.async_update_device( - device_id, remove_config_entry_id=config_entry_id - ) - - def get_discovery_hash(discovery_data: DiscoveryInfoType) -> tuple[str, str]: """Get the discovery hash from the discovery data.""" discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] @@ -995,7 +974,7 @@ class MqttDiscoveryDeviceUpdateMixin(ABC): if not self._skip_device_removal: # Prevent a second cleanup round after the device is removed self._skip_device_removal = True - await cleanup_device_registry( + await async_cleanup_device_registry( self.hass, self._device_id, self._config_entry_id ) @@ -1063,7 +1042,7 @@ class MqttDiscoveryUpdateMixin(Entity): entity_registry = er.async_get(self.hass) if entity_entry := entity_registry.async_get(self.entity_id): entity_registry.async_remove(self.entity_id) - await cleanup_device_registry( + await async_cleanup_device_registry( self.hass, entity_entry.device_id, entity_entry.config_entry_id ) else: @@ -1261,7 +1240,7 @@ class MqttDiscoveryUpdateMixin(Entity): super().add_to_platform_abort() async def async_will_remove_from_hass(self) -> None: - """Stop listening to signal and cleanup discovery data..""" + """Stop listening to signal and cleanup discovery data.""" self._cleanup_discovery_on_remove() def _cleanup_discovery_on_remove(self) -> None: @@ -1411,7 +1390,7 @@ class MqttEntity( self._setup_common_attributes_from_config(self._config) # Initialize entity_id from config - self._init_entity_id() + self._init_entity_registry(discovery_data) # Initialize mixin classes MqttAttributesMixin.__init__(self, config) @@ -1422,16 +1401,19 @@ class MqttEntity( MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) ensure_via_device_exists(self.hass, self.device_info, self._config_entry) - def _init_entity_id(self) -> None: - """Set entity_id from default_entity_id if defined in config.""" + def _init_entity_registry(self, discovery_data: DiscoveryInfoType | None) -> None: + """Set entity_id from default_entity_id if defined in config. + + Check if the previous registry state was disabled + or is set to be disabled initially for discovered entities. + """ object_id: str default_entity_id: str | None - if (default_entity_id := self._config.get(CONF_DEFAULT_ENTITY_ID)) is None: - return - _, _, object_id = default_entity_id.partition(".") - self.entity_id = async_generate_entity_id( - self._entity_id_format, object_id, None, self.hass - ) + if default_entity_id := self._config.get(CONF_DEFAULT_ENTITY_ID): + _, _, object_id = default_entity_id.partition(".") + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, None, self.hass + ) if self.unique_id is None: return @@ -1447,6 +1429,42 @@ class MqttEntity( # if a deleted entity was found self._update_registry_entity_id = self.entity_id + if ( + self._config[CONF_ENABLED_BY_DEFAULT] + and deleted_entry + and deleted_entry.disabled_by is not None + ): + # Enable previous deleted entity and enable it + recreated_entry = entity_registry.async_get_or_create( + entity_platform, DOMAIN, self.unique_id + ) + entity_registry.async_update_entity( + recreated_entry.entity_id, + disabled_by=None, + ) + + if discovery_data is None: + return + + # Allow a disabled entity and device registry + # to be cleaned up via MQTT discovery + if existing_entity_id := entity_registry.async_get_entity_id( + entity_platform, DOMAIN, self.unique_id + ): + existing_entry = entity_registry.async_get(existing_entity_id) + + # Store discovery hash for new entities that are initial disabled + # or for entries that are disabled in the registry, + # so they can be removed with an empty discovery payload + if ( + existing_entity_id is None + or (existing_entry and existing_entry.disabled_by is not None) + ) and not self._config[CONF_ENABLED_BY_DEFAULT]: + mqtt_data = self.hass.data[DATA_MQTT] + mqtt_data.discovery_discovered_and_disabled[ + discovery_data[ATTR_DISCOVERY_HASH] + ] = (entity_platform, DOMAIN, self.unique_id) + @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -1458,6 +1476,7 @@ class MqttEntity( self._update_registry_entity_id = None await super().async_added_to_hass() + await self._async_finish_update_config() self._subscriptions = {} self._prepare_subscribe_topics() if self._subscriptions: @@ -1475,6 +1494,12 @@ class MqttEntity( To be extended by subclasses. """ + async def _async_finish_update_config(self) -> None: + """Called after added to hass and after discovery update. + + To be extended by subclasses. + """ + async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> None: """Handle updated discovery message.""" try: @@ -1485,6 +1510,7 @@ class MqttEntity( self._config = config self._setup_from_config(self._config) self._setup_common_attributes_from_config(self._config) + await self._async_finish_update_config() # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) @@ -1515,36 +1541,20 @@ class MqttEntity( await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self) debug_info.remove_entity_data(self.hass, self.entity_id) - async def async_publish( - self, - topic: str, - payload: PublishPayloadType, - qos: int = 0, - retain: bool = False, - encoding: str | None = DEFAULT_ENCODING, - ) -> None: - """Publish message to an MQTT topic.""" - log_message(self.hass, self.entity_id, topic, payload, qos, retain) - await async_publish( - self.hass, - topic, - payload, - qos, - retain, - encoding, - ) - async def async_publish_with_config( self, topic: str, payload: PublishPayloadType ) -> None: """Publish payload to a topic using config.""" - await self.async_publish( - topic, - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + kwargs: dict[str, Any] = { + key: value for key, value in self._config.items() if key in PUBLISH_KWARGS + } + qos: int = self._config[CONF_QOS] + retain: bool = self._config[CONF_RETAIN] + encoding: str = self._config[CONF_ENCODING] + log_message( + self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs ) + await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs) @staticmethod @abstractmethod @@ -1577,7 +1587,7 @@ class MqttEntity( """(Re)Setup the common attributes for the entity.""" self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_entity_registry_enabled_default = bool( - config.get(CONF_ENABLED_BY_DEFAULT) + config.get(CONF_ENABLED_BY_DEFAULT, True) ) self._attr_icon = config.get(CONF_ICON) self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index aef21838d59..6c15bd938f6 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -1,7 +1,5 @@ """Support for MQTT events.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any @@ -113,7 +111,8 @@ class MqttEvent(MqttEntity, EventEntity): """Handle new MQTT messages.""" if msg.retain: _LOGGER.debug( - "Ignoring event trigger from replayed retained payload '%s' on topic %s", + "Ignoring event trigger from replayed retained" + " payload '%s' on topic %s", msg.payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 39ea543c809..e36c782ffb7 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -1,7 +1,5 @@ """Support for MQTT fans.""" -from __future__ import annotations - from collections.abc import Callable import logging import math diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 07ddcddb13a..952f8cad3e3 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -1,7 +1,5 @@ """Support for MQTT humidifiers.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any @@ -23,6 +21,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, @@ -62,7 +61,6 @@ from .util import valid_publish_topic, valid_subscribe_topic PARALLEL_UPDATES = 0 CONF_AVAILABLE_MODES_LIST = "modes" -CONF_DEVICE_CLASS = "device_class" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_STATE_TOPIC = "mode_state_topic" diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 5e84e83bf69..ab4e8ed943d 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -1,7 +1,5 @@ """Support for MQTT images.""" -from __future__ import annotations - from base64 import b64decode import binascii from collections.abc import Callable diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 1917c56f209..a1c13ecd874 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -1,7 +1,5 @@ """Support for MQTT lawn mowers.""" -from __future__ import annotations - from collections.abc import Callable import contextlib import logging diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 3ffad9226be..6f235dd87e6 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,7 +1,5 @@ """Support for MQTT lights.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 25ea1ee7dc7..7e680a0267f 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -1,7 +1,5 @@ """Support for MQTT lights.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, cast diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index b388cdebb65..0480b53e6ab 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -1,7 +1,5 @@ """Support for MQTT JSON lights.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import TYPE_CHECKING, Any, cast @@ -337,8 +335,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = last_attributes.get( ATTR_BRIGHTNESS, self.brightness ) - self._attr_color_mode = last_attributes.get( - ATTR_COLOR_MODE, self.color_mode + self._attr_color_mode = ( + last_attributes.get(ATTR_COLOR_MODE) or self.color_mode ) self._attr_color_temp_kelvin = last_attributes.get( ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 13b83f082b0..5183248187e 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -1,7 +1,5 @@ """Support for MQTT Template lights.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 2232abb7934..a6293a0a48a 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -1,7 +1,5 @@ """Support for MQTT locks.""" -from __future__ import annotations - from collections.abc import Callable import logging import re diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 52ad9f5f080..3f40e4d2c55 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -1,7 +1,5 @@ """Models used by multiple MQTT modules.""" -from __future__ import annotations - from ast import literal_eval import asyncio from collections import deque @@ -11,9 +9,15 @@ from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict +from paho.mqtt.client import MQTTMessage + from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.exceptions import ServiceValidationError, TemplateError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceValidationError, + TemplateError, +) from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType @@ -26,8 +30,6 @@ from homeassistant.helpers.typing import ( from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from paho.mqtt.client import MQTTMessage - from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger @@ -44,6 +46,70 @@ class PayloadSentinel(StrEnum): DEFAULT = "default" +MAX_28BIT: int = 268435455 + + +class SubscriptionID: + """ID generator for wildcard subscriptions.""" + + _next_id: int = 2 + _used_ids: set[int] + _available_ids: set[int] + _registered_subscriptions: dict[str, int] # topic, subscription_id + + def __init__(self) -> None: + """Initialize the Subscription Identifier generator.""" + self._used_ids = set() + self._available_ids = set() + self._registered_subscriptions = {} + + def _generate(self, topic: str) -> int: + """Generate a new subscription ID.""" + if self._available_ids: + subscription_id = self._available_ids.pop() + self._used_ids.add(subscription_id) + self._registered_subscriptions[topic] = subscription_id + return subscription_id + + subscription_id = self._next_id + if subscription_id > MAX_28BIT: + # pylint: disable-next=home-assistant-exception-message-with-translation + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mqtt_max_subscription_id_reached", + ) + self._used_ids.add(subscription_id) + self._next_id += 1 + self._registered_subscriptions[topic] = subscription_id + return subscription_id + + def get_subscription_id(self, topic: str) -> int: + """Get a registered subscription ID.""" + return self._registered_subscriptions[topic] + + def get_or_generate(self, topic: str) -> int: + """Get an existing or generate a new subscription ID. + + ID 0 is reserved. + ID 1 is used for non wildcard topics. + Generator starts at ID 2. + """ + if topic in self._registered_subscriptions: + return self._registered_subscriptions[topic] + return self._generate(topic) + + def release(self, topic: str) -> None: + """Release a Subscription Identifier to allow reuse.""" + if ( + (subscription_id := self._registered_subscriptions.pop(topic, None)) + is not None + and subscription_id + and subscription_id in self._used_ids + ): + self._used_ids.remove(subscription_id) + self._available_ids.add(subscription_id) + + _LOGGER = logging.getLogger(__name__) ATTR_THIS = "this" @@ -159,8 +225,11 @@ class MqttCommandTemplateException(ServiceValidationError): } entity_id_log = "" if entity_id is None else f" for entity '{entity_id}'" self._message = ( - f"{type(base_exception).__name__}: {base_exception} rendering template{entity_id_log}" - f", template: '{command_template}' and payload: {value_log}" + f"{type(base_exception).__name__}:" + f" {base_exception} rendering" + f" template{entity_id_log}" + f", template: '{command_template}'" + f" and payload: {value_log}" ) def __str__(self) -> str: @@ -245,8 +314,12 @@ class MqttValueTemplateException(TemplateError): ) payload_log = str(payload) self._message = ( - f"{type(base_exception).__name__}: {base_exception} rendering template{entity_id_log}" - f", template: '{value_template}'{default_payload_log} and payload: {payload_log}" + f"{type(base_exception).__name__}:" + f" {base_exception} rendering" + f" template{entity_id_log}" + f", template: '{value_template}'" + f"{default_payload_log}" + f" and payload: {payload_log}" ) def __str__(self) -> str: @@ -400,6 +473,12 @@ class MqttData: ) device_triggers: dict[str, Trigger] = field(default_factory=dict) data_config_flow_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Attribute `discovery_discovered_and_disabled` maps a discovery hash to + # the entity registry index, which is a tuple (entity_platform, "mqtt", unique_id) + # It allows to cleanup disabled entities when an empty payload is received. + discovery_discovered_and_disabled: dict[tuple[str, str], tuple[str, str, str]] = ( + field(default_factory=dict) + ) discovery_already_discovered: set[tuple[str, str]] = field(default_factory=set) discovery_pending_discovered: dict[tuple[str, str], PendingDiscovered] = field( default_factory=dict @@ -417,6 +496,7 @@ class MqttData: state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: set[Subscription] = field(default_factory=set) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) + subscription_id_generator: SubscriptionID = field(default_factory=SubscriptionID) @dataclass(slots=True) @@ -429,10 +509,20 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class MessageExpiryInterval(TypedDict, total=False): + """Hold the Message Expiry Interval.""" + + days: float + hours: float + minutes: float + seconds: float + + class DeviceMqttOptions(TypedDict, total=False): """Hold the shared MQTT specific options for an MQTT device.""" qos: int + message_expiry_interval: MessageExpiryInterval class MqttDeviceData(TypedDict, total=False): diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 0b6dbce38b4..c027b6a2d43 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -1,7 +1,5 @@ """Support for MQTT notify.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components import notify @@ -54,7 +52,7 @@ async def async_setup_entry( class MqttNotify(MqttEntity, NotifyEntity): - """Representation of a notification entity service that can send messages using MQTT.""" + """Notification entity that can send messages using MQTT.""" _default_name = DEFAULT_NAME _entity_id_format = notify.ENTITY_ID_FORMAT diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index cba52bd04ec..5f2c8650f02 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -1,7 +1,5 @@ """Configure number in a device through MQTT topic.""" -from __future__ import annotations - from collections.abc import Callable import logging diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py index 6a002904f11..75dfac16cf6 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -1,18 +1,19 @@ """Repairs for MQTT.""" -from __future__ import annotations - from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN +URL_MQTT_BROKER_CONFIGURATION = ( + "https://www.home-assistant.io/integrations/mqtt/#broker-configuration" +) + class MQTTDeviceEntryMigration(RepairsFlow): """Handler to remove subentry for migrated MQTT device.""" @@ -25,13 +26,13 @@ class MQTTDeviceEntryMigration(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: device_registry = dr.async_get(self.hass) @@ -60,13 +61,9 @@ async def async_create_fix_flow( """Create flow.""" if TYPE_CHECKING: assert data is not None - entry_id = data["entry_id"] - subentry_id = data["subentry_id"] - name = data["name"] - if TYPE_CHECKING: - assert isinstance(entry_id, str) - assert isinstance(subentry_id, str) - assert isinstance(name, str) + entry_id: str = data["entry_id"] # type: ignore[assignment] + subentry_id: str = data["subentry_id"] # type: ignore[assignment] + name: str = data["name"] # type: ignore[assignment] return MQTTDeviceEntryMigration( entry_id=entry_id, subentry_id=subentry_id, diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 12f680b6e12..4acfc91378d 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -1,7 +1,5 @@ """Support for MQTT scenes.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 9e7307d2bc4..9cf204ede6e 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -1,7 +1,5 @@ """Shared schemas for MQTT discovery and YAML config items.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -42,6 +40,7 @@ from .const import ( CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, + CONF_MESSAGE_EXPIRY_INTERVAL, CONF_ORIGIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, @@ -67,9 +66,12 @@ SHARED_OPTIONS = [ CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_MESSAGE_EXPIRY_INTERVAL, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_STATE_TOPIC, + CONF_QOS, ] @@ -161,6 +163,14 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All( ), ) + +def valid_message_expiry_interval(value: Any) -> int: + """Return Message Expiry Interval in seconds.""" + if isinstance(value, int): + return cv.positive_int(value) # type: ignore[no-any-return] + return int(cv.positive_time_period_dict(value).total_seconds()) + + MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -172,6 +182,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend( vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string, + vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -203,6 +214,7 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend( vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval, vol.Optional(CONF_QOS): valid_qos_schema, vol.Optional(CONF_ENCODING): cv.string, } diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 1b3ea1a7c44..bc7c2ba71d6 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -1,7 +1,5 @@ """Configure select in a device through MQTT topic.""" -from __future__ import annotations - from collections.abc import Callable import logging @@ -10,7 +8,12 @@ import voluptuous as vol from homeassistant.components import select from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.const import ( + CONF_NAME, + CONF_OPTIMISTIC, + CONF_OPTIONS, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,12 +23,7 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_OPTIONS, - CONF_STATE_TOPIC, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3423fc161ce..cb75d2e2ca2 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -1,7 +1,5 @@ """Support for MQTT sensors.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -27,6 +25,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME, + CONF_OPTIONS, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, @@ -45,7 +44,6 @@ from .config import MQTT_RO_SCHEMA from .const import ( CONF_EXPIRE_AFTER, CONF_LAST_RESET_VALUE_TEMPLATE, - CONF_OPTIONS, CONF_STATE_TOPIC, CONF_SUGGESTED_DISPLAY_PRECISION, PAYLOAD_NONE, diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index f6fac1d2c1e..a24c084883b 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -11,25 +11,29 @@ publish: example: "The temperature is {{ states('sensor.temperature') }}" selector: template: - evaluate_payload: - advanced: true - default: false - selector: - boolean: - qos: - advanced: true - default: 0 - selector: - select: - options: - - "0" - - "1" - - "2" - retain: - default: false - selector: - boolean: - + publish_options: + collapsed: true + fields: + evaluate_payload: + default: false + selector: + boolean: + qos: + default: "0" + selector: + select: + options: + - "0" + - "1" + - "2" + retain: + default: false + selector: + boolean: + message_expiry_interval: + selector: + duration: + enable_day: true dump: fields: topic: diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 545c0da625f..a6acad1ad32 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -1,7 +1,5 @@ """Support for MQTT sirens.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, cast diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4b09d3558b3..730a039163a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -48,7 +48,7 @@ "data_description": { "advanced_options": "Enable and select **Submit** to set advanced options.", "broker": "The hostname or IP address of your MQTT broker.", - "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", + "certificate": "The custom CA certificate file to validate your MQTT broker's certificate.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_key": "The private key file that belongs to your client certificate.", @@ -56,8 +56,8 @@ "keepalive": "A value less than 90 seconds is advised.", "password": "The password to log in to your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", - "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.", + "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.", "set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "transport": "The transport to be used for the connection to your MQTT broker.", @@ -83,7 +83,7 @@ "password": "[%key:component::mqtt::config::step::broker::data_description::password%]", "username": "[%key:component::mqtt::config::step::broker::data_description::username%]" }, - "description": "The MQTT broker reported an authentication error. Please confirm the brokers correct username and password.", + "description": "The MQTT broker reported an authentication error. Please confirm the broker's correct username and password.", "title": "Re-authentication required with the MQTT broker" }, "start_addon": { @@ -162,7 +162,7 @@ "component": "Entity" }, "data_description": { - "component": "Select the entity you want to delete. Minimal one entity is required." + "component": "Select the entity you want to delete. At least one entity is required." }, "description": "Delete an entity. The entity will be removed from the device. Removing an entity will break any automations or scripts that depend on it.", "title": "Delete entity" @@ -197,9 +197,11 @@ }, "mqtt_settings": { "data": { + "message_expiry_interval": "Message Expiry Interval", "qos": "QoS" }, "data_description": { + "message_expiry_interval": "Retention time interval for published message.", "qos": "The Quality of Service value the device's entities should use." }, "name": "MQTT settings" @@ -376,6 +378,7 @@ "support_duration": "Duration support", "support_volume_set": "Set volume support", "supported_color_modes": "Supported color modes", + "timezone": "Time zone", "url_template": "URL template", "url_topic": "URL topic", "value_template": "Value template" @@ -428,6 +431,7 @@ "support_duration": "The siren supports setting a duration in second. The `duration` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_duration)", "support_volume_set": "The siren supports setting a volume. The `volume_level` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_volume_set)", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "timezone": "Set to a valid [IANA time zone identifier]({tz_abbr_url}). Do not set this option if the date/time structure is providing time zone information via the status update.", "url_template": "[Template]({value_templating_url}) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", "value_template": "Defines a [template]({value_templating_url}) to extract the {platform} entity value. [Learn more.]({url}#value_template)" @@ -1090,8 +1094,8 @@ "command_template_error": { "message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}." }, - "invalid_platform_config": { - "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." + "invalid_platform_config_message": { + "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. Message: {message}" }, "invalid_publish_topic": { "message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})" @@ -1099,6 +1103,15 @@ "mqtt_broker_error": { "message": "Error talking to MQTT: {error_message}." }, + "mqtt_max_subscription_id_reached": { + "message": "MQTT Subscription ID limit reached. Cannot generate more IDs to subscribe." + }, + "mqtt_message_expiry_interval_not_supported": { + "message": "Publishing to topic {topic} with a Message Expiry Interval is not supported for protocol version {protocol}." + }, + "mqtt_not_enabled_cannot_subscribe": { + "message": "Cannot subscribe to topic \"{topic}\" because MQTT is not enabled, make sure MQTT is set up correctly." + }, "mqtt_not_setup_cannot_publish": { "message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly." }, @@ -1120,6 +1133,10 @@ "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.", "title": "Invalid config found for MQTT {domain} item" }, + "protocol_5_migration": { + "description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.", + "title": "MQTT protocol migration failed" + }, "subentry_migration_discovery": { "fix_flow": { "step": { @@ -1212,6 +1229,7 @@ "birth_topic": "Birth message topic", "discovery": "Enable discovery", "discovery_prefix": "Discovery prefix", + "discovery_qos": "Discovery QoS", "will_enable": "Enable will message", "will_payload": "Will message payload", "will_qos": "Will message QoS", @@ -1226,6 +1244,7 @@ "birth_topic": "The MQTT topic where Home Assistant will publish a \"birth\" message.", "discovery": "Option to enable MQTT automatic discovery.", "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", + "discovery_qos": "The quality of service for discovery subscriptions to your MQTT broker.", "will_enable": "When set, Home Assistant will ask your broker to publish a \"will\" message when MQTT is stopped or when it loses the connection to your broker.", "will_payload": "The message your MQTT broker \"will\" publish when the MQTT integration is stopped or when the connection is lost.", "will_qos": "The quality of service of the \"will\" message that is published by your MQTT broker.", @@ -1441,6 +1460,8 @@ "button": "[%key:component::button::title%]", "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", + "date": "[%key:component::date::title%]", + "datetime": "[%key:component::datetime::title%]", "fan": "[%key:component::fan::title%]", "image": "[%key:component::image::title%]", "light": "[%key:component::light::title%]", @@ -1452,6 +1473,7 @@ "siren": "[%key:component::siren::title%]", "switch": "[%key:component::switch::title%]", "text": "[%key:component::text::title%]", + "time": "[%key:component::time::title%]", "valve": "[%key:component::valve::title%]", "water_heater": "[%key:component::water_heater::title%]" } @@ -1532,6 +1554,10 @@ "description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data.", "name": "Evaluate payload" }, + "message_expiry_interval": { + "description": "Expires a publish message after the interval in case the device subscriber was temporarily offline, or the message was set as a retained message. Only supported with MQTT protocol version 5.0.", + "name": "Message Expiry Interval" + }, "payload": { "description": "The payload to publish. Publishes an empty message if not provided.", "name": "Payload" @@ -1549,7 +1575,12 @@ "name": "Topic" } }, - "name": "Publish" + "name": "Publish", + "sections": { + "publish_options": { + "name": "Publish options" + } + } }, "reload": { "description": "Reloads MQTT entities from the YAML-configuration.", diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 08d501ede12..c888dd2a0c2 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -1,7 +1,5 @@ """Helper to handle a set of topics to subscribe to.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index a9323e30435..5c44a73b85b 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -1,7 +1,5 @@ """Support for MQTT switches.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 0615e0e7e6c..f5194764491 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -1,7 +1,5 @@ """Provides tag scanning for MQTT.""" -from __future__ import annotations - from collections.abc import Callable import functools import logging diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index c1b6024d910..eb7b6e36abe 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -1,7 +1,5 @@ """Support for MQTT text platform.""" -from __future__ import annotations - from collections.abc import Callable import logging import re diff --git a/homeassistant/components/mqtt/time.py b/homeassistant/components/mqtt/time.py new file mode 100644 index 00000000000..241b438183c --- /dev/null +++ b/homeassistant/components/mqtt/time.py @@ -0,0 +1,154 @@ +"""Support for MQTT time platform.""" + +from collections.abc import Callable +import datetime +import logging +from typing import Any + +from dateutil.parser import ParserError, parse +import voluptuous as vol + +from homeassistant.components import time +from homeassistant.components.time import TimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Time" + +MQTT_TIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT time through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttTimeEntity, + time.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttTimeEntity(MqttEntity, TimeEntity): + """Representation of the MQTT time entity.""" + + _attr_native_value: datetime.time | None = None + _attributes_extra_blocked = MQTT_TIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = time.ENTITY_ID_FORMAT + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received time expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + else: + self._attr_native_value = value.time() + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime.time) -> None: + """Change the time.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index da26f7f6839..714f3ef51b9 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -1,7 +1,5 @@ """Offer MQTT listening automation rules.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress import logging diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 5591e5d801d..5be27399cd0 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -1,7 +1,5 @@ """Configure update platform in a device through MQTT topic.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 3aea554e460..db35e8351f0 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -1,7 +1,5 @@ """Utility functions for the MQTT integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from functools import lru_cache @@ -17,7 +15,12 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import MAX_LENGTH_STATE_STATE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -216,7 +219,7 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: return False entry = hass.config_entries.async_entries(DOMAIN)[0] - if entry.state == ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.LOADED: return True state_reached_future: asyncio.Future[bool] @@ -421,3 +424,31 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: def learn_more_url(platform: str) -> str: """Return the URL for the platform specific MQTT documentation.""" return f"https://www.home-assistant.io/integrations/{platform}.mqtt/" + + +async def async_cleanup_device_registry( + hass: HomeAssistant, device_id: str | None, config_entry_id: str | None +) -> None: + """Clean up the device registry after MQTT removal. + + Remove MQTT from the device registry entry if there are no remaining + entities, triggers or tags. + """ + # Local import to avoid circular dependencies + from . import device_trigger, tag # noqa: PLC0415 + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + if ( + device_id + and device_id not in device_registry.deleted_devices + and config_entry_id + and not er.async_entries_for_device( + entity_registry, device_id, include_disabled_entities=False + ) + and not await device_trigger.async_get_triggers(hass, device_id) + and not tag.async_has_tags(hass, device_id) + ): + device_registry.async_update_device( + device_id, remove_config_entry_id=config_entry_id + ) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index fb1166250f1..9501754455b 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -1,7 +1,5 @@ """Support for MQTT vacuums.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -16,7 +14,13 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_IDLE, + STATE_PAUSED, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,10 +42,8 @@ FAN_SPEED = "fan_speed" SEGMENTS = "segments" STATE = "state" -STATE_IDLE = "idle" STATE_DOCKED = "docked" STATE_ERROR = "error" -STATE_PAUSED = "paused" STATE_RETURNING = "returning" STATE_CLEANING = "cleaning" @@ -147,7 +149,8 @@ def validate_clean_area_config(config: ConfigType) -> ConfigType: return config if not config.get(CONF_UNIQUE_ID): raise vol.Invalid( - f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured" + f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}`" + f" requires `{CONF_UNIQUE_ID}` to be configured" ) return config @@ -251,6 +254,10 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES] self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE + ) | ( + self.supported_features & VacuumEntityFeature.CLEAN_AREA + if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config + else 0 ) self._clean_segments_command_topic = config.get( CONF_CLEAN_SEGMENTS_COMMAND_TOPIC diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 7c575de09de..42661e4029b 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -1,7 +1,5 @@ """Support for MQTT valve devices.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any @@ -273,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity): self._range, float(position_payload) ) except ValueError: - _LOGGER.warning( + _LOGGER.debug( "Ignoring non numeric payload '%s' received on topic '%s'", position_payload, msg.topic, @@ -281,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity): else: percentage_payload = min(max(percentage_payload, 0), 100) self._attr_current_valve_position = percentage_payload - # Reset closing and opening if the valve is fully opened or fully closed - if state is None and percentage_payload in (0, 100): - state = RESET_CLOSING_OPENING + # Reset opening/closing when a position update is received + # without an explicit opening/closing transitional state. + state = state or RESET_CLOSING_OPENING position_set = True if state_payload and state is None and not position_set: _LOGGER.warning( @@ -293,8 +291,6 @@ class MqttValve(MqttEntity, ValveEntity): state_payload, ) return - if state is None: - return self._update_state(state) @callback diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index a9610cba0cb..7c4726ddadf 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -1,7 +1,5 @@ """Support for MQTT water heater devices.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 6f4e83799d1..1d5312ff34a 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -1,7 +1,5 @@ """Support for GPS tracking MQTT enabled devices.""" -from __future__ import annotations - import json import logging diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 10051bdeb16..73b365eaac0 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -1,7 +1,5 @@ """Support for MQTT room presence detection.""" -from __future__ import annotations - from datetime import timedelta from functools import lru_cache import logging diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py index 47ec9f04637..4f7b9ba0abc 100644 --- a/homeassistant/components/msteams/notify.py +++ b/homeassistant/components/msteams/notify.py @@ -1,7 +1,5 @@ """Microsoft Teams platform for notify component.""" -from __future__ import annotations - import logging from typing import Any @@ -72,5 +70,6 @@ class MSTeamsNotificationService(BaseNotificationService): teams_message.addSection(message_section) try: teams_message.send() + # pylint: disable-next=home-assistant-action-swallowed-exception except RuntimeError as err: _LOGGER.error("Could not send notification. Error: %s", err) diff --git a/homeassistant/components/mta/__init__.py b/homeassistant/components/mta/__init__.py index 231b6e768c6..e33bb6f77f5 100644 --- a/homeassistant/components/mta/__init__.py +++ b/homeassistant/components/mta/__init__.py @@ -1,7 +1,5 @@ """The MTA New York City Transit integration.""" -from __future__ import annotations - import asyncio from homeassistant.const import Platform diff --git a/homeassistant/components/mta/config_flow.py b/homeassistant/components/mta/config_flow.py index e3b1f315eec..f9e7537ff24 100644 --- a/homeassistant/components/mta/config_flow.py +++ b/homeassistant/components/mta/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MTA New York City Transit integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/mta/coordinator.py b/homeassistant/components/mta/coordinator.py index 775e9f1e411..cca3cbe5eaa 100644 --- a/homeassistant/components/mta/coordinator.py +++ b/homeassistant/components/mta/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for MTA New York City Transit.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime import logging diff --git a/homeassistant/components/mta/sensor.py b/homeassistant/components/mta/sensor.py index a6dbee64611..0f906395f3f 100644 --- a/homeassistant/components/mta/sensor.py +++ b/homeassistant/components/mta/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for MTA New York City Transit.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index dad0506ff82..31326bf207b 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -1,31 +1,25 @@ """The Mullvad VPN integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import MullvadCoordinator +from .coordinator import MullvadConfigEntry, MullvadCoordinator PLATFORMS = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MullvadConfigEntry) -> bool: """Set up Mullvad VPN integration.""" coordinator = MullvadCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MullvadConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 3984b2fec08..9603060e7df 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -5,14 +5,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import MullvadCoordinator +from .coordinator import MullvadConfigEntry, MullvadCoordinator BINARY_SENSORS = ( BinarySensorEntityDescription( @@ -25,11 +24,11 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MullvadConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = hass.data[DOMAIN] + coordinator = config_entry.runtime_data async_add_entities( MullvadBinarySensor(coordinator, entity_description, config_entry) @@ -46,7 +45,7 @@ class MullvadBinarySensor(CoordinatorEntity[MullvadCoordinator], BinarySensorEnt self, coordinator: MullvadCoordinator, entity_description: BinarySensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MullvadConfigEntry, ) -> None: """Initialize the Mullvad binary sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/mullvad/coordinator.py b/homeassistant/components/mullvad/coordinator.py index 7d613d719ca..72b78c9a527 100644 --- a/homeassistant/components/mullvad/coordinator.py +++ b/homeassistant/components/mullvad/coordinator.py @@ -15,13 +15,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type MullvadConfigEntry = ConfigEntry[MullvadCoordinator] + class MullvadCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Mullvad VPN data update coordinator.""" - config_entry: ConfigEntry + config_entry: MullvadConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: MullvadConfigEntry) -> None: """Initialize the Mullvad coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index c0d56abba2b..f11d73a6af6 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -1,7 +1,5 @@ """Music Assistant (music-assistant.io) integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass, field @@ -25,7 +23,7 @@ from music_assistant_models.errors import ( from music_assistant_models.player import Player from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_TOKEN, CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -40,7 +38,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, CONF_TOKEN, DOMAIN, LOGGER +from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER from .helpers import get_music_assistant_client from .services import register_actions @@ -49,7 +47,14 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BUTTON, + Platform.MEDIA_PLAYER, + Platform.NUMBER, + Platform.SELECT, + Platform.SWITCH, + Platform.TEXT, +] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 @@ -213,7 +218,8 @@ async def async_setup_entry( # noqa: C901 mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) - # register listener for player configs (to handle toggling of the 'expose_to_ha' setting) + # register listener for player configs + # (to handle toggling of the 'expose_to_ha' setting) def handle_player_config_updated(event: MassEvent) -> None: """Handle Mass Player Config Updated event.""" if event.object_id is None or not event.data: @@ -259,12 +265,12 @@ async def _client_listen( try: await mass.start_listening(init_ready) except MusicAssistantError as err: - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) except Exception as err: # pylint: disable=broad-except # We need to guard against unknown exceptions to not crash this task. - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise LOGGER.exception("Unexpected exception: %s", err) diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 445ef2c3e98..de7b47c314b 100644 --- a/homeassistant/components/music_assistant/button.py +++ b/homeassistant/components/music_assistant/button.py @@ -1,7 +1,5 @@ """Music Assistant Button platform.""" -from __future__ import annotations - from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -34,7 +32,7 @@ async def async_setup_entry( class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): - """Representation of a Button entity to favorite the currently playing item on a player.""" + """Representation of a Button entity to favorite the current item.""" entity_description = ButtonEntityDescription( key="favorite_now_playing", diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 74a6c84dd50..52417fa6cd7 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MusicAssistant integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any from urllib.parse import urlencode @@ -23,7 +21,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -33,13 +31,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - AUTH_SCHEMA_VERSION, - CONF_TOKEN, - DOMAIN, - HASSIO_DISCOVERY_SCHEMA_VERSION, - LOGGER, -) +from .const import AUTH_SCHEMA_VERSION, DOMAIN, HASSIO_DISCOVERY_SCHEMA_VERSION, LOGGER DEFAULT_TITLE = "Music Assistant" DEFAULT_URL = "http://mass.local:8095" @@ -147,9 +139,11 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): This flow is triggered by the Music Assistant app. """ # Build URL from app discovery info - # The app exposes the API on port 8095, but also hosts an internal-only - # webserver (default at port 8094) for the Home Assistant integration to connect to. - # The info where the internal API is exposed is passed via discovery_info + # The app exposes the API on port 8095, but also + # hosts an internal-only webserver (default at port + # 8094) for the HA integration to connect to. + # The info where the internal API is exposed is + # passed via discovery_info host = discovery_info.config["host"] port = discovery_info.config["port"] self.url = f"http://{host}:{port}" @@ -290,7 +284,8 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): state = _encode_jwt( self.hass, {"flow_id": self.flow_id, "redirect_uri": redirect_uri} ) - # Music Assistant server will redirect to: {redirect_uri}?state={state}&code={token} + # Music Assistant server will redirect to: + # {redirect_uri}?state={state}&code={token} params = urlencode( { "return_url": f"{redirect_uri}?state={state}", diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 035da439db6..c6d8d9ab86f 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -12,8 +12,6 @@ AUTH_SCHEMA_VERSION = 28 # Schema version where hassio discovery support was added HASSIO_DISCOVERY_SCHEMA_VERSION = 28 -CONF_TOKEN = "token" - ATTR_IS_GROUP = "is_group" ATTR_GROUP_MEMBERS = "group_members" ATTR_GROUP_PARENTS = "group_parents" diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index 21fc072a639..9b47b122ddf 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -1,13 +1,12 @@ """Base entity model.""" -from __future__ import annotations - from typing import TYPE_CHECKING from music_assistant_models.enums import EventType from music_assistant_models.event import MassEvent -from music_assistant_models.player import Player +from music_assistant_models.player import Player, PlayerOption +from homeassistant.const import EntityCategory from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -84,3 +83,45 @@ class MusicAssistantEntity(Entity): async def async_on_update(self) -> None: """Handle player updates.""" + + +class MusicAssistantPlayerOptionEntity(MusicAssistantEntity): + """Base entity for Music Assistant Player Options.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, mass: MusicAssistantClient, player_id: str, player_option: PlayerOption + ) -> None: + """Initialize MusicAssistantPlayerOptionEntity.""" + super().__init__(mass, player_id) + + self.mass_option_key = player_option.key + self.mass_type = player_option.type + + self.on_player_option_update(player_option) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + # need callbacks of parent to catch availability + await super().async_added_to_hass() + + # main callback for player options + self.async_on_remove( + self.mass.subscribe( + self.__on_mass_player_options_update, + EventType.PLAYER_OPTIONS_UPDATED, + self.player_id, + ) + ) + + def __on_mass_player_options_update(self, event: MassEvent) -> None: + """Call when we receive an event from MusicAssistant.""" + for option in self.player.options: + if option.key == self.mass_option_key: + self.on_player_option_update(option) + self.async_write_ha_state() + break + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Callback for player option updates.""" diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 2f8512dc7c6..9ee4117b1e6 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -1,7 +1,5 @@ """Helpers for the Music Assistant integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import functools from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index c59f88aa5e7..e8949830386 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.4"], + "requirements": ["music-assistant-client==1.3.5"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index fe50afe98e7..abf599311ca 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -1,7 +1,5 @@ """Media Source Implementation.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, cast @@ -549,7 +547,8 @@ def _process_search_results( continue # Create browse item - # Convert to string to get the original value since we're using MASSMediaType enum + # Convert to string to get the original value + # since we're using MASSMediaType enum str_media_type = media_type.value.lower() can_expand = _should_expand_media_type(str_media_type) media_class = _get_media_class_for_type(str_media_type) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8eb13002fd9..3aee7c59ef5 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -1,7 +1,5 @@ """MediaPlayer platform for Music Assistant integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from contextlib import suppress @@ -131,6 +129,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): _attr_name = None _attr_media_image_remotely_accessible = True _attr_media_content_type = HAMediaType.MUSIC + _attr_translation_key = "media_player" def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: """Initialize MediaPlayer entity.""" @@ -140,6 +139,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 self._source_list_mapping: dict[str, str] = {} + self._sound_mode_list_mapping: dict[str, str] = {} async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -218,6 +218,23 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._source_list_mapping = source_mappings self._attr_source = active_source_name + # translation_key, sound_mode.id + sound_mode_mappings: dict[str, str] = {} + active_sound_mode_translation_key: str | None = None + for sound_mode in player.sound_mode_list: + if sound_mode.passive: + # ignore passive sound_mode because HA does not differentiate between + # active and passive sound mode + continue + translation_key = sound_mode.translation_key + if player.active_sound_mode == sound_mode.id: + active_sound_mode_translation_key = translation_key + sound_mode_mappings[translation_key] = sound_mode.id + + self._attr_sound_mode_list = list(sound_mode_mappings.keys()) + self._sound_mode_list_mapping = sound_mode_mappings + self._attr_sound_mode = active_sound_mode_translation_key + group_members: list[str] = [] if player.group_members: group_members = player.group_members @@ -397,6 +414,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) await self.mass.players.player_command_select_source(self.player_id, source_id) + @catch_musicassistant_error + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + sound_mode_id = self._sound_mode_list_mapping.get(sound_mode) + if sound_mode_id is None: + raise ServiceValidationError( + f"Sound mode '{sound_mode}' not found for player {self.name}" + ) + await self.mass.players.select_sound_mode(self.player_id, sound_mode_id) + @catch_musicassistant_error async def _async_handle_play_media( self, @@ -682,4 +709,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): supported_features |= MediaPlayerEntityFeature.TURN_OFF if PlayerFeature.SELECT_SOURCE in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE + if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._attr_supported_features = supported_features diff --git a/homeassistant/components/music_assistant/number.py b/homeassistant/components/music_assistant/number.py new file mode 100644 index 00000000000..35769338825 --- /dev/null +++ b/homeassistant/components/music_assistant/number.py @@ -0,0 +1,117 @@ +"""Music Assistant Number platform.""" + +from typing import Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_NUMBER: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "bass": True, + "dialogue_level": False, + "dialogue_lift": False, + "dts_dialogue_control": False, + "equalizer_high": False, + "equalizer_low": False, + "equalizer_mid": False, + "subwoofer_volume": True, + "treble": True, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant Number Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigNumber] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type + in ( + PlayerOptionType.INTEGER, + PlayerOptionType.FLOAT, + ) + and not player_option.options # these we map to select + ): + # we ignore entities with unknown translation keys. + if player_option.translation_key not in PLAYER_OPTIONS_NUMBER: + continue + + entities.append( + MusicAssistantPlayerConfigNumber( + mass, + player_id, + player_option=player_option, + entity_description=NumberEntityDescription( + key=player_option.key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_NUMBER[ + player_option.translation_key + ], + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.NUMBER, add_player) + + +class MusicAssistantPlayerConfigNumber(MusicAssistantPlayerOptionEntity, NumberEntity): + """Representation of a Number entity to control player settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: NumberEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigNumber.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + _value = round(value) if self.mass_type == PlayerOptionType.INTEGER else value + await self.mass.players.set_option( + self.player_id, + self.mass_option_key, + _value, + ) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + if player_option.min_value is not None: + self._attr_native_min_value = player_option.min_value + if player_option.max_value is not None: + self._attr_native_max_value = player_option.max_value + if player_option.step is not None: + self._attr_native_step = player_option.step + + self._attr_native_value = ( + player_option.value + if isinstance(player_option.value, (int, float)) + else None + ) diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 8bb91d2deec..38d86f7c155 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -1,7 +1,5 @@ """Voluptuous schemas for Music Assistant integration service responses.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from music_assistant_models.enums import ImageType, MediaType diff --git a/homeassistant/components/music_assistant/select.py b/homeassistant/components/music_assistant/select.py new file mode 100644 index 00000000000..e7804764c9c --- /dev/null +++ b/homeassistant/components/music_assistant/select.py @@ -0,0 +1,127 @@ +"""Music Assistant select platform.""" + +from typing import Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_SELECT: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "dimmer": False, + "equalizer_mode": False, + "link_audio_delay": True, + "link_audio_quality": False, + "link_control": False, + "sleep": False, + "surround_decoder_type": False, + "tone_control_mode": True, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant Select Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigSelect] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type + != PlayerOptionType.BOOLEAN # these always go to switch + and player_option.options + ): + # We ignore entities with unknown + # translation key for the base name. + # However, we accept a non-available + # translation_key in strings.json for the + # entity's state, as these are oftentimes + # dynamically created, dependent on a + # specific player and might not be known to + # the provider developer. In that case, the + # frontend falls back to showing the state's + # bare translation key. + if player_option.translation_key not in PLAYER_OPTIONS_SELECT: + continue + + entities.append( + MusicAssistantPlayerConfigSelect( + mass, + player_id, + player_option=player_option, + entity_description=SelectEntityDescription( + key=player_option.key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_SELECT[ + player_option.translation_key + ], + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.SELECT, add_player) + + +class MusicAssistantPlayerConfigSelect(MusicAssistantPlayerOptionEntity, SelectEntity): + """Representation of a select entity to control player settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: SelectEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigSelect.""" + # this was verified already in the entry callback + assert player_option.options is not None + # we have to define the dicts before initializing the parent, as this + # then calls self.on_player_option_update + self._option_translation_key_to_key_mapping = { + option.translation_key: option.key for option in player_option.options + } + self._option_key_to_translation_key_mapping = { + option.key: option.translation_key for option in player_option.options + } + + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + self._attr_options = list(self._option_translation_key_to_key_mapping.keys()) + + @catch_musicassistant_error + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await self.mass.players.set_option( + self.player_id, + self.mass_option_key, + self._option_translation_key_to_key_mapping[option], + ) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + self._attr_current_option = ( + self._option_key_to_translation_key_mapping.get(player_option.value) + if isinstance(player_option.value, str) + else None + ) diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index aaa3c71c9f2..87ea1f0f211 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -1,7 +1,5 @@ """Custom actions (previously known as services) for the Music Assistant integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from music_assistant_models.enums import MediaType, QueueOption diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index fbc1949d5fd..28c554c4d5e 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -46,7 +46,6 @@ play_media: - "add" translation_key: enqueue radio_mode: - advanced: true selector: boolean: @@ -138,20 +137,21 @@ search: example: "News of the world" selector: text: - limit: - advanced: true - example: 25 - default: 5 - selector: - number: - min: 1 - max: 100 - step: 1 - library_only: - example: "true" - default: false - selector: - boolean: + search_options: + fields: + limit: + example: 25 + default: 5 + selector: + number: + min: 1 + max: 100 + step: 1 + library_only: + example: "true" + default: false + selector: + boolean: get_library: fields: @@ -183,24 +183,24 @@ get_library: example: "We Are The Champions" selector: text: - limit: - advanced: true - example: 25 - default: 25 - selector: - number: - min: 1 - max: 500 - step: 1 - offset: - advanced: true - example: 25 - default: 0 - selector: - number: - min: 1 - max: 1000000 - step: 1 + pagination: + fields: + limit: + example: 25 + default: 25 + selector: + number: + min: 1 + max: 500 + step: 1 + offset: + example: 25 + default: 0 + selector: + number: + min: 0 + max: 1000000 + step: 1 order_by: example: "random" selector: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 57c5e1745b4..a52253658da 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -53,6 +53,210 @@ "favorite_now_playing": { "name": "Favorite current song" } + }, + "media_player": { + "media_player": { + "state_attributes": { + "sound_mode": { + "state": { + "2ch_stereo": "2ch stereo", + "5ch_stereo": "5ch stereo", + "7ch_stereo": "7ch stereo", + "9ch_stereo": "9ch stereo", + "11ch_stereo": "11ch stereo", + "action_game": "Action game", + "adventure": "Adventure", + "all_ch_stereo": "All ch stereo", + "amsterdam": "Hall in Amsterdam", + "arena": "Arena", + "bass_booster": "Bass booster", + "bottom_line": "The Bottom Line", + "cellar_club": "Cellar club", + "chamber": "Chamber", + "concert": "Live concert", + "disco": "Disco", + "drama": "Drama", + "enhanced": "Enhanced", + "frankfurt": "Hall in Frankfurt", + "freiburg": "Church in Freiburg", + "game": "Game", + "jazz_club": "Jazz club", + "mono_movie": "Mono movie", + "movie": "Movie", + "munich": "Hall in Munich", + "munich_a": "Hall in Munich A", + "munich_b": "Hall in Munich B", + "music": "Music", + "music_video": "Music video", + "my_surround": "My surround", + "off": "[%key:common::state::off%]", + "pavilion": "Pavilion", + "recital_opera": "Recital/opera", + "roleplaying_game": "Roleplaying game", + "roxy_theatre": "The Roxy Theatre", + "royaumont": "Church in Royaumont", + "sci-fi": "Sci-fi", + "spectacle": "Spectacle", + "sports": "Sports", + "standard": "Standard", + "stereo": "Stereo", + "straight": "Straight", + "stuttgart": "Hall in Stuttgart", + "surr_decoder": "Surround decoder", + "talk_show": "Talk show", + "target": "Target", + "tokyo": "Church in Tokyo", + "tv_program": "TV program", + "usa_a": "Hall in USA A", + "usa_b": "Hall in USA B", + "vienna": "Hall in Vienna", + "village_gate": "Village Gate", + "village_vanguard": "Village Vanguard", + "warehouse_loft": "Warehouse loft" + } + } + } + } + }, + "number": { + "bass": { + "name": "Bass" + }, + "dialogue_level": { + "name": "Dialogue level" + }, + "dialogue_lift": { + "name": "Dialogue lift" + }, + "dts_dialogue_control": { + "name": "DTS dialogue control" + }, + "equalizer_high": { + "name": "Equalizer high" + }, + "equalizer_low": { + "name": "Equalizer low" + }, + "equalizer_mid": { + "name": "Equalizer mid" + }, + "subwoofer_volume": { + "name": "Subwoofer volume" + }, + "treble": { + "name": "Treble" + } + }, + "select": { + "dimmer": { + "name": "Dimmer", + "state": { + "auto": "[%key:common::state::auto%]" + } + }, + "equalizer_mode": { + "name": "Equalizer mode", + "state": { + "auto": "[%key:common::state::auto%]", + "bypass": "Bypass", + "manual": "[%key:common::state::manual%]" + } + }, + "link_audio_delay": { + "name": "Link audio delay", + "state": { + "audio_sync": "Audio synchronization", + "audio_sync_off": "Audio synchronization off", + "audio_sync_on": "Audio synchronization on", + "balanced": "Balanced", + "lip_sync": "Lip synchronization" + } + }, + "link_audio_quality": { + "name": "Link audio quality", + "state": { + "compressed": "Compressed", + "uncompressed": "Uncompressed" + } + }, + "link_control": { + "name": "Link control", + "state": { + "speed": "Speed", + "stability": "Stability", + "standard": "Standard" + } + }, + "sleep": { + "name": "Sleep timer", + "state": { + "0": "[%key:common::state::off%]", + "30": "30 minutes", + "60": "60 minutes", + "90": "90 minutes", + "120": "120 minutes" + } + }, + "surround_decoder_type": { + "name": "Surround decoder type", + "state": { + "auto": "[%key:common::state::auto%]", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "[%key:common::action::toggle%]" + } + }, + "tone_control_mode": { + "name": "Tone control mode", + "state": { + "auto": "[%key:common::state::auto%]", + "bypass": "Bypass", + "manual": "[%key:common::state::manual%]" + } + } + }, + "switch": { + "adaptive_drc": { + "name": "Adaptive DRC" + }, + "bass_extension": { + "name": "Bass extension" + }, + "clear_voice": { + "name": "Clear voice" + }, + "enhancer": { + "name": "Enhancer" + }, + "extra_bass": { + "name": "Extra bass" + }, + "party_mode": { + "name": "Party mode" + }, + "pure_direct": { + "name": "Pure direct" + }, + "speaker_a": { + "name": "Speaker A" + }, + "speaker_b": { + "name": "Speaker B" + }, + "surround_3d": { + "name": "Surround 3D" + } + }, + "text": { + "network_name": { + "name": "Network name" + } } }, "issues": { @@ -156,7 +360,12 @@ "name": "Search" } }, - "name": "Get library items" + "name": "Get library items", + "sections": { + "pagination": { + "name": "Pagination" + } + } }, "get_queue": { "description": "Retrieves the details of the currently active queue of a Music Assistant player.", @@ -246,7 +455,12 @@ "name": "Search name" } }, - "name": "Search Music Assistant" + "name": "Search Music Assistant", + "sections": { + "search_options": { + "name": "Search options" + } + } }, "transfer_queue": { "description": "Transfers a player's queue to another player.", diff --git a/homeassistant/components/music_assistant/switch.py b/homeassistant/components/music_assistant/switch.py new file mode 100644 index 00000000000..c32f8893605 --- /dev/null +++ b/homeassistant/components/music_assistant/switch.py @@ -0,0 +1,104 @@ +"""Music Assistant Switch platform.""" + +from typing import Any, Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_SWITCH: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "adaptive_drc": False, + "bass_extension": False, + "clear_voice": False, + "enhancer": True, + "extra_bass": False, + "party_mode": False, + "pure_direct": True, + "speaker_a": True, + "speaker_b": True, + "surround_3d": False, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant Switch Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigSwitch] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type == PlayerOptionType.BOOLEAN + ): + # we ignore entities with unknown translation keys. + if player_option.translation_key not in PLAYER_OPTIONS_SWITCH: + continue + + entities.append( + MusicAssistantPlayerConfigSwitch( + mass, + player_id, + player_option=player_option, + entity_description=SwitchEntityDescription( + key=player_option.key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_SWITCH[ + player_option.translation_key + ], + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.SWITCH, add_player) + + +class MusicAssistantPlayerConfigSwitch(MusicAssistantPlayerOptionEntity, SwitchEntity): + """Representation of a Switch entity to control player settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigSwitch.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Handle turn on command.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, True) + + @catch_musicassistant_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Handle turn off command.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, False) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + self._attr_is_on = ( + player_option.value if isinstance(player_option.value, bool) else None + ) diff --git a/homeassistant/components/music_assistant/text.py b/homeassistant/components/music_assistant/text.py new file mode 100644 index 00000000000..14662e322e4 --- /dev/null +++ b/homeassistant/components/music_assistant/text.py @@ -0,0 +1,91 @@ +"""Music Assistant text platform.""" + +from typing import Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.text import TextEntity, TextEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_TEXT: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "network_name": True +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant text Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigText] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type == PlayerOptionType.STRING + and not player_option.options # these we map to select + ): + # we ignore entities with unknown translation keys. + if player_option.translation_key not in PLAYER_OPTIONS_TEXT: + continue + + entities.append( + MusicAssistantPlayerConfigText( + mass, + player_id, + player_option=player_option, + entity_description=TextEntityDescription( + key=player_option.key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_TEXT[ + player_option.translation_key + ], + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.TEXT, add_player) + + +class MusicAssistantPlayerConfigText(MusicAssistantPlayerOptionEntity, TextEntity): + """Representation of a text entity to control player provider dependent settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: TextEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigtext.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_set_value(self, value: str) -> None: + """Set text value.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, value) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + self._attr_native_value = ( + player_option.value if isinstance(player_option.value, str) else None + ) diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py index 4921ec1f821..17b074f8cd2 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -1,7 +1,5 @@ """The mütesync integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index a2aacfc927e..a85a0081f89 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -1,7 +1,5 @@ """Config flow for mütesync integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/mutesync/coordinator.py b/homeassistant/components/mutesync/coordinator.py index 2e4925edd56..4b27568847d 100644 --- a/homeassistant/components/mutesync/coordinator.py +++ b/homeassistant/components/mutesync/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the mütesync integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 031ec164ecd..985beac8f4f 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -1,7 +1,5 @@ """Support for departure information for public transport in Munich.""" -from __future__ import annotations - from collections.abc import Mapping from copy import deepcopy from datetime import timedelta @@ -165,7 +163,7 @@ class MVGLiveSensor(SensorEntity): def _get_minutes_until_departure(departure_time: int) -> int: - """Calculate the time difference in minutes between the current time and a given departure time. + """Calculate the time difference in minutes between now and a departure time. Args: departure_time: Unix timestamp of the departure time, in seconds. diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index 19e29004be8..6256b1d29bc 100644 --- a/homeassistant/components/mycroft/notify.py +++ b/homeassistant/components/mycroft/notify.py @@ -1,7 +1,5 @@ """Mycroft AI notification platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/myneomitis/__init__.py b/homeassistant/components/myneomitis/__init__.py index ab27ae01585..00ab6a5a493 100644 --- a/homeassistant/components/myneomitis/__init__.py +++ b/homeassistant/components/myneomitis/__init__.py @@ -1,7 +1,5 @@ """Integration for MyNeomitis.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any @@ -22,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT] @dataclass @@ -114,6 +112,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MyNeomitisConfigEntry) - return True +def process_connection_update(new_state: dict[str, Any]) -> bool | None: + """Return availability from a connection update.""" + if not new_state or "connected" not in new_state: + return None + + return bool(new_state.get("connected")) + + async def async_unload_entry(hass: HomeAssistant, entry: MyNeomitisConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/myneomitis/climate.py b/homeassistant/components/myneomitis/climate.py new file mode 100644 index 00000000000..2f2a13991d5 --- /dev/null +++ b/homeassistant/components/myneomitis/climate.py @@ -0,0 +1,360 @@ +"""Climate entities for MyNeomitis integration.""" + +import logging +from typing import Any + +from pyaxencoapi import ( + PRESET_MODE_MAP, + PRESET_MODE_MODELS, + REVERSE_PRESET_MODE_MAP, + Preset, + PyAxencoAPI, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MyNeomitisConfigEntry, process_connection_update +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_MODELS: frozenset[str] = frozenset({"EV30", "ECTRL", "ESTAT", "RSS-ECTRL"}) +SUPPORTED_SUB_MODELS: frozenset[str] = frozenset({"NTD", "ETRV"}) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MyNeomitisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entities from a config entry.""" + api = config_entry.runtime_data.api + devices = config_entry.runtime_data.devices + + climate_entities: list[MyNeoClimate] = [] + for device in devices: + model = device.get("model") + if model not in SUPPORTED_MODELS | SUPPORTED_SUB_MODELS: + continue + + device_id = device.get("_id") + if not device_id: + _LOGGER.warning("Skipping device without _id: %s", device.get("name")) + continue + + climate_entities.append(MyNeoClimate(api, device)) + + if climate_entities: + async_add_entities(climate_entities) + + +class MyNeoClimate(ClimateEntity): + """Climate entity for MyNeomitis device.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "myneomitis" + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_should_poll = False + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + + def __init__(self, api: PyAxencoAPI, device: dict[str, Any]) -> None: + """Initialize the MyNeoClimate entity.""" + self._api = api + self._device = device + self._device_id: str = device["_id"] + model = device.get("model") + name = device.get("name") or self._device_id + + self._attr_unique_id = self._device_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=name, + manufacturer="Axenco", + model=model, + ) + + connected = bool(device.get("connected", False)) + self._attr_available = connected + self._unavailable_logged: bool = False + + state = device.get("state", {}) + self._is_sub_device = model in SUPPORTED_SUB_MODELS + self._parents = device.get("parents") or {} + if model in PRESET_MODE_MODELS: + self._attr_preset_modes = PRESET_MODE_MODELS[model] + else: + default_presets = [p.key for p in Preset] + _LOGGER.warning( + "Model %s not found in PRESET_MODE_MODELS, using default presets %s", + model, + default_presets, + ) + self._attr_preset_modes = default_presets + self._attr_min_temp = state.get("comfLimitMin", 7) + self._attr_max_temp = state.get("comfLimitMax", 30) + self._attr_current_temperature = state.get("currentTemp") + self._attr_target_temperature = ( + state.get("targetTemp") + if self._is_sub_device + else state.get("overrideTemp") + ) + target_mode = state.get("targetMode") + if isinstance(target_mode, int): + self._attr_preset_mode = REVERSE_PRESET_MODE_MAP.get(target_mode) + else: + self._attr_preset_mode = None + self._last_preset_mode: str | None = ( + self._attr_preset_mode + if self._attr_preset_mode and self._attr_preset_mode != "standby" + else None + ) + if model == "NTD" and state.get("changeOverUser") == 1: + self._attr_hvac_modes = [HVACMode.COOL, HVACMode.OFF] + self._attr_hvac_mode = ( + HVACMode.OFF + if PRESET_MODE_MAP.get(self._attr_preset_mode or "") == 4 + else HVACMode.COOL + ) + else: + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + self._attr_hvac_mode = ( + HVACMode.OFF + if PRESET_MODE_MAP.get(self._attr_preset_mode or "") == 4 + else HVACMode.HEAT + ) + + async def async_added_to_hass(self) -> None: + """Register listener when entity is added to hass.""" + await super().async_added_to_hass() + if unsubscribe := self._api.register_listener( + self._device_id, self.handle_ws_update + ): + self.async_on_remove(unsubscribe) + + @callback + def handle_ws_update(self, new_state: dict[str, Any]) -> None: + """Update entity state from WebSocket callback.""" + available = process_connection_update(new_state) + if available is not None: + self._attr_available = available + if not available: + if not self._unavailable_logged: + _LOGGER.info("The entity %s is unavailable", self.entity_id) + self._unavailable_logged = True + elif self._unavailable_logged: + _LOGGER.info("The entity %s is back online", self.entity_id) + self._unavailable_logged = False + + if not new_state: + return + + if "currentTemp" in new_state: + self._attr_current_temperature = new_state["currentTemp"] + if "overrideTemp" in new_state: + self._attr_target_temperature = new_state["overrideTemp"] + elif "targetTemp" in new_state: + self._attr_target_temperature = new_state["targetTemp"] + if "targetMode" in new_state: + self._attr_preset_mode = REVERSE_PRESET_MODE_MAP.get( + new_state["targetMode"] + ) + if self._attr_preset_mode and self._attr_preset_mode != "standby": + self._last_preset_mode = self._attr_preset_mode + if self._attr_preset_mode == "standby": + self._attr_hvac_mode = HVACMode.OFF + elif self._attr_hvac_mode == HVACMode.OFF: + self._attr_hvac_mode = next( + ( + mode + for mode in self._attr_hvac_modes + if mode is not HVACMode.OFF + ), + HVACMode.HEAT, + ) + if "changeOverUser" in new_state and self._device.get("model") == "NTD": + if new_state["changeOverUser"] == 1: + self._attr_hvac_modes = [HVACMode.COOL, HVACMode.OFF] + + if ( + self._attr_hvac_mode != HVACMode.OFF + and self._attr_preset_mode != "standby" + ): + self._attr_hvac_mode = HVACMode.COOL + else: + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + + if ( + self._attr_hvac_mode != HVACMode.OFF + and self._attr_preset_mode != "standby" + ): + self._attr_hvac_mode = HVACMode.HEAT + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature for the climate entity.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + if self._attr_preset_mode != "setpoint": + ok = await self._set_device_mode("setpoint") + if not ok: + raise HomeAssistantError( + f"Failed to set preset mode 'setpoint' for {self.entity_id}" + ) + self._attr_preset_mode = "setpoint" + if self._attr_hvac_mode == HVACMode.OFF: + self._attr_hvac_mode = next( + ( + mode + for mode in (self._attr_hvac_modes or []) + if mode is not HVACMode.OFF + ), + HVACMode.HEAT, + ) + + ok = await self._set_device_temperature(temperature) + if not ok: + raise HomeAssistantError( + f"Failed to set temperature to {temperature} for {self.entity_id}" + ) + + self._attr_target_temperature = temperature + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the climate entity.""" + if preset_mode not in PRESET_MODE_MAP: + _LOGGER.warning("Unknown preset mode: %s", preset_mode) + return + + new_hvac_mode = self._attr_hvac_mode + if preset_mode == "standby": + new_hvac_mode = HVACMode.OFF + elif self._attr_hvac_mode == HVACMode.OFF: + new_hvac_mode = next( + ( + mode + for mode in (self._attr_hvac_modes or []) + if mode is not HVACMode.OFF + ), + HVACMode.HEAT, + ) + + ok = await self._set_device_mode(preset_mode) + if not ok: + raise HomeAssistantError( + f"Failed to set preset mode '{preset_mode}' for {self.entity_id}" + ) + + self._attr_hvac_mode = new_hvac_mode + if preset_mode != "standby": + self._last_preset_mode = preset_mode + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode for the climate entity.""" + if hvac_mode == HVACMode.OFF: + if self._attr_preset_mode and self._attr_preset_mode != "standby": + self._last_preset_mode = self._attr_preset_mode + + ok = await self._set_device_mode("standby") + if not ok: + raise HomeAssistantError( + f"Failed to set standby mode for {self.entity_id}" + ) + self._attr_preset_mode = "standby" + else: + preset_to_restore = None + if ( + self._last_preset_mode + and self._attr_preset_modes is not None + and self._last_preset_mode in self._attr_preset_modes + ): + preset_to_restore = self._last_preset_mode + + if not preset_to_restore: + preset_to_restore = next( + (p for p in (self._attr_preset_modes or []) if p != "standby"), + "comfort", + ) + + ok = await self._set_device_mode(preset_to_restore) + if not ok: + raise HomeAssistantError( + f"Failed to restore preset '{preset_to_restore}'" + f" for {self.entity_id}" + ) + self._attr_preset_mode = preset_to_restore + + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + async def _set_device_mode(self, mode: str) -> bool: + """Set the device mode via API.""" + try: + mode_value = PRESET_MODE_MAP.get(mode) + if mode_value is None: + _LOGGER.error( + "Attempt to set unknown mode %s for %s", mode, self.entity_id + ) + return False + + if self._is_sub_device: + gateway = self._parents.get("gateway") + rfid = self._device.get("rfid") + if not gateway or not rfid: + _LOGGER.error( + "Missing gateway or rfid for sub-device %s, cannot set mode", + self._attr_unique_id, + ) + return False + await self._api.set_sub_device_mode(gateway, str(rfid), mode_value) + else: + await self._api.set_device_mode(self._device_id, mode_value) + except (TimeoutError, ConnectionError) as err: + _LOGGER.error("Error setting device mode for %s: %s", self._device_id, err) + return False + + return True + + async def _set_device_temperature(self, temperature: float) -> bool: + """Set the device temperature via API.""" + try: + if self._is_sub_device: + gateway = self._parents.get("gateway") + rfid = self._device.get("rfid") + if not gateway or not rfid: + _LOGGER.error( + "Missing gateway or rfid for sub-device" + " %s, cannot set temperature", + self._attr_unique_id, + ) + return False + await self._api.set_sub_device_temperature( + gateway, str(rfid), temperature + ) + else: + await self._api.set_device_temperature(self._device_id, temperature) + except (TimeoutError, ConnectionError) as err: + _LOGGER.error( + "Error setting device temperature for %s: %s", + self._device_id, + err, + ) + return False + + return True diff --git a/homeassistant/components/myneomitis/icons.json b/homeassistant/components/myneomitis/icons.json index 8814be2396d..0d198a3fa5f 100644 --- a/homeassistant/components/myneomitis/icons.json +++ b/homeassistant/components/myneomitis/icons.json @@ -1,5 +1,23 @@ { "entity": { + "climate": { + "myneomitis": { + "state_attributes": { + "preset_mode": { + "state": { + "antifrost": "mdi:snowflake", + "auto": "mdi:refresh-auto", + "boost": "mdi:rocket-launch", + "comfort": "mdi:fire", + "comfort_plus": "mdi:fire-circle", + "eco": "mdi:leaf", + "setpoint": "mdi:thermostat", + "standby": "mdi:toggle-switch-off-outline" + } + } + } + } + }, "select": { "pilote": { "state": { diff --git a/homeassistant/components/myneomitis/manifest.json b/homeassistant/components/myneomitis/manifest.json index b9dfa39dd83..0a314a42e56 100644 --- a/homeassistant/components/myneomitis/manifest.json +++ b/homeassistant/components/myneomitis/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["pyaxencoapi==1.0.6"] + "requirements": ["pyaxencoapi==1.0.7"] } diff --git a/homeassistant/components/myneomitis/select.py b/homeassistant/components/myneomitis/select.py index c2d70e70346..b8ba4574d7f 100644 --- a/homeassistant/components/myneomitis/select.py +++ b/homeassistant/components/myneomitis/select.py @@ -3,8 +3,6 @@ This module defines and sets up the select entities for the MyNeomitis integration. """ -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any @@ -104,12 +102,8 @@ async def async_setup_entry( def _create_entity(device: dict) -> MyNeoSelect: """Create a select entity for a device.""" if device["model"] == "EWS": - # According to the MyNeomitis API, EWS "relais" devices expose a "relayMode" - # field in their state, while "pilote" devices do not. We therefore use the - # presence of "relayMode" as an explicit heuristic to distinguish relais - # from pilote devices. If the upstream API changes this behavior, this - # detection logic must be revisited. - if "relayMode" in device.get("state", {}): + state = device.get("state") or {} + if state.get("deviceType") == 0: description = SELECT_TYPES["relais"] else: description = SELECT_TYPES["pilote"] diff --git a/homeassistant/components/myneomitis/strings.json b/homeassistant/components/myneomitis/strings.json index 59edeafd0ff..e768bc7c287 100644 --- a/homeassistant/components/myneomitis/strings.json +++ b/homeassistant/components/myneomitis/strings.json @@ -24,6 +24,24 @@ } }, "entity": { + "climate": { + "myneomitis": { + "state_attributes": { + "preset_mode": { + "state": { + "antifrost": "Frost protection", + "auto": "[%key:common::state::auto%]", + "boost": "Boost", + "comfort": "Comfort", + "comfort_plus": "Comfort +", + "eco": "Eco", + "setpoint": "Setpoint", + "standby": "[%key:common::state::standby%]" + } + } + } + } + }, "select": { "pilote": { "state": { diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 47629006887..ac0b8b26e98 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -1,7 +1,5 @@ """The MyQ integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index e2aca8b9f01..e713ff60645 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,6 +1,5 @@ """Connect to a MySensors gateway via pymysensors API.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Callable, Mapping import logging diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 90ed6ecb623..d1d8b758798 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -1,7 +1,5 @@ """Support for MySensors binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index eb54a76b8a8..3782134a3e7 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -1,7 +1,5 @@ """MySensors platform that offers a Climate (MySensors-HVAC) component.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index e616e325835..805abd1ea12 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MySensors.""" -from __future__ import annotations - import os from typing import Any @@ -98,7 +96,8 @@ def _is_same_device( ) -> bool: """Check if another ConfigDevice is actually the same as user_input. - This function only compares addresses and tcp ports, so it is possible to fool it with tricks like port forwarding. + This function only compares addresses and tcp ports, so it is possible + to fool it with tricks like port forwarding. """ if entry.data[CONF_DEVICE] != user_input[CONF_DEVICE]: return False diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 05e19d452a2..8093bd92a9d 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -1,7 +1,5 @@ """MySensors constants.""" -from __future__ import annotations - from collections import defaultdict from typing import Final, Literal, TypedDict diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 84346a5d10a..f49e9983fb3 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -1,7 +1,5 @@ """Support for MySensors covers.""" -from __future__ import annotations - from enum import Enum, unique from typing import Any @@ -83,17 +81,17 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): @property def is_closed(self) -> bool: """Return True if the cover is closed.""" - return self.get_cover_state() == CoverState.CLOSED + return self.get_cover_state() is CoverState.CLOSED @property def is_closing(self) -> bool: """Return True if the cover is closing.""" - return self.get_cover_state() == CoverState.CLOSING + return self.get_cover_state() is CoverState.CLOSING @property def is_opening(self) -> bool: """Return True if the cover is opening.""" - return self.get_cover_state() == CoverState.OPENING + return self.get_cover_state() is CoverState.OPENING @property def current_cover_position(self) -> int | None: diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index e6368b0b81d..fa11d2ae71e 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -1,7 +1,5 @@ """Support for tracking MySensors devices.""" -from __future__ import annotations - from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/mysensors/entity.py b/homeassistant/components/mysensors/entity.py index 5caa42c282c..6d09a89f5e5 100644 --- a/homeassistant/components/mysensors/entity.py +++ b/homeassistant/components/mysensors/entity.py @@ -1,6 +1,5 @@ """Handle MySensors devices.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from abc import abstractmethod import logging diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 91453ea3306..a6c18f2a249 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -1,7 +1,5 @@ """Handle MySensors gateways.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable @@ -284,6 +282,8 @@ async def _gw_start( gateway.on_conn_made = gateway_connected # Don't use hass.async_create_task to avoid holding up setup indefinitely. + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN][MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)] = ( asyncio.create_task(gateway.start()) ) # store the connect task so it can be cancelled in gw_stop diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 96ea5347102..a00a6ca92e5 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -1,7 +1,5 @@ """Handle MySensors messages.""" -from __future__ import annotations - from collections.abc import Callable from mysensors import Message diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 3c9b841bdb3..6de3cb27c06 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -1,7 +1,5 @@ """Helper functions for mysensors package.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable from enum import IntEnum @@ -62,6 +60,8 @@ def discover_mysensors_node( hass: HomeAssistant, gateway_id: GatewayId, node_id: int ) -> None: """Discover a MySensors node.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data discovered_nodes = hass.data[DOMAIN].setdefault( MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() ) @@ -157,7 +157,10 @@ def invalid_msg( """Return a message for an invalid child during schema validation.""" presentation = gateway.const.Presentation set_req = gateway.const.SetReq - return f"{presentation(child.type).name} requires value_type {set_req[value_type_name].name}" + return ( + f"{presentation(child.type).name} requires" + f" value_type {set_req[value_type_name].name}" + ) def validate_set_msg( diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index fa5e625c72b..f71e1db8020 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -1,7 +1,5 @@ """Support for MySensors lights.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.light import ( diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index ccb67f78eba..c5c3ababfc9 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -1,7 +1,5 @@ """Support MySensors IR transceivers.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any, cast diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 836070f4a09..8b5fa3f140a 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,5 @@ """Support for MySensors sensors.""" -from __future__ import annotations - from typing import Any from awesomeversion import AwesomeVersion @@ -230,6 +228,8 @@ async def async_setup_entry( """Add battery sensor for each MySensors node.""" gateway_id = discovery_info[ATTR_GATEWAY_ID] node_id = discovery_info[ATTR_NODE_ID] + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 9b57102a94c..b906121a582 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -1,7 +1,5 @@ """Support for MySensors switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 9fdd9da5345..c1dd794ca20 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -1,7 +1,5 @@ """Provide a text platform for MySensors.""" -from __future__ import annotations - from homeassistant.components.text import TextEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 5440a28b01d..307e99be1bd 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -1,7 +1,5 @@ """The myStrom integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py index 0e4d8db73f4..60b5d1e3de5 100644 --- a/homeassistant/components/mystrom/binary_sensor.py +++ b/homeassistant/components/mystrom/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the myStrom buttons.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py index 38b292e9f97..28e7848c6c4 100644 --- a/homeassistant/components/mystrom/config_flow.py +++ b/homeassistant/components/mystrom/config_flow.py @@ -1,9 +1,7 @@ """Config flow for myStrom integration.""" -from __future__ import annotations - import logging -from typing import Any +from typing import TYPE_CHECKING, Any import pymystrom from pymystrom.exceptions import MyStromConnectionError @@ -11,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -20,6 +19,8 @@ DEFAULT_NAME = "myStrom Device" STEP_USER_DATA_SCHEMA = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_HOST): str, } @@ -31,6 +32,8 @@ class MyStromConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -51,3 +54,38 @@ class MyStromConfigFlow(ConfigFlow, domain=DOMAIN): schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + mac_address = discovery_info.macaddress.upper() + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + try: + await pymystrom.get_device_info(discovery_info.ip) + except MyStromConnectionError: + return self.async_abort(reason="cannot_connect") + + self._host = discovery_info.ip + self.context["title_placeholders"] = {"host": discovery_info.ip} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle discovery confirmation.""" + if user_input is not None: + return self.async_create_entry( + title=DEFAULT_NAME, + data={CONF_HOST: self._host}, + ) + + self._set_confirm_only() + if TYPE_CHECKING: + assert self._host is not None + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 67964d7d5b4..1ca084d6fed 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -1,7 +1,5 @@ """Support for myStrom Wifi bulbs.""" -from __future__ import annotations - import logging from typing import Any @@ -91,6 +89,7 @@ class MyStromLight(LightEntity): await self._bulb.set_sunrise(30) if effect == EFFECT_RAINBOW: await self._bulb.set_rainbow(30) + # pylint: disable-next=home-assistant-action-swallowed-exception except MyStromConnectionError: _LOGGER.warning("No route to myStrom bulb") @@ -98,6 +97,7 @@ class MyStromLight(LightEntity): """Turn off the bulb.""" try: await self._bulb.set_off() + # pylint: disable-next=home-assistant-action-swallowed-exception except MyStromConnectionError: _LOGGER.warning("The myStrom bulb not online") diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index 2cab6ec12f6..fc8dc8cba12 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -4,6 +4,14 @@ "codeowners": ["@fabaff"], "config_flow": true, "dependencies": ["http"], + "dhcp": [ + { + "hostname": "mystrom-*" + }, + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/mystrom", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 87a44dffc6c..a57cbbbd444 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -1,7 +1,5 @@ """Support for myStrom sensors of switches/plugs.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index 2466f5f0d3c..b4c86693866 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -1,12 +1,16 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up the myStrom device at {host}?" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 860d2dff727..bb1132fe43c 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -1,7 +1,5 @@ """Support for myStrom switches/plugs.""" -from __future__ import annotations - import logging from typing import Any @@ -53,6 +51,7 @@ class MyStromSwitch(SwitchEntity): """Turn the switch on.""" try: await self.plug.turn_on() + # pylint: disable-next=home-assistant-action-swallowed-exception except MyStromConnectionError: _LOGGER.error("No route to myStrom plug") @@ -60,6 +59,7 @@ class MyStromSwitch(SwitchEntity): """Turn the switch off.""" try: await self.plug.turn_off() + # pylint: disable-next=home-assistant-action-swallowed-exception except MyStromConnectionError: _LOGGER.error("No route to myStrom plug") diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 8eee978cf18..d579c6a971e 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -1,7 +1,5 @@ """The myUplink integration.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py index 32e0ea70193..a87f37e891a 100644 --- a/homeassistant/components/myuplink/api.py +++ b/homeassistant/components/myuplink/api.py @@ -1,7 +1,5 @@ """API for myUplink bound to Home Assistant OAuth.""" -from __future__ import annotations - from typing import cast from aiohttp import ClientSession diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 785a7ff4532..45585eebdd2 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -144,7 +144,7 @@ class MyUplinkDevicePointBinarySensor(MyUplinkEntity, BinarySensorEntity): """Return device data availability.""" return super().available and ( self.coordinator.data.devices[self.device_id].connectionState - == DeviceConnectionState.Connected + is DeviceConnectionState.Connected ) @@ -172,7 +172,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): """Binary sensor state value.""" return ( self.coordinator.data.devices[self.device_id].connectionState - == DeviceConnectionState.Connected + is DeviceConnectionState.Connected ) diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py index 61605a04fc8..cdc7d980176 100644 --- a/homeassistant/components/myuplink/diagnostics.py +++ b/homeassistant/components/myuplink/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for myUplink.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 0a3f7d2ebb6..9b9b2a0892c 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -332,7 +332,7 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): - """Representation of a myUplink device point sensor for raw value from ENUM device_class.""" + """Representation of a myUplink device point sensor for raw ENUM value.""" _attr_entity_registry_enabled_default = False _attr_device_class = None diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index 2af8c607610..92665a9714c 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing with NAD receivers through RS-232.""" -from __future__ import annotations - from nad_receiver import NADReceiver, NADReceiverTCP, NADReceiverTelnet import voluptuous as vol @@ -214,8 +212,8 @@ class NADtcp(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - nad_volume_to_set = int( - round(volume * (self._max_vol - self._min_vol) + self._min_vol) + nad_volume_to_set = round( + volume * (self._max_vol - self._min_vol) + self._min_vol ) self._nad_receiver.set_volume(nad_volume_to_set) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 4504cff42b3..f5824c988ab 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -1,7 +1,5 @@ """The Nettigo Air Monitor component.""" -from __future__ import annotations - import logging from aiohttp.client_exceptions import ClientError diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 791a5fdc27c..0f95f3631de 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -1,7 +1,5 @@ """Support for the Nettigo Air Monitor service.""" -from __future__ import annotations - import logging from aiohttp.client_exceptions import ClientError diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index a13757234bc..7b6ebac3924 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Nettigo Air Monitor.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 2dedcf3c68a..7029772fbfc 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -1,7 +1,5 @@ """Constants for Nettigo Air Monitor integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/nam/diagnostics.py b/homeassistant/components/nam/diagnostics.py index 905c1669496..6fceb71cd83 100644 --- a/homeassistant/components/nam/diagnostics.py +++ b/homeassistant/components/nam/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for NAM.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index e59d111e5e5..c3e8c0d8667 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -1,7 +1,5 @@ """Support for the Nettigo Air Monitor service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -358,8 +356,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_UPTIME, - translation_key="last_restart", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value=lambda sensors: utcnow() - timedelta(seconds=sensors.uptime or 0), diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 83913110d45..f1aa0311f2f 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -93,9 +93,6 @@ "heca_temperature": { "name": "HECA temperature" }, - "last_restart": { - "name": "Last restart" - }, "mhz14a_carbon_dioxide": { "name": "MH-Z14A carbon dioxide" }, diff --git a/homeassistant/components/namecheapdns/config_flow.py b/homeassistant/components/namecheapdns/config_flow.py index 312b4b1d80c..c4b06c3fb6e 100644 --- a/homeassistant/components/namecheapdns/config_flow.py +++ b/homeassistant/components/namecheapdns/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Namecheap DynamicDNS integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 18aa4e7611a..1638fd91ab9 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1,7 +1,5 @@ """The Nanoleaf integration.""" -from __future__ import annotations - import asyncio from contextlib import suppress import logging diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 5b9653604d3..49541036959 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nanoleaf integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import os diff --git a/homeassistant/components/nanoleaf/device_trigger.py b/homeassistant/components/nanoleaf/device_trigger.py index 28b39e03db7..387f7c276ed 100644 --- a/homeassistant/components/nanoleaf/device_trigger.py +++ b/homeassistant/components/nanoleaf/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Nanoleaf.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/nanoleaf/diagnostics.py b/homeassistant/components/nanoleaf/diagnostics.py index ce2045acf7b..18fff175595 100644 --- a/homeassistant/components/nanoleaf/diagnostics.py +++ b/homeassistant/components/nanoleaf/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nanoleaf.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 5fd547383ac..5b29ba0ba63 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,7 +1,5 @@ """Support for Nanoleaf Lights.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ( diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py index a95c48f0f81..4b85684bd50 100644 --- a/homeassistant/components/nasweb/__init__.py +++ b/homeassistant/components/nasweb/__init__.py @@ -1,7 +1,5 @@ """The NASweb integration.""" -from __future__ import annotations - import logging from webio_api import WebioAPI @@ -52,6 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo ) if not await webio_api.refresh_device_info(): _LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST]) + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_internal_error", translation_placeholders={"support_email": SUPPORT_EMAIL}, @@ -59,6 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo webio_serial = webio_api.get_serial_number() if webio_serial is None: _LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST]) + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_internal_error", translation_placeholders={"support_email": SUPPORT_EMAIL}, @@ -67,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo _LOGGER.error( "[%s] Serial number doesn't match config entry", entry.data[CONF_HOST] ) + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError(translation_key="config_entry_error_serial_mismatch") coordinator = NASwebCoordinator( @@ -78,12 +79,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo webhook_url = nasweb_data.get_webhook_url(hass) if not await webio_api.status_subscription(webhook_url, True): _LOGGER.error("Failed to subscribe for status updates from webio") + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_internal_error", translation_placeholders={"support_email": SUPPORT_EMAIL}, ) if not await nasweb_data.notify_coordinator.check_connection(webio_serial): _LOGGER.error("Did not receive status from device") + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_no_status_update", translation_placeholders={"support_email": SUPPORT_EMAIL}, @@ -93,10 +96,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo f"[{entry.data[CONF_HOST]}] Check connection reached timeout" ) from error except AuthError as error: + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_invalid_authentication" ) from error except NoURLAvailableError as error: + # pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch raise ConfigEntryError( translation_key="config_entry_error_missing_internal_url" ) from error diff --git a/homeassistant/components/nasweb/alarm_control_panel.py b/homeassistant/components/nasweb/alarm_control_panel.py index 695c0168886..9d27d2f1851 100644 --- a/homeassistant/components/nasweb/alarm_control_panel.py +++ b/homeassistant/components/nasweb/alarm_control_panel.py @@ -1,7 +1,5 @@ """Platform for NASweb alarms.""" -from __future__ import annotations - import logging import time @@ -137,7 +135,8 @@ class ZoneEntity(AlarmControlPanelEntity, BaseCoordinatorEntity): """Update the entity. Only used by the generic entity update service. - Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + Scheduling updates is not necessary, the coordinator + takes care of updates via push notifications. """ @property diff --git a/homeassistant/components/nasweb/climate.py b/homeassistant/components/nasweb/climate.py index 5d3b4c469bc..a87281ae55f 100644 --- a/homeassistant/components/nasweb/climate.py +++ b/homeassistant/components/nasweb/climate.py @@ -1,7 +1,5 @@ """Platform for NASweb thermostat.""" -from __future__ import annotations - import time from typing import Any @@ -154,7 +152,8 @@ class Thermostat(ClimateEntity, BaseCoordinatorEntity): """Update the entity. Only used by the generic entity update service. - Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + Scheduling updates is not necessary, the coordinator + takes care of updates via push notifications. """ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py index 298210903dc..6b31a5a70dc 100644 --- a/homeassistant/components/nasweb/config_flow.py +++ b/homeassistant/components/nasweb/config_flow.py @@ -1,7 +1,5 @@ """Config flow for NASweb integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py index e27b81d62a6..415c8b4e91b 100644 --- a/homeassistant/components/nasweb/coordinator.py +++ b/homeassistant/components/nasweb/coordinator.py @@ -1,7 +1,5 @@ """Message routing coordinators for handling NASweb push notifications.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import datetime, timedelta @@ -28,7 +26,10 @@ KEY_ZONES = "zones" class NotificationCoordinator: - """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" + """Coordinator redirecting push notifications for this integration. + + Redirects to appropriate NASwebCoordinator. + """ def __init__(self) -> None: """Initialize coordinator.""" @@ -162,11 +163,15 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): self.async_update_listeners() def _schedule_last_update_check(self) -> None: - """Schedule a task to trigger entities state update after `STATUS_UPDATE_MAX_TIME_INTERVAL`. + """Schedule a task to trigger entities state update. - This method schedules a task (`_handle_max_update_interval`) to be executed after - `STATUS_UPDATE_MAX_TIME_INTERVAL` seconds without status update, which enables entities - to change their state to unavailable. After each status update this task is rescheduled. + Triggers after `STATUS_UPDATE_MAX_TIME_INTERVAL`. + This method schedules a task + (`_handle_max_update_interval`) to be executed after + `STATUS_UPDATE_MAX_TIME_INTERVAL` seconds without + status update, which enables entities to change their + state to unavailable. After each status update this + task is rescheduled. """ self._async_unsub_last_update_check() now = self._hass.loop.time() diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py index 82a69b74aa6..512a52542df 100644 --- a/homeassistant/components/nasweb/sensor.py +++ b/homeassistant/components/nasweb/sensor.py @@ -1,7 +1,5 @@ """Platform for NASweb sensors.""" -from __future__ import annotations - import logging import time @@ -115,7 +113,8 @@ class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity): """Update the entity. Only used by the generic entity update service. - Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + Scheduling updates is not necessary, the coordinator + takes care of updates via push notifications. """ diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index a36f3062932..3b9955b3945 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -1,7 +1,5 @@ """Platform for NASweb output.""" -from __future__ import annotations - import logging import time from typing import Any @@ -128,7 +126,8 @@ class RelaySwitch(SwitchEntity, BaseCoordinatorEntity): """Update the entity. Only used by the generic entity update service. - Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + Scheduling updates is not necessary, the coordinator + takes care of updates via push notifications. """ async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/national_grid_us/__init__.py b/homeassistant/components/national_grid_us/__init__.py deleted file mode 100644 index 7db5e6e8160..00000000000 --- a/homeassistant/components/national_grid_us/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: National Grid US.""" diff --git a/homeassistant/components/national_grid_us/manifest.json b/homeassistant/components/national_grid_us/manifest.json deleted file mode 100644 index 88041ba2964..00000000000 --- a/homeassistant/components/national_grid_us/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "national_grid_us", - "name": "National Grid US", - "integration_type": "virtual", - "supported_by": "opower" -} diff --git a/homeassistant/components/neato/api.py b/homeassistant/components/neato/api.py index 75a3d6724de..b6b5c15ee57 100644 --- a/homeassistant/components/neato/api.py +++ b/homeassistant/components/neato/api.py @@ -1,7 +1,5 @@ """API for Neato Botvac bound to Home Assistant OAuth.""" -from __future__ import annotations - from asyncio import run_coroutine_threadsafe from typing import Any diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py index 2afaca89000..9881fe7c179 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -1,7 +1,5 @@ """Support for Neato buttons.""" -from __future__ import annotations - from pybotvac import Robot from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 4234867be99..1e95d8c6e46 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -1,7 +1,5 @@ """Support for loading picture from Neato.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 72e2575be67..a6f2bcbcfa3 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Neato Botvac.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py index f172353edd0..fb46b1e641d 100644 --- a/homeassistant/components/neato/entity.py +++ b/homeassistant/components/neato/entity.py @@ -1,7 +1,5 @@ """Base entity for Neato.""" -from __future__ import annotations - from pybotvac import Robot from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index 9410e60ad09..3d9f1e29b16 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -1,7 +1,5 @@ """Support for Neato botvac connected vacuum cleaners.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 577a515bf4d..37886a921a7 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.28"] + "requirements": ["pybotvac==0.0.29"] } diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 6ec28dba7fe..fc9ee06b581 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -1,7 +1,5 @@ """Support for Neato sensors.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/neato/services.py b/homeassistant/components/neato/services.py index 71234560d28..184c83e1eb5 100644 --- a/homeassistant/components/neato/services.py +++ b/homeassistant/components/neato/services.py @@ -1,7 +1,5 @@ """Neato services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index df0aba9787e..e50d6df872c 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -1,7 +1,5 @@ """Support for Neato Connected Vacuums switches.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -102,6 +100,7 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): if self.type == SWITCH_TYPE_SCHEDULE: try: self.robot.enable_schedule() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato switch connection error '%s': %s", self.entity_id, ex @@ -112,6 +111,7 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): if self.type == SWITCH_TYPE_SCHEDULE: try: self.robot.disable_schedule() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato switch connection error '%s': %s", self.entity_id, ex diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 02d2e40b4db..2509fc24766 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -1,7 +1,5 @@ """Support for Neato Connected Vacuums.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -277,6 +275,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self.robot.start_cleaning() elif self._state["state"] == 3: self.robot.resume_cleaning() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex @@ -286,6 +285,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Pause the vacuum.""" try: self.robot.pause_cleaning() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex @@ -298,6 +298,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self.robot.pause_cleaning() self._attr_activity = VacuumActivity.RETURNING self.robot.send_to_base() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex @@ -307,6 +308,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Stop the vacuum cleaner.""" try: self.robot.stop_cleaning() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex @@ -316,6 +318,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Locate the robot by making it emit a sound.""" try: self.robot.locate() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex @@ -325,6 +328,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Run a spot cleaning starting from the base.""" try: self.robot.start_spot_cleaning() + # pylint: disable-next=home-assistant-action-swallowed-exception except NeatoRobotException as ex: _LOGGER.error( "Neato vacuum connection error for '%s': %s", self.entity_id, ex diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py index 203616d69a5..3d98f9b5c51 100644 --- a/homeassistant/components/nederlandse_spoorwegen/__init__.py +++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py @@ -1,7 +1,5 @@ """The Nederlandse Spoorwegen integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py index 1172bc462b5..476178f5768 100644 --- a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Nederlandse Spoorwegen public transport.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py index 71c35facaf6..cf9a95cb3ab 100644 --- a/homeassistant/components/nederlandse_spoorwegen/config_flow.py +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nederlandse Spoorwegen integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nederlandse_spoorwegen/coordinator.py b/homeassistant/components/nederlandse_spoorwegen/coordinator.py index a7b736c322d..97aa2fa0c09 100644 --- a/homeassistant/components/nederlandse_spoorwegen/coordinator.py +++ b/homeassistant/components/nederlandse_spoorwegen/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Nederlandse Spoorwegen.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta import logging @@ -54,7 +52,7 @@ class NSRouteResult: class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]): - """Class to manage fetching Nederlandse Spoorwegen data from the API for a single route.""" + """Class to manage fetching NS data from the API for a single route.""" def __init__( self, @@ -99,7 +97,8 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]): # Filter out trips that have already departed (trips are already sorted) future_trips = self._remove_trips_in_the_past(trips) - # If a specific time is configured, filter to only show trips at or after that time + # If a specific time is configured, filter to only show + # trips at or after that time if self.departure_time: reference_time = self._get_time_from_route(self.departure_time) future_trips = self._filter_trips_at_or_after_time( @@ -116,7 +115,7 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]): ) def _get_time_from_route(self, time_str: str | None) -> datetime: - """Convert time string to datetime with automatic rollover to tomorrow if needed.""" + """Convert time string to datetime with automatic rollover.""" if not time_str: return _current_time_nl() diff --git a/homeassistant/components/nederlandse_spoorwegen/diagnostics.py b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py index 95735849922..ee21cd3aae0 100644 --- a/homeassistant/components/nederlandse_spoorwegen/diagnostics.py +++ b/homeassistant/components/nederlandse_spoorwegen/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nederlandse Spoorwegen.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 712a020684c..3af39b0b995 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -1,7 +1,5 @@ """Support for Nederlandse Spoorwegen public transport.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index 4036086fe0f..3116cce3926 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -1,7 +1,5 @@ """Support for Ness D8X/D16X devices.""" -from __future__ import annotations - import logging from typing import NamedTuple diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index d9f8d9db3b1..9d300b8c58d 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Ness D8X/D16X alarm panel.""" -from __future__ import annotations - import logging from nessclient import ArmingMode, ArmingState, Client @@ -25,10 +23,12 @@ _LOGGER = logging.getLogger(__name__) ARMING_MODE_TO_STATE = { ArmingMode.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY, ArmingMode.ARMED_HOME: AlarmControlPanelState.ARMED_HOME, - ArmingMode.ARMED_DAY: AlarmControlPanelState.ARMED_AWAY, # no applicable state, fallback to away + # no applicable state, fallback to away + ArmingMode.ARMED_DAY: AlarmControlPanelState.ARMED_AWAY, ArmingMode.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmingMode.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION, - ArmingMode.ARMED_HIGHEST: AlarmControlPanelState.ARMED_AWAY, # no applicable state, fallback to away + # no applicable state, fallback to away + ArmingMode.ARMED_HIGHEST: AlarmControlPanelState.ARMED_AWAY, } diff --git a/homeassistant/components/ness_alarm/binary_sensor.py b/homeassistant/components/ness_alarm/binary_sensor.py index 1058f69e37e..0f92a2f3faa 100644 --- a/homeassistant/components/ness_alarm/binary_sensor.py +++ b/homeassistant/components/ness_alarm/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Ness D8X/D16X zone states - represented as binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/ness_alarm/config_flow.py b/homeassistant/components/ness_alarm/config_flow.py index 1cbc11f3320..14c54e4543c 100644 --- a/homeassistant/components/ness_alarm/config_flow.py +++ b/homeassistant/components/ness_alarm/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ness Alarm integration.""" -from __future__ import annotations - import asyncio import logging from types import MappingProxyType diff --git a/homeassistant/components/ness_alarm/quality_scale.yaml b/homeassistant/components/ness_alarm/quality_scale.yaml new file mode 100644 index 00000000000..1694d2053f9 --- /dev/null +++ b/homeassistant/components/ness_alarm/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: | + Alarm actions (arm/disarm/trigger) currently call client methods directly + without integration-level exception handling. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: + status: todo + comment: Binary sensor initial state should be None (unknown) instead of False. + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Switch from MockClient to proper AsyncMock. + More tests in test_init.py should use mock_config_entry fixture. + + # Gold + devices: + status: done + comment: | + Binary sensors linked to main device via via_device. + Consider improving services to not just pick the first config entry. + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: exempt + comment: No entities need a non-default category. + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: Entities use device name as entity name. + exception-translations: todo + icon-translations: + status: exempt + comment: No entity icons are used. + reconfiguration-flow: todo + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ness_alarm/services.py b/homeassistant/components/ness_alarm/services.py index a20c3b7a5d3..c43a0853478 100644 --- a/homeassistant/components/ness_alarm/services.py +++ b/homeassistant/components/ness_alarm/services.py @@ -1,7 +1,5 @@ """Services for the Ness Alarm integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ATTR_CODE, ATTR_STATE diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index d3cf1dedb9e..1ddc7654d62 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -1,7 +1,5 @@ """Support for Nest devices.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from http import HTTPStatus @@ -73,6 +71,7 @@ from .media_source import ( async_get_media_source_devices, async_get_transcoder, ) +from .services import async_setup_services from .types import DevicesAddedListener, NestConfigEntry, NestData _LOGGER = logging.getLogger(__name__) @@ -117,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) + async_setup_services(hass) return True diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index d55826f7ed0..4eba7e0e815 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -1,7 +1,5 @@ """API for Google Nest Device Access bound to Home Assistant OAuth.""" -from __future__ import annotations - import datetime import logging from typing import cast @@ -29,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) class AsyncConfigEntryAuth(AbstractAuth): - """Provide Google Nest Device Access authentication tied to an OAuth2 based config entry.""" + """Provide Google Nest Device Access auth tied to an OAuth2 config entry.""" def __init__( self, diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 4b5bee127d0..0ab742d27f0 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -1,7 +1,5 @@ """Support for Google Nest SDM Cameras.""" -from __future__ import annotations - from abc import ABC import asyncio from collections.abc import Awaitable, Callable @@ -267,7 +265,10 @@ class NestWebRTCEntity(NestCameraBaseEntity): async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return a placeholder image for WebRTC cameras that don't support snapshots.""" + """Return a placeholder image for WebRTC cameras. + + WebRTC cameras don't support snapshots. + """ return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index cf1e67ad887..8ad088bf668 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,7 +1,6 @@ """Support for Google Nest SDM climate devices.""" -from __future__ import annotations - +from datetime import timedelta from typing import Any, cast from google_nest_sdm.device import Device @@ -69,7 +68,7 @@ FAN_MODE_MAP = { FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} FAN_INV_MODES = list(FAN_INV_MODE_MAP) -MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API +MAX_FAN_DURATION = 43200 # 12 hours is the max in the SDM API MIN_TEMP = 10 MAX_TEMP = 32 MIN_TEMP_RANGE = 1.66667 @@ -346,3 +345,29 @@ class ThermostatEntity(ClimateEntity): raise HomeAssistantError( f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}" ) from err + + async def async_set_fan_timer(self, duration: timedelta) -> None: + """Set a short term fan timer.""" + if not self.supported_features & ClimateEntityFeature.FAN_MODE: + raise HomeAssistantError(f"Entity {self.entity_id} does not support fan") + + if self.hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + f"Cannot turn on fan for {self.entity_id}," + " please set an HVAC mode (e.g. heat/cool) first" + ) + + seconds = int(duration.total_seconds()) + if seconds <= 0 or seconds > MAX_FAN_DURATION: + raise ValueError( + f"Duration {seconds} for {self.entity_id} must be" + f" between 1 and {MAX_FAN_DURATION} seconds" + ) + + trait = self._device.traits[FanTrait.NAME] + try: + await trait.set_timer(FAN_INV_MODE_MAP[FAN_ON], duration=seconds) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} fan timer: {err}" + ) from err diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 0b249db7a4b..d43002e5ac7 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -8,8 +8,6 @@ NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with some overrides to custom steps inserted in the middle of the flow. """ -from __future__ import annotations - from collections.abc import Iterable, Mapping import logging from typing import TYPE_CHECKING, Any @@ -323,7 +321,7 @@ class NestFlowHandler( async def async_step_pubsub_topic_confirm( self, user_input: dict | None = None ) -> ConfigFlowResult: - """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console.""" + """Confirm the Pub/Sub topic is set correctly in Device Access Console.""" if user_input is not None: return await self.async_step_pubsub_subscription() return self.async_show_form( @@ -367,7 +365,8 @@ class NestFlowHandler( else: user_input[CONF_SUBSCRIPTION_NAME] = subscription_name else: - # The user created this subscription themselves so do not delete when removing the integration. + # The user created this subscription themselves so + # do not delete when removing the integration. user_input[CONF_SUBSCRIBER_ID_IMPORTED] = True if not errors: diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 8241b8aa5f8..9108370da3d 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -1,7 +1,5 @@ """Library for extracting device specific information common to entities.""" -from __future__ import annotations - from collections.abc import Mapping from google_nest_sdm.device import Device @@ -90,7 +88,7 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: @callback def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device]: - """Return a mapping of all nest devices by home assistant device id, for all config entries.""" + """Return a mapping of all nest devices by HA device id.""" device_registry = dr.async_get(hass) devices = {} for nest_device_id, device in async_nest_devices(hass).items(): diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index d2d36b6e529..7ce87379e03 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Nest.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py index 345e15b0593..b3b5f7689c6 100644 --- a/homeassistant/components/nest/diagnostics.py +++ b/homeassistant/components/nest/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nest.""" -from __future__ import annotations - from typing import Any from google_nest_sdm import diagnostics diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index 9bb041fce6c..eed45bdc8f8 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -8,6 +8,7 @@ from google_nest_sdm.event import EventMessage, EventType from google_nest_sdm.traits import TraitType from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -42,7 +43,7 @@ ENTITY_DESCRIPTIONS = [ key=EVENT_DOORBELL_CHIME, translation_key="chime", device_class=EventDeviceClass.DOORBELL, - event_types=[EVENT_DOORBELL_CHIME], + event_types=[DoorbellEventType.RING], trait_types=[TraitType.DOORBELL_CHIME], api_event_types=[EventType.DOORBELL_CHIME], ), @@ -80,7 +81,7 @@ async def async_setup_entry( class NestTraitEventEntity(EventEntity): - """Nest doorbell event entity.""" + """Nest event entity for event entity descriptions.""" entity_description: NestEventEntityDescription _attr_has_entity_name = True @@ -113,6 +114,9 @@ class NestTraitEventEntity(EventEntity): # This event is a duplicate message in the same thread return + if event_type == EVENT_DOORBELL_CHIME: + event_type = DoorbellEventType.RING + self._trigger_event( event_type, {"nest_event_id": nest_event_id}, diff --git a/homeassistant/components/nest/icons.json b/homeassistant/components/nest/icons.json new file mode 100644 index 00000000000..1147328d12a --- /dev/null +++ b/homeassistant/components/nest/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_fan_timer": { + "service": "mdi:fan-clock" + } + } +} diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 4c7eb87636c..d46886a6fff 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -16,8 +16,6 @@ For additional background on Nest Camera events see: https://developers.google.com/nest/device-access/api/camera#handle_camera_events """ -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass import datetime @@ -284,16 +282,20 @@ class NestEventMediaStore(EventMediaStore): return devices async def async_remove_orphaned_media(self, now: datetime.datetime) -> None: - """Remove any media files that are orphaned and not referenced by the active event data. + """Remove orphaned media files not referenced by active event data. - The event media store handles garbage collection, but there may be cases where files are - left around or unable to be removed. This is a scheduled event that will also check for - old orphaned files and remove them when the events are not referenced in the active list - of event data. + The event media store handles garbage collection, but + there may be cases where files are left around or unable + to be removed. This is a scheduled event that will also + check for old orphaned files and remove them when the + events are not referenced in the active list of event + data. - Event media files are stored with the format -.suffix. We extract - the list of valid timestamps from the event data and remove any files that are not in that list - or are older than the cutoff time. + Event media files are stored with the format + -.suffix. We extract the list of + valid timestamps from the event data and remove any + files that are not in that list or are older than the + cutoff time. """ _LOGGER.debug("Checking for orphaned media at %s", now) diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 553068bb8b2..ec4d56ba97a 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -1,7 +1,5 @@ """Support for Google Nest SDM sensors.""" -from __future__ import annotations - import logging from google_nest_sdm.device import Device diff --git a/homeassistant/components/nest/services.py b/homeassistant/components/nest/services.py new file mode 100644 index 00000000000..0cb97f5a0a5 --- /dev/null +++ b/homeassistant/components/nest/services.py @@ -0,0 +1,30 @@ +"""Define services for the Nest integration.""" + +import voluptuous as vol + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SERVICE_SET_FAN_TIMER = "set_fan_timer" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Nest integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_FAN_TIMER, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required("duration"): cv.time_period, + }, + func="async_set_fan_timer", + required_features=[ClimateEntityFeature.FAN_MODE], + ) diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml new file mode 100644 index 00000000000..820ed5e1779 --- /dev/null +++ b/homeassistant/components/nest/services.yaml @@ -0,0 +1,12 @@ +set_fan_timer: + target: + entity: + domain: climate + integration: nest + supported_features: + - climate.ClimateEntityFeature.FAN_MODE + fields: + duration: + required: true + selector: + duration: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index e15c7f2dcb7..6ae0d6622b5 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -113,7 +113,7 @@ "state_attributes": { "event_type": { "state": { - "doorbell_chime": "[%key:component::nest::entity::event::chime::name%]" + "ring": "[%key:component::event::entity_component::doorbell::state_attributes::event_type::state::ring%]" } } } @@ -163,5 +163,17 @@ "create_new_topic": "Create new topic" } } + }, + "services": { + "set_fan_timer": { + "description": "Sets the fan to run for a specific duration.", + "fields": { + "duration": { + "description": "The duration the fan should run for.", + "name": "Duration" + } + }, + "name": "Set fan timer" + } } } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 62b99eb9b3e..07997b7fe78 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,7 +1,5 @@ """The Netatmo integration.""" -from __future__ import annotations - import logging import secrets from typing import Any @@ -61,6 +59,8 @@ MAX_WEBHOOK_RETRIES = 3 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = { DATA_PERSONS: {}, DATA_DEVICE_IDS: {}, @@ -162,6 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> b try: await entry.runtime_data.auth.async_addwebhook(webhook_url) _LOGGER.debug("Register Netatmo webhook: %s", webhook_url) + # pylint: disable-next=home-assistant-action-swallowed-exception except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) else: @@ -186,7 +187,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> b else: entry.async_on_unload(async_at_started(hass, register_webhook)) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register(DOMAIN, "register_webhook", register_webhook) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) entry.async_on_unload(entry.add_update_listener(async_config_entry_updated)) diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index 21fbff3fc72..f88f44e38db 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -67,25 +67,11 @@ OPENING_CATEGORY_TO_DEVICE_CLASS: Final[dict[str | None, BinarySensorDeviceClass def get_opening_category(netatmo_device: NetatmoDevice) -> str: - """Helper function to get opening category from Netatmo API raw data.""" + """Helper function to get opening category for doortag.""" - # Iterate through each home in the raw data. - for home in netatmo_device.data_handler.account.raw_data["homes"]: - # Check if the modules list exists for the current home. - if "modules" in home: - # Iterate through each module to find a matching ID. - for module in home["modules"]: - if module["id"] == netatmo_device.device.entity_id: - # We found the matching device. Get its category. - if module.get("category") is not None: - return cast(str, module["category"]) - raise ValueError( - f"Device {netatmo_device.device.entity_id} found, " - "but 'category' is missing in raw data." - ) - - raise ValueError( - f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data." + return ( + getattr(netatmo_device.device, "doortag_category", None) + or DOORTAG_CATEGORY_OTHER ) @@ -103,9 +89,9 @@ OPENING_CATEGORY_TO_KEY: Final[dict[str, str | None]] = { class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Netatmo binary sensor entity.""" - netatmo_name: str | None = ( - None # The name used by Netatmo API for this sensor (exposed feature as attribute) if different than key - ) + # The name used by Netatmo API for this sensor + # (exposed feature as attribute) if different than key + netatmo_name: str | None = None value_fn: Callable[[str], str | bool | None] = lambda x: x @@ -300,8 +286,11 @@ class NetatmoBinarySensor(NetatmoModuleEntity, BinarySensorEntity): self.entity_description = description self._attr_unique_id = f"{self.device.entity_id}-{description.key}" - # Register publishers for the entity if needed (not already done in parent class - weather and air_care) - # We need to keep this here because we have two classes depending on it and we want to avoid adding publishers for all binary sensors + # Register publishers for the entity if needed + # (not already done in parent class - weather and + # air_care). We need to keep this here because we have + # two classes depending on it and we want to avoid + # adding publishers for all binary sensors if self.device.device_category in DEVICE_CATEGORY_BINARY_PUBLISHERS: self._publishers.extend( [ @@ -317,9 +306,11 @@ class NetatmoBinarySensor(NetatmoModuleEntity, BinarySensorEntity): def async_update_callback(self) -> None: """Update the entity's state.""" - # Should be the connectivity (reachable) sensor only here as we have update for opening in its class + # Should be the connectivity (reachable) sensor only + # here as we have update for opening in its class - # Setting reachable sensor, so we just get it directly (backward compatibility to weather binary sensor) + # Setting reachable sensor, so we just get it directly + # (backward compatibility to weather binary sensor) value = getattr(self.device, self.entity_description.key, None) if value is None: diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 288a7664eb1..904bad51c9d 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -1,7 +1,5 @@ """Support for Netatmo/Bubendorff button.""" -from __future__ import annotations - import logging from pyatmo import modules as NaModules @@ -14,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,9 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): ] ) self._attr_unique_id = ( - f"{self.device.entity_id}-{self.device_type}-preferred_position" + f"{self.device.entity_id}" + f"-{device_type_to_str(self.device_type)}" + "-preferred_position" ) @callback diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 9baca23cfa4..97c8932937b 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,6 +1,5 @@ """Support for the Netatmo cameras.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast @@ -11,6 +10,7 @@ from pyatmo.event import Event as NaEvent import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.const import ATTR_PERSONS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -21,7 +21,6 @@ from .const import ( ATTR_CAMERA_LIGHT_MODE, ATTR_EVENT_TYPE, ATTR_PERSON, - ATTR_PERSONS, CAMERA_LIGHT_MODES, CAMERA_TRIGGERS, CONF_URL_SECURITY, @@ -43,6 +42,7 @@ from .const import ( ) from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,9 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): Camera.__init__(self) super().__init__(netatmo_device) - self._attr_unique_id = f"{netatmo_device.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{netatmo_device.device.entity_id}-{device_type_to_str(self.device_type)}" + ) self._light_state = None self._publishers.extend( @@ -167,7 +169,8 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): if event_type in [EVENT_TYPE_DISCONNECTION, EVENT_TYPE_OFF]: _LOGGER.debug( - "Camera %s has received %s event, turning off and idleing streaming", + "Camera %s has received %s event," + " turning off and idleing streaming", data["camera_id"], event_type, ) @@ -175,7 +178,9 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self._monitoring = False elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]: _LOGGER.debug( - "Camera %s has received %s event, turning on and enabling streaming if applicable", + "Camera %s has received %s event," + " turning on and enabling streaming" + " if applicable", data["camera_id"], event_type, ) @@ -291,7 +296,10 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): def get_video_url(self, video_id: str) -> str: """Get video url.""" if self.device.is_local: - return f"{self.device.local_url}/vod/{video_id}/files/{self._quality}/index.m3u8" + return ( + f"{self.device.local_url}/vod/{video_id}" + f"/files/{self._quality}/index.m3u8" + ) return f"{self.device.vpn_url}/vod/{video_id}/files/{self._quality}/index.m3u8" def fetch_person_ids(self, persons: list[str | None]) -> list[str]: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 3d3eb8d449f..2592fd28568 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,6 +1,5 @@ """Support for Netatmo Smart thermostats.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast @@ -55,6 +54,7 @@ from .const import ( ) from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -220,7 +220,9 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if self.device_type is NA_THERM: self._attr_hvac_modes.append(HVACMode.OFF) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_added_to_hass(self) -> None: """Entity created.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 812f8fbb3c0..881b25d21b2 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Netatmo.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index e8812407e47..1e6546ef3a6 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -39,14 +39,15 @@ API_SCOPES_EXCLUDED_FROM_CLOUD = [ "write_mhs1", ] -NETATMO_CREATE_BATTERY = "netatmo_create_battery" NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" +NETATMO_CREATE_CLIMATE_BATTERY_SENSOR = "netatmo_create_climate_battery_sensor" NETATMO_CREATE_COVER = "netatmo_create_cover" NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR = "netatmo_create_connectivity_binary_sensor" NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" +NETATMO_CREATE_LEGACY_SENSOR = "netatmo_create_legacy_sensor" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_OPENING_BINARY_SENSOR = "netatmo_create_opening_binary_sensor" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" @@ -91,7 +92,6 @@ ATTR_HOME_ID = "home_id" ATTR_HOME_NAME = "home_name" ATTR_IS_KNOWN = "is_known" ATTR_PERSON = "person" -ATTR_PERSONS = "persons" ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index eafc573829d..35d55edcef3 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -1,7 +1,5 @@ """Support for Netatmo/Bubendorff covers.""" -from __future__ import annotations - import logging from typing import Any @@ -20,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -72,7 +71,9 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity): }, ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 767582249e1..8d63a2016f3 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,6 +1,5 @@ """The Netatmo data handler.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import deque from dataclasses import dataclass @@ -32,14 +31,15 @@ from .const import ( DATA_SCHEDULES, DOMAIN, MANUFACTURER, - NETATMO_CREATE_BATTERY, NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, NETATMO_CREATE_COVER, NETATMO_CREATE_FAN, + NETATMO_CREATE_LEGACY_SENSOR, NETATMO_CREATE_LIGHT, NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, @@ -371,13 +371,14 @@ class NetatmoDataHandler: NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, - NETATMO_CREATE_SENSOR, + NETATMO_CREATE_LEGACY_SENSOR, ], - NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], + NetatmoDeviceCategory.meter: [NETATMO_CREATE_LEGACY_SENSOR], NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], NetatmoDeviceCategory.opening: [ NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, NETATMO_CREATE_OPENING_BINARY_SENSOR, + NETATMO_CREATE_SENSOR, ], } for module in home.modules.values(): @@ -430,7 +431,7 @@ class NetatmoDataHandler: if module.device_category is NetatmoDeviceCategory.climate: async_dispatcher_send( self.hass, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, NetatmoDevice( self, module, diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 2673ebf8e05..c71bba59b5c 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Netatmo.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index 50f58ab1891..a3b4f032714 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Netatmo.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 2d12631a3db..fe3aeb15f51 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -1,7 +1,5 @@ """Base class for Netatmo entities.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any, cast @@ -140,6 +138,8 @@ class NetatmoRoomEntity(NetatmoDeviceEntity): if device := registry.async_get_device( identifiers={(DOMAIN, self.device.entity_id)} ): + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data self.hass.data[DOMAIN][DATA_DEVICE_IDS][self.device.entity_id] = device.id @property diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index aefb47a995b..6c9665ca475 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -1,7 +1,5 @@ """Support for Netatmo/Bubendorff fans.""" -from __future__ import annotations - import logging from typing import Final @@ -15,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -64,7 +63,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index 026f3f916f5..77a54261517 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -1,10 +1,18 @@ """Helper for Netatmo integration.""" -from __future__ import annotations - -from dataclasses import dataclass +from dataclasses import dataclass, field from uuid import UUID, uuid4 +from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType + + +def device_type_to_str(device_type: NetatmoDeviceType) -> str: + """Convert a device type to a string. + + Used to generate backwards compatible unique ids. + """ + return f"{type(device_type).__name__}.{device_type}" + @dataclass class NetatmoArea: @@ -17,4 +25,4 @@ class NetatmoArea: lon_sw: float mode: str show_on_map: bool - uuid: UUID = uuid4() + uuid: UUID = field(default_factory=uuid4) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index cd7a688db41..e4644f56dcb 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -1,7 +1,5 @@ """Support for the Netatmo camera lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index aeb4ffa0c55..6d6aea230f1 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.2.3"] + "requirements": ["pyatmo==9.4.0"] } diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index f92214c90f5..d01e6806cc8 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -1,6 +1,5 @@ """Netatmo Media Source Implementation.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime as dt import logging diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index ec7d801a4dd..9898d331b31 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,6 +1,5 @@ """Support for the Netatmo climate schedule selector.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -99,12 +98,11 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - self._attr_current_option = ( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ) - ).name - self.async_write_ha_state() + if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][ + self.home.entity_id + ].get(data["schedule_id"]): + self._attr_current_option = schedule.name + self.async_write_ha_state() async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 058d948da62..8917625b75b 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,14 +1,14 @@ """Support for the Netatmo sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +from functools import partial import logging -from typing import Any, cast +from typing import Any, Final, cast import pyatmo from pyatmo.modules import PublicWeatherArea +from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory from homeassistant.components.sensor import ( SensorDeviceClass, @@ -41,11 +41,14 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + CONF_URL_CONTROL, CONF_URL_ENERGY, CONF_URL_PUBLIC_WEATHER, + CONF_URL_SECURITY, CONF_WEATHER_AREAS, DOMAIN, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, + NETATMO_CREATE_LEGACY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SENSOR, NETATMO_CREATE_WEATHER_SENSOR, @@ -123,11 +126,27 @@ def process_wifi(strength: StateType) -> str | None: class NetatmoSensorEntityDescription(SensorEntityDescription): """Describes Netatmo sensor entity.""" - netatmo_name: str + # For legacy sensors netatmo_name is set and is used as + # the translation_key! Legacy sensors are: weather, + # climate, switch and meter sensors, as they were the + # first ones implemented. For new sensors, + # translation_key should be set explicitly on key and + # netatmo_name should be used only to retrieve the value + # from the device. If the netatmo_name is not set, the + # key is used to retrieve the value from the device. + netatmo_name: str | None = None + # Mark sensors whose last known native_value may be + # retained when fresh data is unavailable. This is + # intended for sensors where the last reported value + # remains useful, such as battery level or a last known + # state. This flag does not by itself keep the entity + # available; the entity may still become unavailable + # when the device is unreachable. + is_sticky: bool | None = None value_fn: Callable[[StateType], StateType] = lambda x: x -SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( +NETATMO_WEATHER_SENSOR_DESCRIPTIONS: Final[list[NetatmoSensorEntityDescription]] = [ NetatmoSensorEntityDescription( key="temperature", netatmo_name="temperature", @@ -286,8 +305,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), -) -SENSOR_TYPES_KEYS = [desc.key for desc in SENSOR_TYPES] +] @dataclass(frozen=True, kw_only=True) @@ -383,14 +401,73 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ ), ) -BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription( - key="battery", - netatmo_name="battery", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, -) +NETATMO_CLIMATE_BATTERY_SENSOR_DESCRIPTIONS: Final[ + list[NetatmoSensorEntityDescription] +] = [ + NetatmoSensorEntityDescription( + key="battery", + netatmo_name="battery", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ) +] + +NETATMO_OPENING_SENSOR_DESCRIPTIONS: Final[list[NetatmoSensorEntityDescription]] = [ + NetatmoSensorEntityDescription( + key="battery", + netatmo_name="battery", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + is_sticky=True, + ), + NetatmoSensorEntityDescription( + key="rf_status", + netatmo_name="rf_strength", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=process_rf, + ), +] + +DEVICE_CATEGORY_CLIMATE_BATTERY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.climate: NETATMO_CLIMATE_BATTERY_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_NEW_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.opening: NETATMO_OPENING_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_WEATHER_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.air_care: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.weather: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, +} + +# Duplicate for meter, climate, switch sensors for legacy reasons +# (as originally weather definitions reused - target for future simplification) +DEVICE_CATEGORY_LEGACY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]] +] = { + NetatmoDeviceCategory.meter: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.switch: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.climate: NETATMO_WEATHER_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_SENSOR_URLS: Final[dict[NetatmoDeviceCategory, str]] = { + NetatmoDeviceCategory.climate: CONF_URL_ENERGY, + NetatmoDeviceCategory.meter: CONF_URL_ENERGY, + NetatmoDeviceCategory.opening: CONF_URL_SECURITY, + NetatmoDeviceCategory.switch: CONF_URL_CONTROL, +} async def async_setup_entry( @@ -401,46 +478,76 @@ async def async_setup_entry( """Set up the Netatmo sensor platform.""" @callback - def _create_battery_entity(netatmo_device: NetatmoDevice) -> None: - if not hasattr(netatmo_device.device, "battery"): + def _create_base_sensor_entity( + sensorClass: type[NetatmoBaseSensor], + descriptions: dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]], + netatmo_device: NetatmoDevice, + ) -> None: + """Create sensor entities for a Netatmo device.""" + + if netatmo_device.device.device_category is None: return - entity = NetatmoClimateBatterySensor(netatmo_device) - async_add_entities([entity]) - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_battery_entity) - ) - - @callback - def _create_weather_sensor_entity(netatmo_device: NetatmoDevice) -> None: - async_add_entities( - NetatmoWeatherSensor(netatmo_device, description) - for description in SENSOR_TYPES - if description.netatmo_name in netatmo_device.device.features + descriptions_to_add = descriptions.get( + netatmo_device.device.device_category, [] ) - entry.async_on_unload( - async_dispatcher_connect( - hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_sensor_entity - ) - ) + entities: list[NetatmoBaseSensor] = [] - @callback - def _create_sensor_entity(netatmo_device: NetatmoDevice) -> None: - _LOGGER.debug( - "Adding %s sensor %s", - netatmo_device.device.device_category, - netatmo_device.device.name, - ) - async_add_entities( - NetatmoSensor(netatmo_device, description) - for description in SENSOR_TYPES - if description.key in netatmo_device.device.features - ) + # Create sensors for module + for description in descriptions_to_add: + if description.netatmo_name is None: + feature_check = description.key + else: + feature_check = description.netatmo_name + if feature_check in netatmo_device.device.features: + _LOGGER.debug( + 'Adding key = "%s" / netatmo_name = "%s" sensor for device %s', + description.key, + description.netatmo_name, + netatmo_device.device.name, + ) + entities.append( + sensorClass( + netatmo_device, + description, + ) + ) - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity) - ) + if entities: + async_add_entities(entities) + + sensor_subscriptions = [ + ( + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, + NetatmoClimateBatterySensor, + DEVICE_CATEGORY_CLIMATE_BATTERY_SENSORS, + ), + ( + NETATMO_CREATE_SENSOR, + NetatmoSensor, + DEVICE_CATEGORY_NEW_SENSORS, + ), + ( + NETATMO_CREATE_WEATHER_SENSOR, + NetatmoWeatherSensor, + DEVICE_CATEGORY_WEATHER_SENSORS, + ), + ( + NETATMO_CREATE_LEGACY_SENSOR, + NetatmoLegacySensor, + DEVICE_CATEGORY_LEGACY_SENSORS, + ), + ] + + for signal, sensor_class, descriptions in sensor_subscriptions: + entry.async_on_unload( + async_dispatcher_connect( + hass, + signal, + partial(_create_base_sensor_entity, sensor_class, descriptions), + ) + ) @callback def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: @@ -448,9 +555,14 @@ async def async_setup_entry( msg = f"No climate type found for this room: {netatmo_device.room.name}" _LOGGER.debug(msg) return + + descriptions_to_add = DEVICE_CATEGORY_LEGACY_SENSORS.get( + NetatmoDeviceCategory.climate, [] + ) + async_add_entities( NetatmoRoomSensor(netatmo_device, description) - for description in SENSOR_TYPES + for description in descriptions_to_add if description.key in netatmo_device.room.features ) @@ -518,7 +630,55 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): +class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): + """Implementation of a Netatmo sensor.""" + + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize the sensor.""" + + # To prevent exception about missing URL we need to set it explicitly + if netatmo_device.device.device_category is not None: + if ( + DEVICE_CATEGORY_SENSOR_URLS.get(netatmo_device.device.device_category) + is not None + ): + self._attr_configuration_url = DEVICE_CATEGORY_SENSOR_URLS[ + netatmo_device.device.device_category + ] + + super().__init__(netatmo_device, **kwargs) + self.entity_description = description + + # Legacy value retrieval for weather, climate, switch + # and meter sensors to prevent breaking changes, as they + # were the first ones implemented. + @callback + def async_update_callback(self) -> None: + """Update the entity's state (the legacy way).""" + # Keep the last known value for these legacy sensors when the device is + # unreachable to preserve the historical behavior expected by existing entities. + if not self.device.reachable: + if self.available: + self._attr_available = False + return + + if (state := getattr(self.device, self.entity_description.key)) is None: + return + + self._attr_available = True + self._attr_native_value = state + + self.async_write_ha_state() + + +class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor): """Implementation of a Netatmo weather/home coach sensor.""" entity_description: NetatmoSensorEntityDescription @@ -529,7 +689,7 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): description: NetatmoSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) + super().__init__(netatmo_device, description=description) self.entity_description = description self._attr_translation_key = description.netatmo_name self._attr_unique_id = f"{self.device.entity_id}-{description.key}" @@ -539,14 +699,22 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): """Return True if entity is available.""" return ( self.device.reachable - or getattr(self.device, self.entity_description.netatmo_name) is not None + or getattr( + self.device, + self.entity_description.netatmo_name or self.entity_description.key, + ) + is not None ) @callback def async_update_callback(self) -> None: """Update the entity's state.""" value = cast( - StateType, getattr(self.device, self.entity_description.netatmo_name) + StateType, + getattr( + self.device, + self.entity_description.netatmo_name or self.entity_description.key, + ), ) if value is not None: value = self.entity_description.value_fn(value) @@ -554,29 +722,60 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): self.async_write_ha_state() -class NetatmoClimateBatterySensor(NetatmoModuleEntity, SensorEntity): - """Implementation of a Netatmo sensor.""" +class NetatmoLegacySensor(NetatmoBaseSensor): + """Implementation of a Netatmo legacy sensor.""" + + # Legacy sensors are sensors that were implemented + # before the refactor (like climate, meter and switch) + # and that still use the old way (weather style) of + # retrieving values from the device, entity_description: NetatmoSensorEntityDescription - device: pyatmo.modules.NRV - _attr_configuration_url = CONF_URL_ENERGY - def __init__(self, netatmo_device: NetatmoDevice) -> None: + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) - self.entity_description = BATTERY_SENSOR_DESCRIPTION + super().__init__(netatmo_device, description=description) + + self.entity_description = description self._publishers.extend( [ { "name": HOME, - "home_id": netatmo_device.device.home.entity_id, + "home_id": self.home.entity_id, SIGNAL_NAME: netatmo_device.signal_name, }, ] ) - self._attr_unique_id = f"{netatmo_device.parent_id}-{self.device.entity_id}-{self.entity_description.key}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device.entity_id}-{description.key}" + ) + + +class NetatmoClimateBatterySensor(NetatmoLegacySensor): + """Implementation of a Netatmo Climate Battery sensor.""" + + entity_description: NetatmoSensorEntityDescription + device: pyatmo.modules.NRV + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(netatmo_device, description=description) + + self._attr_unique_id = ( + f"{netatmo_device.parent_id}" + f"-{self.device.entity_id}" + f"-{self.entity_description.key}" + ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, netatmo_device.parent_id)}, name=netatmo_device.device.name, @@ -595,13 +794,13 @@ class NetatmoClimateBatterySensor(NetatmoModuleEntity, SensorEntity): self._attr_available = True self._attr_native_value = self.device.battery + self.async_write_ha_state() -class NetatmoSensor(NetatmoModuleEntity, SensorEntity): - """Implementation of a Netatmo sensor.""" +class NetatmoSensor(NetatmoBaseSensor): + """Implementation of a Netatmo refactored sensor.""" entity_description: NetatmoSensorEntityDescription - _attr_configuration_url = CONF_URL_ENERGY def __init__( self, @@ -609,36 +808,49 @@ class NetatmoSensor(NetatmoModuleEntity, SensorEntity): description: NetatmoSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(netatmo_device) + super().__init__(netatmo_device, description=description) self.entity_description = description + self._attr_translation_key = description.netatmo_name + self._attr_unique_id = f"{self.device.entity_id}-{description.key}" self._publishers.extend( [ { - "name": HOME, + "name": self.home.entity_id, "home_id": self.home.entity_id, SIGNAL_NAME: netatmo_device.signal_name, }, ] ) - self._attr_unique_id = ( - f"{self.device.entity_id}-{self.device.entity_id}-{description.key}" - ) - + # New sensor implementation optional netatmo_name to + # retrieve value from device, if not set key is used. + # Value is set unavailable if device is not reachable + # except is_sticky, otherwise it is set to the + # processed value @callback def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: if self.available: self._attr_available = False - return + if not self.entity_description.is_sticky: + self._attr_native_value = None + else: + if self.entity_description.netatmo_name is None: + raw_value = getattr(self.device, self.entity_description.key, None) + else: + raw_value = getattr( + self.device, self.entity_description.netatmo_name, None + ) - if (state := getattr(self.device, self.entity_description.key)) is None: - return + if raw_value is not None: + value = self.entity_description.value_fn(raw_value) + else: + value = None - self._attr_available = True - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 4a37acac425..0c0a1570a7f 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -1,7 +1,5 @@ """Support for Netatmo/BTicino/Legrande switches.""" -from __future__ import annotations - import logging from typing import Any @@ -15,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -60,7 +59,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): }, ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) self._attr_is_on = self.device.on @callback diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 7a560854691..79de83d75dd 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,10 +1,11 @@ """The Netatmo integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from aiohttp.web import Request -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -13,7 +14,6 @@ from .const import ( ATTR_FACE_URL, ATTR_HOME_ID, ATTR_IS_KNOWN, - ATTR_PERSONS, DATA_DEVICE_IDS, DATA_PERSONS, DEFAULT_PERSON, diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 41adcd2095e..1b4044a3ba9 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -1,7 +1,5 @@ """Support gathering system information of hosts which are running netdata.""" -from __future__ import annotations - import logging from netdata import Netdata diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index cbde5ccccad..afc32d4c5be 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -1,7 +1,5 @@ """Support for Netgear routers.""" -from __future__ import annotations - import logging from homeassistant.const import CONF_PORT, CONF_SSL diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 3386d07cc6d..4dd0358e556 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Netgear integration.""" -from __future__ import annotations - import logging from typing import Any, cast from urllib.parse import urlparse diff --git a/homeassistant/components/netgear/coordinator.py b/homeassistant/components/netgear/coordinator.py index 9ee6b7b7342..9c508bb251d 100644 --- a/homeassistant/components/netgear/coordinator.py +++ b/homeassistant/components/netgear/coordinator.py @@ -1,7 +1,5 @@ """Models for the Netgear integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 24625a80986..838e4b50d3b 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -1,7 +1,5 @@ """Support for Netgear routers.""" -from __future__ import annotations - import logging from homeassistant.components.device_tracker import ScannerEntity diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py index 3ba7b76262e..fffeb16f36c 100644 --- a/homeassistant/components/netgear/entity.py +++ b/homeassistant/components/netgear/entity.py @@ -1,7 +1,5 @@ """Represent the Netgear router and its devices.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index aa7664a77a8..3b07dc237b3 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -1,7 +1,7 @@ { "domain": "netgear", "name": "NETGEAR", - "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], + "codeowners": ["@Quentame", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear", "integration_type": "hub", diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 23ee47e7a2d..1dc86b150e4 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -1,7 +1,5 @@ """Represent the Netgear router and its devices.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -207,7 +205,9 @@ class NetgearRouter: if not self.devices.get(device_mac): new_device = True - # ntg_device is a namedtuple from the collections module that needs conversion to a dict through ._asdict method + # ntg_device is a namedtuple from the collections + # module that needs conversion to a dict through + # ._asdict method self.devices[device_mac] = ntg_device._asdict() self.devices[device_mac]["mac"] = device_mac self.devices[device_mac]["last_seen"] = now diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 5372ae70bb5..743b5fd3f0e 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -1,7 +1,5 @@ """Support for Netgear routers.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime @@ -372,7 +370,11 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor): """Initialize a Netgear device.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.router.serial_number}-{entity_description.key}-{entity_description.index}" + self._attr_unique_id = ( + f"{coordinator.router.serial_number}" + f"-{entity_description.key}" + f"-{entity_description.index}" + ) self._value: StateType | date | datetime | Decimal = None self.async_update_device() diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 15973348a8e..50151e2130a 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -1,7 +1,5 @@ """Update entities for Netgear devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 881e34d4390..3abda49bc29 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Netgear LTE binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py index 8eacb693089..be059dcf230 100644 --- a/homeassistant/components/netgear_lte/config_flow.py +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Netgear LTE integration.""" -from __future__ import annotations - from typing import Any from aiohttp.cookiejar import CookieJar diff --git a/homeassistant/components/netgear_lte/coordinator.py b/homeassistant/components/netgear_lte/coordinator.py index 7bcefca6403..88ffea3d88b 100644 --- a/homeassistant/components/netgear_lte/coordinator.py +++ b/homeassistant/components/netgear_lte/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Netgear LTE integration.""" -from __future__ import annotations - from datetime import timedelta from eternalegypt.eternalegypt import Error, Information, Modem diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 8788c00ac75..8025b29e4db 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -1,7 +1,5 @@ """Support for Netgear LTE notifications.""" -from __future__ import annotations - from typing import Any import eternalegypt @@ -58,5 +56,6 @@ class NetgearNotifyService(BaseNotificationService): for target in targets: try: await self.modem.sms(target, message) + # pylint: disable-next=home-assistant-action-swallowed-exception except eternalegypt.Error: LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 49301267d9d..5a6ff73cc5b 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -1,7 +1,5 @@ """Support for Netgear LTE sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 8ab912c7a97..ed80d0e2070 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -1,7 +1,5 @@ """The Netio switch component.""" -from __future__ import annotations - from collections import namedtuple from datetime import timedelta import logging diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index dd5344faa56..4a068d88174 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -1,7 +1,5 @@ """The Network Configuration integration.""" -from __future__ import annotations - from ipaddress import IPv4Address, IPv6Address, ip_interface import logging from pathlib import Path @@ -10,7 +8,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from homeassistant.loader import bind_hass from homeassistant.util import package from . import util @@ -42,7 +39,6 @@ def _check_docker_without_host_networking() -> bool: return False -@bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" network: Network = await async_get_network(hass) @@ -55,7 +51,6 @@ def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]: return async_get_loaded_network(hass).adapters -@bind_hass async def async_get_source_ip( hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED ) -> str: @@ -90,7 +85,6 @@ async def async_get_source_ip( return source_ip if source_ip in all_ipv4s else all_ipv4s[0] -@bind_hass async def async_get_enabled_source_ips( hass: HomeAssistant, ) -> list[IPv4Address | IPv6Address]: @@ -128,7 +122,6 @@ def async_only_default_interface_enabled(adapters: list[Adapter]) -> bool: ) -@bind_hass async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Address]: """Return a set of broadcast addresses.""" broadcast_addresses: set[IPv4Address] = {IPv4Address(IPV4_BROADCAST_ADDR)} diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index d8c8858be72..4e313392dc4 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -1,7 +1,5 @@ """Constants for the network integration.""" -from __future__ import annotations - from typing import Final import voluptuous as vol diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py index 93d34e92302..0a637c38178 100644 --- a/homeassistant/components/network/models.py +++ b/homeassistant/components/network/models.py @@ -1,7 +1,5 @@ """Models helper class for the network integration.""" -from __future__ import annotations - from typing import TypedDict diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index db25bedcaea..c2fe6a278f7 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -1,7 +1,5 @@ """Network helper class for the network integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index 88f4c1f913e..bb996bff67e 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -1,7 +1,5 @@ """Network helper class for the network integration.""" -from __future__ import annotations - from ipaddress import IPv4Address, IPv6Address, ip_address import logging import socket diff --git a/homeassistant/components/network/websocket.py b/homeassistant/components/network/websocket.py index 6d3b088bacc..91e89f62364 100644 --- a/homeassistant/components/network/websocket.py +++ b/homeassistant/components/network/websocket.py @@ -1,7 +1,5 @@ """The Network Configuration integration websocket commands.""" -from __future__ import annotations - from contextlib import suppress from typing import Any diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 4d763263469..e49e768c865 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring a Neurio energy sensor.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index bc36fc35bd8..817e79c619f 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -1,7 +1,5 @@ """Support for Nexia / Trane XL thermostats.""" -from __future__ import annotations - from typing import Any from nexia.const import ( diff --git a/homeassistant/components/nexia/coordinator.py b/homeassistant/components/nexia/coordinator.py index 85e784218f4..26d11601f0c 100644 --- a/homeassistant/components/nexia/coordinator.py +++ b/homeassistant/components/nexia/coordinator.py @@ -1,7 +1,5 @@ """Component to embed nexia devices.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/nexia/diagnostics.py b/homeassistant/components/nexia/diagnostics.py index 877aad30cb0..7cea39c1771 100644 --- a/homeassistant/components/nexia/diagnostics.py +++ b/homeassistant/components/nexia/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for nexia.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py index 05d9e5b4614..8e8cbbf4069 100644 --- a/homeassistant/components/nexia/number.py +++ b/homeassistant/components/nexia/number.py @@ -1,7 +1,5 @@ """Support for Nexia / Trane XL Thermostats.""" -from __future__ import annotations - from nexia.thermostat import NexiaThermostat from homeassistant.components.number import NumberEntity diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 648b5dc3eeb..dde7f531bed 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -1,7 +1,5 @@ """Support for Nexia / Trane XL Thermostats.""" -from __future__ import annotations - from nexia.const import UNIT_CELSIUS from nexia.thermostat import NexiaThermostat diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index bf1495217a7..bbf585c0836 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -1,7 +1,5 @@ """Support for Nexia switches.""" -from __future__ import annotations - from collections.abc import Iterable import functools as ft from typing import Any diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 168488e1940..b21853618ce 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -24,6 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) if coordinator is None: coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN][coordinator_key] = coordinator coordinator.add_stop_route(entry_stop, entry.data[CONF_ROUTE]) diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 2e184e13fc7..f69a8a26011 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -1,7 +1,5 @@ """NextBus sensor.""" -from __future__ import annotations - import logging from typing import cast @@ -31,6 +29,8 @@ async def async_setup_entry( entry_stop = config.data[CONF_STOP] coordinator_key = f"{entry_agency}-{entry_stop}" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(coordinator_key) async_add_entities( diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index f51796e6c7f..b1feb69e878 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -1,7 +1,5 @@ """Summary binary data from Nextcoud.""" -from __future__ import annotations - from typing import Final from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index b67b4ff5882..99af4101dc9 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Nextcloud integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 63b31f0edde..7b53b5d6d50 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -1,7 +1,5 @@ """Summary data from Nextcoud.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index b991b001117..884e8a69e7e 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -1,7 +1,5 @@ """Update data from Nextcoud.""" -from __future__ import annotations - from homeassistant.components.update import UpdateEntity, UpdateEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index acc9504988d..221be4ffd82 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -1,7 +1,5 @@ """The NextDNS component.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 5107fcd00d6..9505a7dfad9 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the NextDNS service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 5c78d794120..a61209a6589 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -1,7 +1,5 @@ """Support for the NextDNS service.""" -from __future__ import annotations - from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError from nextdns import ApiError, InvalidApiKeyError diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 9401a735935..9a1fa6e27ec 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for NextDNS.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 44470fe0070..5d83eafecae 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -1,7 +1,5 @@ """NextDns coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/nextdns/diagnostics.py b/homeassistant/components/nextdns/diagnostics.py index 31c0b7f0ca8..60f9d74e492 100644 --- a/homeassistant/components/nextdns/diagnostics.py +++ b/homeassistant/components/nextdns/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for NextDNS.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 1b43f7c9c25..1fbbab4f369 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -1,7 +1,5 @@ """Support for the NextDNS service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 48151eb185c..e733af59908 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -1,7 +1,5 @@ """Support for the NextDNS service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/nextdns/system_health.py b/homeassistant/components/nextdns/system_health.py index 09c13f0580e..1e6704e1576 100644 --- a/homeassistant/components/nextdns/system_health.py +++ b/homeassistant/components/nextdns/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from nextdns.const import API_ENDPOINT diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 9a2e7da2b0a..aae4b9d43c3 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,7 +1,7 @@ """The NFAndroidTV integration.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, Platform.NOTIFY, DOMAIN, - dict(entry.data), + {CONF_NAME: entry.title, **entry.data}, hass.data[DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index ccb882509f6..38f06e65821 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -1,7 +1,5 @@ """Config flow for NFAndroidTV integration.""" -from __future__ import annotations - import logging from typing import Any @@ -26,24 +24,42 @@ class NFAndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_NAME: user_input[CONF_NAME]} - ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) if not (error := await self._async_try_connect(user_input[CONF_HOST])): return self.async_create_entry( - title=user_input[CONF_NAME], + title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})", data=user_input, ) errors["base"] = error return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - } + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for Notification for Android TV / Fire TV.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match(user_input) + if not (error := await self._async_try_connect(user_input[CONF_HOST])): + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + errors["base"] = error + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + suggested_values=user_input or entry.data, ), + description_placeholders={CONF_NAME: entry.title}, errors=errors, ) diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py index cd4b99d0981..f47a75f735b 100644 --- a/homeassistant/components/nfandroidtv/const.py +++ b/homeassistant/components/nfandroidtv/const.py @@ -27,7 +27,6 @@ ATTR_IMAGE_PATH = "path" ATTR_IMAGE_USERNAME = "username" ATTR_IMAGE_PASSWORD = "password" ATTR_IMAGE_AUTH = "auth" -ATTR_ICON = "icon" # Attributes contained in icon ATTR_ICON_URL = "url" ATTR_ICON_PATH = "path" diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index c1c19a600b9..abf20fea141 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -1,7 +1,5 @@ """Notifications for Android TV notification service.""" -from __future__ import annotations - from io import BufferedReader import logging from typing import Any @@ -17,7 +15,7 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, BaseNotificationService, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import ATTR_ICON, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -27,7 +25,6 @@ from .const import ( ATTR_COLOR, ATTR_DURATION, ATTR_FONTSIZE, - ATTR_ICON, ATTR_ICON_AUTH, ATTR_ICON_AUTH_DIGEST, ATTR_ICON_PASSWORD, @@ -85,8 +82,11 @@ class NFAndroidTVNotificationService(BaseNotificationService): try: self.notify = Notifications(self.host) except ConnectError as err: + _LOGGER.debug("Full exception:", exc_info=True) raise HomeAssistantError( - f"Failed to connect to host: {self.host}" + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={CONF_HOST: self.host}, ) from err data: dict | None = kwargs.get(ATTR_DATA) @@ -105,6 +105,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): duration = int( data.get(ATTR_DURATION, Notifications.DEFAULT_DURATION) ) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: _LOGGER.warning( "Invalid duration-value: %s", data.get(ATTR_DURATION) @@ -159,8 +160,8 @@ class NFAndroidTVNotificationService(BaseNotificationService): auth=imagedata.get(ATTR_IMAGE_AUTH), ) else: + # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - "Invalid image provided", translation_domain=DOMAIN, translation_key="invalid_notification_image", translation_placeholders={"type": type(imagedata).__name__}, @@ -181,8 +182,8 @@ class NFAndroidTVNotificationService(BaseNotificationService): auth=icondata.get(ATTR_ICON_AUTH), ) else: + # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( - "Invalid Icon provided", translation_domain=DOMAIN, translation_key="invalid_notification_icon", translation_placeholders={"type": type(icondata).__name__}, @@ -202,7 +203,12 @@ class NFAndroidTVNotificationService(BaseNotificationService): image_file=image_file, ) except ConnectError as err: - raise HomeAssistantError(f"Failed to connect to host: {self.host}") from err + _LOGGER.debug("Full exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={CONF_HOST: self.host}, + ) from err def load_file( self, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index 531a6af1617..5cf31fa863a 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -1,13 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::nfandroidtv::config::step::user::data_description::host%]" + }, + "description": "Reconfigure {name}" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -21,6 +31,9 @@ } }, "exceptions": { + "connection_failed": { + "message": "Failed to connect to host: {host}" + }, "invalid_notification_icon": { "message": "Invalid icon data provided. Got {type}" }, diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 6fc5ea49d97..0082eee755c 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump integration.""" -from __future__ import annotations - from nibe.connection import Connection from nibe.connection.modbus import Modbus from nibe.connection.nibegw import NibeGW, ProductInfo diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 4245a1c7652..ee7c0d301f1 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump binary sensors.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 3d63da77f16..191c0f70933 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump sensors.""" -from __future__ import annotations - from nibe.coil_groups import UNIT_COILGROUPS, UnitCoilGroup from nibe.exceptions import CoilNotFoundException diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 19dcca2362a..1192b680c65 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump climate.""" -from __future__ import annotations - from datetime import date from typing import Any diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 58e8d02a634..cda055186ce 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nibe Heat Pump integration.""" -from __future__ import annotations - from typing import Any from nibe.connection.modbus import Modbus diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index edd0439de54..5318d45aaeb 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump coordinator.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable, Iterable @@ -147,7 +145,9 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): await self.connection.write_coil(data) except WriteDeniedException: LOGGER.debug( - "Denied write on address %d with value %s. This is likely already the value the pump has internally", + "Denied write on address %d with value %s." + " This is likely already the value" + " the pump has internally", coil.address, value, ) diff --git a/homeassistant/components/nibe_heatpump/entity.py b/homeassistant/components/nibe_heatpump/entity.py index 3cbc8af32a3..4b9b62a0bdb 100644 --- a/homeassistant/components/nibe_heatpump/entity.py +++ b/homeassistant/components/nibe_heatpump/entity.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump coordinator.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.helpers.entity import async_generate_entity_id diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index b1857067df8..a7b11041849 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump numbers.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity, NumberMode diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index fa0c936ec5c..38da54849c7 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump select.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 92afbbf4bcd..a8157605921 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump sensors.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.components.sensor import ( diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 42a104e1f30..8c6f3257767 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump switch.""" -from __future__ import annotations - from typing import Any from nibe.coil import Coil, CoilData diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index 72be4503fe8..dcac30aa903 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -1,7 +1,5 @@ """The Nibe Heat Pump sensors.""" -from __future__ import annotations - from datetime import date from nibe.coil import Coil diff --git a/homeassistant/components/nice_go/__init__.py b/homeassistant/components/nice_go/__init__.py index a8d2bd71ac4..c0bcc56c4d8 100644 --- a/homeassistant/components/nice_go/__init__.py +++ b/homeassistant/components/nice_go/__init__.py @@ -1,7 +1,5 @@ """The Nice G.O. integration.""" -from __future__ import annotations - import logging from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index 291d4221d6c..de3f28b0a70 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nice G.O. integration.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import datetime import logging diff --git a/homeassistant/components/nice_go/const.py b/homeassistant/components/nice_go/const.py index c02bcb3c234..d3e7434011c 100644 --- a/homeassistant/components/nice_go/const.py +++ b/homeassistant/components/nice_go/const.py @@ -8,7 +8,6 @@ DOMAIN = "nice_go" # Configuration CONF_SITE_ID = "site_id" -CONF_DEVICE_ID = "device_id" CONF_REFRESH_TOKEN = "refresh_token" CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time" diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index ffdd9dbd518..89fbe78a80a 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Nice G.O.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -235,7 +233,9 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): parsed_data = await self._parse_barrier( self.data[ raw_data["deviceId"] - ].type, # Device type is not sent in device state update, and it can't change, so we just reuse the existing one + ].type, # Device type is not sent in device state + # update, and it can't change, so we just reuse + # the existing one BarrierState( deviceId=raw_data["deviceId"], reported=json.loads(raw_data["reported"]), @@ -268,7 +268,11 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): self.async_set_updated_data(self.data) async def on_connection_lost(self, data: dict[str, Exception]) -> None: - """Handle the websocket connection loss. Don't need to do much since the library will automatically reconnect.""" + """Handle the websocket connection loss. + + Don't need to do much since the library will + automatically reconnect. + """ _LOGGER.debug("Connection lost to the websocket") self.connected = False diff --git a/homeassistant/components/nice_go/diagnostics.py b/homeassistant/components/nice_go/diagnostics.py index 2a663d8925a..44789c77bf2 100644 --- a/homeassistant/components/nice_go/diagnostics.py +++ b/homeassistant/components/nice_go/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nice G.O..""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index f043a23eab5..fab65b64107 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -1,7 +1,5 @@ """Nice G.O. switch platform.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 126a568a1d1..e39ecb4c7bc 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -1,7 +1,5 @@ """Support for Nightscout sensors.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/nightscout/utils.py b/homeassistant/components/nightscout/utils.py index 928abd1aa4f..593167148fb 100644 --- a/homeassistant/components/nightscout/utils.py +++ b/homeassistant/components/nightscout/utils.py @@ -1,7 +1,5 @@ """Nightscout util functions.""" -from __future__ import annotations - import hashlib diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 51e908490e5..1991dfbc596 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -1,7 +1,5 @@ """The Niko home control integration.""" -from __future__ import annotations - from nhc.controller import NHCController from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py index ce4ae3a9acf..e1e712d595a 100644 --- a/homeassistant/components/niko_home_control/config_flow.py +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Niko home control integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py index 2ab3438c4d9..cbd67eb88cd 100644 --- a/homeassistant/components/niko_home_control/cover.py +++ b/homeassistant/components/niko_home_control/cover.py @@ -1,7 +1,5 @@ """Cover Platform for Niko Home Control.""" -from __future__ import annotations - from typing import Any from nhc.cover import NHCCover diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 448efbcc64a..d7b57cded6a 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -1,7 +1,5 @@ """Light platform Niko Home Control.""" -from __future__ import annotations - from typing import Any from nhc.light import NHCLight diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 0e06a62eacf..b86d83cb8d9 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.7.0"] + "requirements": ["nhc==0.8.0"] } diff --git a/homeassistant/components/niko_home_control/scene.py b/homeassistant/components/niko_home_control/scene.py index 129b946b748..11c54679c77 100644 --- a/homeassistant/components/niko_home_control/scene.py +++ b/homeassistant/components/niko_home_control/scene.py @@ -1,7 +1,5 @@ """Scene Platform for Niko Home Control.""" -from __future__ import annotations - from typing import Any from homeassistant.components.scene import BaseScene diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 31259349dea..7d3e4592967 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -1,7 +1,5 @@ """Sensor for checking the air quality around Norway.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 544402b0b3d..666fd3d1012 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,7 +1,5 @@ """The Nina integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import Platform diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 3f351c0b6f4..d1185be8125 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -1,13 +1,12 @@ """NINA binary sensor platform.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,7 +15,6 @@ from .const import ( ATTR_DESCRIPTION, ATTR_EXPIRES, ATTR_HEADLINE, - ATTR_ID, ATTR_RECOMMENDED_ACTIONS, ATTR_SENDER, ATTR_SENT, @@ -92,7 +90,8 @@ class NINAMessage(NinaEntity, BinarySensorEntity): ATTR_DESCRIPTION: data.description, # Deprecated, remove in 2026.11 ATTR_SENDER: data.sender, # Deprecated, remove in 2026.11 ATTR_SEVERITY: data.severity or "Unknown", # Deprecated, remove in 2026.11 - ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, # Deprecated, remove in 2026.11 + # Deprecated, remove in 2026.11 + ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, ATTR_AFFECTED_AREAS: data.affected_areas, # Deprecated, remove in 2026.11 ATTR_WEB: data.more_info_url, # Deprecated, remove in 2026.11 ATTR_ID: data.id, diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index f00f8918298..812e3d8a3f8 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nina integration.""" -from __future__ import annotations - from typing import Any from pynina import ApiError, Nina diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index d034303a243..9af64322eac 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -1,7 +1,5 @@ """Constants for the Nina integration.""" -from __future__ import annotations - from datetime import timedelta from logging import Logger, getLogger from typing import Final @@ -31,7 +29,6 @@ ATTR_SEVERITY: str = "severity" ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions" ATTR_AFFECTED_AREAS: str = "affected_areas" ATTR_WEB: str = "web" -ATTR_ID: str = "id" ATTR_SENT: str = "sent" ATTR_START: str = "start" ATTR_EXPIRES: str = "expires" diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 12e4e831dc6..aafe4de6c1a 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the nina integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import datetime @@ -125,7 +123,10 @@ class NINADataUpdateCoordinator( self.headline_filter, raw_warn.headline, flags=re.IGNORECASE ): _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" + "Ignore warning (%s) by headline filter (%s) with headline: %s", + raw_warn.id, + self.headline_filter, + raw_warn.headline, ) continue @@ -137,7 +138,10 @@ class NINADataUpdateCoordinator( self.area_filter, affected_areas_string, flags=re.IGNORECASE ): _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" + "Ignore warning (%s) by area filter (%s) with area: %s", + raw_warn.id, + self.area_filter, + affected_areas_string, ) continue diff --git a/homeassistant/components/nina/sensor.py b/homeassistant/components/nina/sensor.py index d1491d6365b..a25f5bc6ab1 100644 --- a/homeassistant/components/nina/sensor.py +++ b/homeassistant/components/nina/sensor.py @@ -1,7 +1,5 @@ """NINA sensor platform.""" -from __future__ import annotations - from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index 6efe2828718..3a1d861ec5b 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -1,7 +1,5 @@ """The Nintendo Switch parental controls integration.""" -from __future__ import annotations - from pynintendoauth.exceptions import ( InvalidOAuthConfigurationException, InvalidSessionTokenException, diff --git a/homeassistant/components/nintendo_parental_controls/config_flow.py b/homeassistant/components/nintendo_parental_controls/config_flow.py index f40c5d4712a..edb0feafb4c 100644 --- a/homeassistant/components/nintendo_parental_controls/config_flow.py +++ b/homeassistant/components/nintendo_parental_controls/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Nintendo Switch parental controls integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/nintendo_parental_controls/coordinator.py b/homeassistant/components/nintendo_parental_controls/coordinator.py index abc8f0fdf4e..ba84c106774 100644 --- a/homeassistant/components/nintendo_parental_controls/coordinator.py +++ b/homeassistant/components/nintendo_parental_controls/coordinator.py @@ -1,7 +1,5 @@ """Nintendo parental controls data coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -54,8 +52,10 @@ class NintendoUpdateCoordinator(DataUpdateCoordinator[None]): try: return await self.api.update() except InvalidOAuthConfigurationException as err: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryError( - err, translation_domain=DOMAIN, translation_key="invalid_auth" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from err except NoDevicesFoundException as err: raise ConfigEntryError( diff --git a/homeassistant/components/nintendo_parental_controls/entity.py b/homeassistant/components/nintendo_parental_controls/entity.py index b7e586d7999..1e7d67ba91b 100644 --- a/homeassistant/components/nintendo_parental_controls/entity.py +++ b/homeassistant/components/nintendo_parental_controls/entity.py @@ -1,7 +1,5 @@ """Base entity definition for Nintendo parental controls.""" -from __future__ import annotations - from pynintendoparental.device import Device from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index fd1fe831b68..eac2b3c06fd 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.4.0"] } diff --git a/homeassistant/components/nintendo_parental_controls/number.py b/homeassistant/components/nintendo_parental_controls/number.py index d04eaac0907..6f6b2879439 100644 --- a/homeassistant/components/nintendo_parental_controls/number.py +++ b/homeassistant/components/nintendo_parental_controls/number.py @@ -1,7 +1,5 @@ """Number platform for Nintendo Parental controls.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/nintendo_parental_controls/quality_scale.yaml b/homeassistant/components/nintendo_parental_controls/quality_scale.yaml index 0cc427fd205..8837d6f779f 100644 --- a/homeassistant/components/nintendo_parental_controls/quality_scale.yaml +++ b/homeassistant/components/nintendo_parental_controls/quality_scale.yaml @@ -57,7 +57,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: | diff --git a/homeassistant/components/nintendo_parental_controls/select.py b/homeassistant/components/nintendo_parental_controls/select.py index bd4a80ae3c1..3fd1d2bb6cb 100644 --- a/homeassistant/components/nintendo_parental_controls/select.py +++ b/homeassistant/components/nintendo_parental_controls/select.py @@ -1,7 +1,5 @@ """Nintendo Switch Parental Controls select entity definitions.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/nintendo_parental_controls/sensor.py b/homeassistant/components/nintendo_parental_controls/sensor.py index 99282317e3a..3b18c2a4f91 100644 --- a/homeassistant/components/nintendo_parental_controls/sensor.py +++ b/homeassistant/components/nintendo_parental_controls/sensor.py @@ -1,12 +1,12 @@ """Sensor platform for Nintendo parental controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from enum import StrEnum +from pynintendoparental.player import Player + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -28,20 +28,30 @@ class NintendoParentalControlsSensor(StrEnum): """Store keys for Nintendo parental controls sensors.""" PLAYING_TIME = "playing_time" + PLAYER_PLAYING_TIME = "player_playing_time" TIME_REMAINING = "time_remaining" TIME_EXTENDED = "time_extended" @dataclass(kw_only=True, frozen=True) -class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): - """Description for Nintendo parental controls sensor entities.""" +class NintendoParentalControlsDeviceSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo parental controls device sensor entities.""" value_fn: Callable[[Device], datetime | int | float | None] available_fn: Callable[[Device], bool] = lambda device: True -SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] = ( - NintendoParentalControlsSensorEntityDescription( +@dataclass(kw_only=True, frozen=True) +class NintendoParentalControlsPlayerSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo parental controls player sensor entities.""" + + value_fn: Callable[[Player], int | float | None] + + +DEVICE_SENSOR_DESCRIPTIONS: tuple[ + NintendoParentalControlsDeviceSensorEntityDescription, ... +] = ( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.PLAYING_TIME, translation_key=NintendoParentalControlsSensor.PLAYING_TIME, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -49,7 +59,7 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.today_playing_time, ), - NintendoParentalControlsSensorEntityDescription( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.TIME_REMAINING, translation_key=NintendoParentalControlsSensor.TIME_REMAINING, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -57,7 +67,7 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.today_time_remaining, ), - NintendoParentalControlsSensorEntityDescription( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.TIME_EXTENDED, translation_key=NintendoParentalControlsSensor.TIME_EXTENDED, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -68,30 +78,53 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] ), ) +PLAYER_SENSOR_DESCRIPTIONS: tuple[ + NintendoParentalControlsPlayerSensorEntityDescription, ... +] = ( + NintendoParentalControlsPlayerSensorEntityDescription( + key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME, + translation_key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda player: player.playing_time, + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry, - async_add_devices: AddConfigEntryEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - async_add_devices( - NintendoParentalControlsSensorEntity(entry.runtime_data, device, sensor) + entities: list[NintendoDevice] = [] + entities.extend( + NintendoParentalControlsDeviceSensorEntity(entry.runtime_data, device, sensor) for device in entry.runtime_data.api.devices.values() - for sensor in SENSOR_DESCRIPTIONS + for sensor in DEVICE_SENSOR_DESCRIPTIONS ) + for device in entry.runtime_data.api.devices.values(): + entities.extend( + NintendoParentalControlsPlayerSensorEntity( + entry.runtime_data, device, player_id, sensor + ) + for player_id in device.players + for sensor in PLAYER_SENSOR_DESCRIPTIONS + ) + async_add_entities(entities) -class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity): +class NintendoParentalControlsDeviceSensorEntity(NintendoDevice, SensorEntity): """Represent a single sensor.""" - entity_description: NintendoParentalControlsSensorEntityDescription + entity_description: NintendoParentalControlsDeviceSensorEntityDescription def __init__( self, coordinator: NintendoUpdateCoordinator, device: Device, - description: NintendoParentalControlsSensorEntityDescription, + description: NintendoParentalControlsDeviceSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, device=device, key=description.key) @@ -106,3 +139,39 @@ class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity): def available(self) -> bool: """Return if the sensor is available.""" return super().available and self.entity_description.available_fn(self._device) + + +class NintendoParentalControlsPlayerSensorEntity(NintendoDevice, SensorEntity): + """Represent a single player sensor.""" + + entity_description: NintendoParentalControlsPlayerSensorEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + player_id: str, + description: NintendoParentalControlsPlayerSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + self.player_id = player_id + player_obj = device.get_player(player_id) + nickname = player_obj.nickname or "" + self._attr_translation_placeholders = {"nickname": nickname} + self._attr_unique_id = f"{device.device_id}_{player_id}_{description.key}" + + @property + def entity_picture(self) -> str | None: + """Return the entity picture.""" + if self.player_id not in self._device.players: + return None + return self._device.get_player(self.player_id).player_image + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + if self.player_id not in self._device.players: + return None + return self.entity_description.value_fn(self._device.get_player(self.player_id)) diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index cde905574a2..86e3fb1a9a6 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -47,6 +47,9 @@ } }, "sensor": { + "player_playing_time": { + "name": "{nickname} used screen time" + }, "playing_time": { "name": "Used screen time" }, diff --git a/homeassistant/components/nintendo_parental_controls/switch.py b/homeassistant/components/nintendo_parental_controls/switch.py index f7d349892d7..c36b9afa12c 100644 --- a/homeassistant/components/nintendo_parental_controls/switch.py +++ b/homeassistant/components/nintendo_parental_controls/switch.py @@ -1,7 +1,5 @@ """Switch platform for Nintendo Parental.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/nintendo_parental_controls/time.py b/homeassistant/components/nintendo_parental_controls/time.py index e1c94006707..995eb95ce64 100644 --- a/homeassistant/components/nintendo_parental_controls/time.py +++ b/homeassistant/components/nintendo_parental_controls/time.py @@ -1,7 +1,5 @@ """Time platform for Nintendo parental controls.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index e94b6d20016..42a4d973c00 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -1,7 +1,5 @@ """Support for the Nissan Leaf Carwings/Nissan Connect API.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta from http import HTTPStatus diff --git a/homeassistant/components/nissan_leaf/binary_sensor.py b/homeassistant/components/nissan_leaf/binary_sensor.py index 7938b314deb..8eb8ac05e05 100644 --- a/homeassistant/components/nissan_leaf/binary_sensor.py +++ b/homeassistant/components/nissan_leaf/binary_sensor.py @@ -1,7 +1,5 @@ """Plugged In Status Support for the Nissan Leaf.""" -from __future__ import annotations - import logging from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/nissan_leaf/button.py b/homeassistant/components/nissan_leaf/button.py index 6a5d051751b..a4d741b59fe 100644 --- a/homeassistant/components/nissan_leaf/button.py +++ b/homeassistant/components/nissan_leaf/button.py @@ -1,7 +1,5 @@ """Button to start charging the Nissan Leaf.""" -from __future__ import annotations - import logging from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/nissan_leaf/const.py b/homeassistant/components/nissan_leaf/const.py index 22842fbbc72..437298fc986 100644 --- a/homeassistant/components/nissan_leaf/const.py +++ b/homeassistant/components/nissan_leaf/const.py @@ -1,7 +1,5 @@ """Constants for the Nissan Leaf integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/nissan_leaf/entity.py b/homeassistant/components/nissan_leaf/entity.py index 73813c8931e..81297162ae3 100644 --- a/homeassistant/components/nissan_leaf/entity.py +++ b/homeassistant/components/nissan_leaf/entity.py @@ -1,7 +1,5 @@ """Support for the Nissan Leaf Carwings/Nissan Connect API.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 71dda39db1a..bfc8147f0e7 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -1,7 +1,5 @@ """Battery Charge and Range Support for the Nissan Leaf.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/nissan_leaf/switch.py b/homeassistant/components/nissan_leaf/switch.py index 82a84567fec..b3eed5c95d9 100644 --- a/homeassistant/components/nissan_leaf/switch.py +++ b/homeassistant/components/nissan_leaf/switch.py @@ -1,7 +1,5 @@ """Charge and Climate Control Support for the Nissan Leaf.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 591db22f6a0..b10c854971c 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1,7 +1,5 @@ """The Nmap Tracker integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import datetime, timedelta @@ -41,7 +39,9 @@ from .const import ( type NmapTrackerConfigEntry = ConfigEntry[NmapDeviceScanner] -# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' +# Some version of nmap will fail with +# 'Assertion failed: htn.toclock_running == true +# (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 @@ -147,6 +147,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove tracking for devices owned by this config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES] remove_mac_addresses = [ mac_address @@ -258,7 +260,7 @@ class NmapDeviceScanner: self._hass.async_create_task(self._async_scan_devices()) def _build_options(self): - """Build the command line and strip out last results that do not need to be updated.""" + """Build the options and strip out last results that don't need updating.""" options = self._options if self.home_interval: boundary = dt_util.now() - self.home_interval diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index b2c009271e8..b22edde5db5 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nmap Tracker integration.""" -from __future__ import annotations - from ipaddress import ip_address, ip_network, summarize_address_range import re from typing import Any @@ -167,6 +165,8 @@ async def _async_build_schema_with_user_input( if include_options: schema.update( { + # Approved exemption: nmap scan interval is user-configurable + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 26762577007..edc86c231bd 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,7 +1,5 @@ """Support for scanning a network with nmap.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 4a2783143ca..97c489aeb14 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -29,6 +29,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: station_response = await api_client.get_stations() if station_response is None: return False + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index ff418dbc9a6..77e864b1868 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -7,6 +7,7 @@ from pyrail.models import StationDetails import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, @@ -16,13 +17,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import ( - CONF_EXCLUDE_VIAS, - CONF_SHOW_ON_MAP, - CONF_STATION_FROM, - CONF_STATION_TO, - DOMAIN, -) +from .const import CONF_EXCLUDE_VIAS, CONF_STATION_FROM, CONF_STATION_TO, DOMAIN class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): @@ -82,7 +77,10 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" + config_entry_name = ( + f"Train from {station_from.standard_name}" + f" to {station_to.standard_name}" + ) return self.async_create_entry( title=config_entry_name, data=user_input, diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py index 04c8beb327d..827ad06866c 100644 --- a/homeassistant/components/nmbs/const.py +++ b/homeassistant/components/nmbs/const.py @@ -13,7 +13,6 @@ CONF_STATION_FROM = "station_from" CONF_STATION_TO = "station_to" CONF_STATION_LIVE = "station_live" CONF_EXCLUDE_VIAS = "exclude_vias" -CONF_SHOW_ON_MAP = "show_on_map" def find_station_by_name(hass: HomeAssistant, station_name: str): diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 1bb83e142d5..d2ec3b6af54 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -1,7 +1,5 @@ """Get ride details and liveboard details for NMBS (Belgian railway).""" -from __future__ import annotations - from datetime import datetime import logging from typing import Any @@ -57,7 +55,7 @@ def get_delay_in_minutes(delay=0): def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" duration = arrival_time - departure_time - duration_time = int(round(duration.total_seconds() / 60)) + duration_time = round(duration.total_seconds() / 60) return duration_time + get_delay_in_minutes(delay) @@ -226,7 +224,10 @@ class NMBSSensor(SensorEntity): def name(self) -> str: """Return the name of the sensor.""" if self._name is None: - return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" + return ( + f"Train from {self._station_from.standard_name}" + f" to {self._station_to.standard_name}" + ) return self._name @property diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 87739c8d98c..b3eb37df7b9 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -1,7 +1,5 @@ """Support for the NOAA Tides and Currents API.""" -from __future__ import annotations - from datetime import datetime import logging from typing import TYPE_CHECKING, Any, Literal, TypedDict diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 8a0b171d5e7..3ab4479b548 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,15 +1,29 @@ """The Nobø Ecohub integration.""" -from __future__ import annotations - from pynobo import nobo from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import ( + ATTR_NAME, + CONF_IP_ADDRESS, + CONF_MAC, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.util import dt as dt_util -from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL +from .const import ( + ATTR_HARDWARE_VERSION, + ATTR_SOFTWARE_VERSION, + CONF_OVERRIDE_TYPE, + CONF_SERIAL, + DOMAIN, + NOBO_MANUFACTURER, +) PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] @@ -20,16 +34,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b """Set up Nobø Ecohub from a config entry.""" serial = entry.data[CONF_SERIAL] - discover = entry.data[CONF_AUTO_DISCOVERED] - ip_address = None if discover else entry.data[CONF_IP_ADDRESS] - hub = nobo( - serial=serial, - ip=ip_address, - discover=discover, - synchronous=False, - timezone=dt_util.get_default_time_zone(), - ) - await hub.connect() + stored_ip = entry.data[CONF_IP_ADDRESS] + + async def _connect(ip: str) -> nobo: + hub = nobo( + serial=serial, + ip=ip, + discover=False, + synchronous=False, + timezone=dt_util.get_default_time_zone(), + ) + await hub.connect() + return hub + + try: + hub = await _connect(stored_ip) + except OSError as err: + # Stored IP may be stale - try UDP rediscovery to pick up a new + # DHCP lease (or a hub that's been moved). + discovered = await nobo.async_discover_hubs(serial=serial) + if not discovered: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"serial": serial, "ip": stored_ip}, + ) from err + new_ip, _ = next(iter(discovered)) + try: + hub = await _connect(new_ip) + except OSError as rediscover_err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"serial": serial, "ip": new_ip}, + ) from rediscover_err + if new_ip != stored_ip: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_IP_ADDRESS: new_ip} + ) async def _async_close(event): """Close the Nobø Ecohub socket connection when HA stops.""" @@ -40,6 +82,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b ) entry.runtime_data = hub + device_registry = dr.async_get(hass) + connections: set[tuple[str, str]] = set() + if mac := entry.data.get(CONF_MAC): + connections.add((CONNECTION_NETWORK_MAC, format_mac(mac))) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, hub.hub_serial)}, + connections=connections, + serial_number=hub.hub_serial, + name=hub.hub_info[ATTR_NAME], + manufacturer=NOBO_MANUFACTURER, + model="Nobø Ecohub", + sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + hw_version=hub.hub_info[ATTR_HARDWARE_VERSION], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hub.start() @@ -54,3 +112,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> await entry.runtime_data.stop() return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # Lowercase override_type to match translation keys. + new_options = dict(entry.options) + if (override_type := new_options.get(CONF_OVERRIDE_TYPE)) is not None: + new_options[CONF_OVERRIDE_TYPE] = override_type.lower() + hass.config_entries.async_update_entry( + entry, options=new_options, version=1, minor_version=2 + ) + + if entry.version == 1 and entry.minor_version < 3: + # auto_discovered no longer affects behaviour; rediscovery is now + # the unconditional fallback on connection failure. + new_data = dict(entry.data) + new_data.pop("auto_discovered", None) + hass.config_entries.async_update_entry( + entry, data=new_data, version=1, minor_version=3 + ) + + return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index e0f21e4d549..6246d7f85a6 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -1,10 +1,8 @@ """Python Control of Nobø Hub - Nobø Energy Control.""" -from __future__ import annotations - from typing import Any -from pynobo import nobo +from pynobo import PynoboError, nobo from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -17,8 +15,14 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ( + ATTR_NAME, + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -32,6 +36,9 @@ from .const import ( DOMAIN, OVERRIDE_TYPE_NOW, ) +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 SUPPORT_FLAGS = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -63,7 +70,7 @@ async def async_setup_entry( async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones) -class NoboZone(ClimateEntity): +class NoboZone(NoboBaseEntity, ClimateEntity): """Representation of a Nobø zone. A Nobø zone consists of a group of physical devices that are @@ -71,7 +78,6 @@ class NoboZone(ClimateEntity): """ _attr_name = None - _attr_has_entity_name = True _attr_max_temp = MAX_TEMPERATURE _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_TENTHS @@ -80,13 +86,14 @@ class NoboZone(ClimateEntity): _attr_preset_modes = PRESET_MODES _attr_supported_features = SUPPORT_FLAGS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_target_temperature_step = 1 - # Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False + _attr_target_temperature_step = PRECISION_WHOLE + # Need to poll to get preset change when in HVACMode.AUTO + _attr_should_poll = True def __init__(self, zone_id, hub: nobo, override_type) -> None: """Initialize the climate device.""" + super().__init__(hub) self._id = zone_id - self._nobo = hub self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" self._override_type = override_type self._attr_device_info = DeviceInfo( @@ -97,28 +104,20 @@ class NoboZone(ClimateEntity): ) self._read_state() - async def async_added_to_hass(self) -> None: - """Register callback from hub.""" - self._nobo.register_callback(self._after_update) - - async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" - self._nobo.deregister_callback(self._after_update) - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target HVAC mode, if it's supported.""" - if hvac_mode not in self.hvac_modes: - raise ValueError( - f"Zone {self._id} '{self._attr_name}' called with unsupported HVAC mode" - f" '{hvac_mode}'" - ) - if hvac_mode == HVACMode.AUTO: - await self.async_set_preset_mode(PRESET_NONE) - elif hvac_mode == HVACMode.HEAT: - await self.async_set_preset_mode(PRESET_COMFORT) + """Set new target HVAC mode.""" + preset = PRESET_COMFORT if hvac_mode == HVACMode.HEAT else PRESET_NONE + await self._apply_preset(preset, "set_hvac_mode_failed") async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new zone override.""" + await self._apply_preset(preset_mode, "set_preset_mode_failed") + + async def _apply_preset( + self, + preset_mode: str, + translation_key: str, + ) -> None: if preset_mode == PRESET_ECO: mode = nobo.API.OVERRIDE_MODE_ECO elif preset_mode == PRESET_AWAY: @@ -127,23 +126,33 @@ class NoboZone(ClimateEntity): mode = nobo.API.OVERRIDE_MODE_COMFORT else: # PRESET_NONE mode = nobo.API.OVERRIDE_MODE_NORMAL - await self._nobo.async_create_override( - mode, - self._override_type, - nobo.API.OVERRIDE_TARGET_ZONE, - self._id, - ) + try: + await self._nobo.async_create_override( + mode, + self._override_type, + nobo.API.OVERRIDE_TARGET_ZONE, + self._id, + ) + except PynoboError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + ) from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TARGET_TEMP_LOW in kwargs: low = round(kwargs[ATTR_TARGET_TEMP_LOW]) high = round(kwargs[ATTR_TARGET_TEMP_HIGH]) - low = min(low, high) - high = max(low, high) - await self._nobo.async_update_zone( - self._id, temp_comfort_c=high, temp_eco_c=low - ) + try: + await self._nobo.async_update_zone( + self._id, temp_comfort_c=high, temp_eco_c=low + ) + except PynoboError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_failed", + ) from err async def async_update(self) -> None: """Fetch new state data for this zone.""" @@ -151,7 +160,12 @@ class NoboZone(ClimateEntity): @callback def _read_state(self) -> None: - """Read the current state from the hub. These are only local calls.""" + """Copy the current hub state onto the entity attributes.""" + if self._id not in self._nobo.zones: + # Zone removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE @@ -178,8 +192,3 @@ class NoboZone(ClimateEntity): self._attr_target_temperature_low = int( self._nobo.zones[self._id][ATTR_TEMP_ECO_C] ) - - @callback - def _after_update(self, hub): - self._read_state() - self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 7809b66d00e..1737a57f018 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Nobø Ecohub integration.""" -from __future__ import annotations - import socket from typing import TYPE_CHECKING, Any @@ -13,13 +11,15 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import NoboHubConfigEntry from .const import ( - CONF_AUTO_DISCOVERED, CONF_OVERRIDE_TYPE, CONF_SERIAL, DOMAIN, @@ -35,11 +35,13 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Nobø Ecohub.""" VERSION = 1 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize the config flow.""" self._discovered_hubs: dict[str, Any] | None = None self._hub: str | None = None + self._mac: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -70,6 +72,83 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=data_schema, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery of a Nobø Ecohub. + + The MAC from the DHCP packet is set as the flow's temporary + unique_id so the user can dismiss this discovery via "Ignore", + and so a previously-ignored hub aborts cleanly on rediscovery. + The unique_id is replaced with the full 12-digit serial when an + entry is created. + + Four paths from here: + - Fast path: a configured entry already has this MAC stored → + refresh its IP and abort. + - Device is already ignored. + - IP+prefix match: listen for the hub's UDP broadcast (15s) to + learn the 9-digit serial prefix. If a configured entry's + stored IP and prefix both match the DHCP packet, backfill its + MAC and abort. + - Otherwise: route to the `selected` step so the user can + supply the 3-digit serial suffix. + """ + self._mac = discovery_info.macaddress + # Fast path: a configured entry already knows this MAC. Refresh + # its IP and skip the broadcast wait entirely. Done before + # `async_set_unique_id` so an ignored entry with the same MAC + # doesn't block the IP refresh of an active configuration. + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_MAC) == discovery_info.macaddress: + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_IP_ADDRESS: discovery_info.ip}, + reason="already_configured", + ) + + # Use the MAC as the temporary unique_id so the frontend offers an + # "Ignore" option, and so a previously-ignored MAC correctly aborts + # the flow here. The MAC is per-device unique (the 9-digit serial + # prefix would shadow sibling hubs from the same production batch). + # Replaced with the full 12-digit serial in _create_configuration + # once the user supplies the suffix. + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured() + + # Wait 15s — when DHCP fires on hub boot, the hub's broadcast + # service comes up after the DHCPDISCOVER but typically within + # ~10s. Shorter waits may miss the first post-boot broadcast. + discovered = await nobo.async_discover_hubs( + ip=discovery_info.ip, autodiscover_wait=15.0 + ) + if not discovered: + return self.async_abort(reason="cannot_discover") + _, serial_prefix = next(iter(discovered)) + + # Fallback: a configured entry without a stored MAC (manual or + # user-picker entry, not yet DHCP-backfilled) is identified by + # both the stored IP and the 9-digit serial prefix matching the + # DHCP packet. Requiring IP match prevents clobbering a sibling + # entry from the same production batch (which shares the prefix). + # Pynobo's connection-failure rediscovery handles IP changes for + # non-DHCP-backfilled entries. + for entry in self._async_current_entries(include_ignore=False): + if ( + entry.data.get(CONF_IP_ADDRESS) == discovery_info.ip + and entry.unique_id + and entry.unique_id.startswith(serial_prefix) + ): + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_MAC: discovery_info.macaddress}, + reason="already_configured", + ) + + self._discovered_hubs = {discovery_info.ip: serial_prefix} + self._hub = discovery_info.ip + return await self.async_step_selected() + async def async_step_selected( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -83,7 +162,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): serial_suffix = user_input["serial_suffix"] serial = f"{serial_prefix}{serial_suffix}" try: - return await self._create_configuration(serial, self._hub, True) + return await self._create_configuration(serial, self._hub) except NoboHubConnectError as error: errors["base"] = error.msg @@ -112,7 +191,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): serial = user_input[CONF_SERIAL] ip_address = user_input[CONF_IP_ADDRESS] try: - return await self._create_configuration(serial, ip_address, False) + return await self._create_configuration(serial, ip_address) except NoboHubConnectError as error: errors["base"] = error.msg @@ -131,7 +210,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _create_configuration( - self, serial: str, ip_address: str, auto_discovered: bool + self, serial: str, ip_address: str ) -> ConfigFlowResult: await self.async_set_unique_id(serial) self._abort_if_unique_id_configured() @@ -141,7 +220,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_SERIAL: serial, CONF_IP_ADDRESS: ip_address, - CONF_AUTO_DISCOVERED: auto_discovered, + CONF_MAC: self._mac, }, ) @@ -153,11 +232,18 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): except OSError as err: raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) - if not await hub.async_connect_hub(ip_address, serial): - raise NoboHubConnectError("cannot_connect") - name = hub.hub_info["name"] - await hub.close() - return name + # pynobo distinguishes the two failure modes: TCP-level errors + # (wrong IP, hub offline, port closed) raise OSError, while a + # successful TCP connection followed by a handshake REJECT + # (serial mismatch) returns False. + try: + if not await hub.async_connect_hub(ip_address, serial): + raise NoboHubConnectError("cannot_connect") + return hub.hub_info["name"] + except OSError as err: + raise NoboHubConnectError("cannot_connect_ip") from err + finally: + await hub.close() @staticmethod def _format_hub(ip, serial_prefix): @@ -205,8 +291,11 @@ class OptionsFlowHandler(OptionsFlowWithReload): schema = vol.Schema( { - vol.Required(CONF_OVERRIDE_TYPE, default=override_type): vol.In( - [OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW] + vol.Required(CONF_OVERRIDE_TYPE, default=override_type): SelectSelector( + SelectSelectorConfig( + options=[OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW], + translation_key=CONF_OVERRIDE_TYPE, + ) ), } ) diff --git a/homeassistant/components/nobo_hub/const.py b/homeassistant/components/nobo_hub/const.py index fdffb977201..4e1cd852f17 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -1,17 +1,18 @@ """Constants for the Nobø Ecohub integration.""" +from typing import Final + DOMAIN = "nobo_hub" -CONF_AUTO_DISCOVERED = "auto_discovered" CONF_SERIAL = "serial" CONF_OVERRIDE_TYPE = "override_type" -OVERRIDE_TYPE_CONSTANT = "Constant" -OVERRIDE_TYPE_NOW = "Now" +OVERRIDE_TYPE_CONSTANT = "constant" +OVERRIDE_TYPE_NOW = "now" NOBO_MANUFACTURER = "Glen Dimplex Nordic AS" -ATTR_HARDWARE_VERSION = "hardware_version" -ATTR_SOFTWARE_VERSION = "software_version" -ATTR_SERIAL = "serial" -ATTR_TEMP_COMFORT_C = "temp_comfort_c" -ATTR_TEMP_ECO_C = "temp_eco_c" -ATTR_ZONE_ID = "zone_id" +ATTR_HARDWARE_VERSION: Final = "hardware_version" +ATTR_SOFTWARE_VERSION: Final = "software_version" +ATTR_SERIAL: Final = "serial" +ATTR_TEMP_COMFORT_C: Final = "temp_comfort_c" +ATTR_TEMP_ECO_C: Final = "temp_eco_c" +ATTR_ZONE_ID: Final = "zone_id" diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py new file mode 100644 index 00000000000..79bc77c28d8 --- /dev/null +++ b/homeassistant/components/nobo_hub/entity.py @@ -0,0 +1,43 @@ +"""Base entity for the Nobø Ecohub integration.""" + +from pynobo import nobo + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + + +class NoboBaseEntity(Entity): + """Base class for Nobø Ecohub entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, hub: nobo) -> None: + """Initialize the entity.""" + self._nobo = hub + + async def async_added_to_hass(self) -> None: + """Register callback with hub.""" + await super().async_added_to_hass() + self._nobo.register_callback(self._handle_hub_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._handle_hub_update) + await super().async_will_remove_from_hass() + + @callback + def _handle_hub_update(self, _hub: nobo) -> None: + """Handle pushed state update from the hub.""" + self._read_state() + self.async_write_ha_state() + + @callback + def _read_state(self) -> None: + """Copy the current hub state from the pynobo client onto the entity attributes. + + The pynobo client keeps its own in-memory state, updated via pushes + from the hub; subclasses override this to map the relevant values + onto their `_attr_*` fields. Must be overridden. + """ + raise NotImplementedError diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index ce32244e1ce..13d7dfd6079 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -3,8 +3,18 @@ "name": "Nob\u00f8 Ecohub", "codeowners": ["@echoromeo", "@oyvindwe"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + }, + { + "hostname": "hub*", + "macaddress": "7C8306*" + } + ], "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["pynobo==1.8.1"] + "quality_scale": "bronze", + "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml new file mode 100644 index 00000000000..6a64bb7e0a6 --- /dev/null +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The hub does not require authentication. + test-coverage: + status: done + comment: > + Investigate whether the `_spec_hub` helper in `test_init.py` can be + replaced by the conftest base mock. + + # Gold + devices: + status: done + comment: > + Model name "Nobø Ecohub" under review for rename to "Nobø Hub". + diagnostics: todo + discovery: done + discovery-update-info: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: + status: todo + comment: > + Custom device class on global override select being dropped in + PR #170135. + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration has no repair scenarios. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. + strict-typing: + status: todo + comment: Requires release of pynobo 1.9.0 diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 98d8ffc6295..9b0be2cac23 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -1,8 +1,6 @@ """Python Control of Nobø Hub - Nobø Energy Control.""" -from __future__ import annotations - -from pynobo import nobo +from pynobo import PynoboError, nobo from homeassistant.components.select import SelectEntity from homeassistant.const import ATTR_NAME @@ -21,6 +19,9 @@ from .const import ( NOBO_MANUFACTURER, OVERRIDE_TYPE_NOW, ) +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -46,13 +47,11 @@ async def async_setup_entry( async_add_entities(entities, True) -class NoboGlobalSelector(SelectEntity): +class NoboGlobalSelector(NoboBaseEntity, SelectEntity): """Global override selector for Nobø Ecohub.""" - _attr_has_entity_name = True _attr_translation_key = "global_override" _attr_device_class = "nobo_hub__override" - _attr_should_poll = False _modes = { nobo.API.OVERRIDE_MODE_NORMAL: "none", nobo.API.OVERRIDE_MODE_AWAY: "away", @@ -64,7 +63,7 @@ class NoboGlobalSelector(SelectEntity): def __init__(self, hub: nobo, override_type) -> None: """Initialize the global override selector.""" - self._nobo = hub + super().__init__(hub) self._attr_unique_id = hub.hub_serial self._override_type = override_type self._attr_device_info = DeviceInfo( @@ -77,14 +76,6 @@ class NoboGlobalSelector(SelectEntity): hw_version=hub.hub_info[ATTR_HARDWARE_VERSION], ) - async def async_added_to_hass(self) -> None: - """Register callback from hub.""" - self._nobo.register_callback(self._after_update) - - async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" - self._nobo.deregister_callback(self._after_update) - async def async_select_option(self, option: str) -> None: """Set override.""" mode = [k for k, v in self._modes.items() if v == option][0] @@ -92,8 +83,11 @@ class NoboGlobalSelector(SelectEntity): await self._nobo.async_create_override( mode, self._override_type, nobo.API.OVERRIDE_TARGET_GLOBAL ) - except Exception as exp: - raise HomeAssistantError from exp + except PynoboError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_global_override_failed", + ) from err async def async_update(self) -> None: """Fetch new state data for this zone.""" @@ -101,31 +95,25 @@ class NoboGlobalSelector(SelectEntity): @callback def _read_state(self) -> None: + """Copy the current hub state onto the entity attributes.""" for override in self._nobo.overrides.values(): if override["target_type"] == nobo.API.OVERRIDE_TARGET_GLOBAL: self._attr_current_option = self._modes[override["mode"]] break - @callback - def _after_update(self, hub) -> None: - self._read_state() - self.async_write_ha_state() - -class NoboProfileSelector(SelectEntity): +class NoboProfileSelector(NoboBaseEntity, SelectEntity): """Week profile selector for Nobø zones.""" _attr_translation_key = "week_profile" - _attr_has_entity_name = True - _attr_should_poll = False - _profiles: dict[int, str] = {} - _attr_options: list[str] = [] _attr_current_option: str | None = None def __init__(self, zone_id: str, hub: nobo) -> None: """Initialize the week profile selector.""" + super().__init__(hub) self._id = zone_id - self._nobo = hub + self._profiles: dict[str, str] = {} + self._attr_options: list[str] = [] self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, @@ -134,14 +122,6 @@ class NoboProfileSelector(SelectEntity): suggested_area=hub.zones[zone_id][ATTR_NAME], ) - async def async_added_to_hass(self) -> None: - """Register callback from hub.""" - self._nobo.register_callback(self._after_update) - - async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" - self._nobo.deregister_callback(self._after_update) - async def async_select_option(self, option: str) -> None: """Set week profile.""" week_profile_id = [k for k, v in self._profiles.items() if v == option][0] @@ -149,8 +129,11 @@ class NoboProfileSelector(SelectEntity): await self._nobo.async_update_zone( self._id, week_profile_id=week_profile_id ) - except Exception as exp: - raise HomeAssistantError from exp + except PynoboError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_week_profile_failed", + ) from err async def async_update(self) -> None: """Fetch new state data for this zone.""" @@ -158,6 +141,12 @@ class NoboProfileSelector(SelectEntity): @callback def _read_state(self) -> None: + """Copy the current hub state onto the entity attributes.""" + if self._id not in self._nobo.zones: + # Zone removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() @@ -166,8 +155,3 @@ class NoboProfileSelector(SelectEntity): self._attr_current_option = self._profiles[ self._nobo.zones[self._id]["week_profile_id"] ] - - @callback - def _after_update(self, hub) -> None: - self._read_state() - self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index a56a02f875e..ac9a259da8d 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -1,7 +1,5 @@ """Python Control of Nobø Hub - Nobø Energy Control.""" -from __future__ import annotations - from pynobo import nobo from homeassistant.components.sensor import ( @@ -17,6 +15,9 @@ from homeassistant.helpers.typing import StateType from . import NoboHubConfigEntry from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -36,20 +37,19 @@ async def async_setup_entry( ) -class NoboTemperatureSensor(SensorEntity): +class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): """A Nobø device with a temperature sensor.""" _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT - _attr_should_poll = False - _attr_has_entity_name = True + _attr_suggested_display_precision = 1 def __init__(self, serial: str, hub: nobo) -> None: """Initialize the temperature sensor.""" + super().__init__(hub) self._temperature: StateType = None self._id = serial - self._nobo = hub component = hub.components[self._id] self._attr_unique_id = component[ATTR_SERIAL] zone_id = component[ATTR_ZONE_ID] @@ -67,24 +67,13 @@ class NoboTemperatureSensor(SensorEntity): ) self._read_state() - async def async_added_to_hass(self) -> None: - """Register callback from hub.""" - self._nobo.register_callback(self._after_update) - - async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" - self._nobo.deregister_callback(self._after_update) - @callback def _read_state(self) -> None: - """Read the current state from the hub. This is a local call.""" + """Copy the current hub state onto the entity attributes.""" + if self._id not in self._nobo.components: + # Component removed via the Nobø app; mark unavailable. + self._attr_available = False + return + self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) - if value is None: - self._attr_native_value = None - else: - self._attr_native_value = round(float(value), 1) - - @callback - def _after_update(self, hub) -> None: - self._read_state() - self.async_write_ha_state() + self._attr_native_value = None if value is None else float(value) diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 5323ee23965..07cd3d15c26 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -1,10 +1,13 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address." }, "error": { "cannot_connect": "Failed to connect - check serial number", + "cannot_connect_ip": "Failed to connect - check IP address", "invalid_ip": "Invalid IP address", "invalid_serial": "Invalid serial number", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -15,18 +18,28 @@ "ip_address": "[%key:common::config_flow::data::ip%]", "serial": "Serial number (12 digits)" }, + "data_description": { + "ip_address": "The IP address of your Nobø Ecohub.", + "serial": "The full 12-digit serial number printed on the back of your Nobø Ecohub." + }, "description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address." }, "selected": { "data": { "serial_suffix": "Serial number suffix (3 digits)" }, + "data_description": { + "serial_suffix": "The last 3 digits of the serial number printed on the back of your Nobø Ecohub." + }, "description": "Configuring {hub}.\r\rTo connect to the hub, you need to enter the last 3 digits of the hub's serial number." }, "user": { "data": { "device": "Discovered hubs" }, + "data_description": { + "device": "Select the Nobø Ecohub discovered on your local network, or choose manual entry." + }, "description": "Select Nobø Ecohub to configure." } } @@ -47,13 +60,44 @@ } } }, + "exceptions": { + "cannot_connect": { + "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration." + }, + "set_global_override_failed": { + "message": "Failed to set global override." + }, + "set_hvac_mode_failed": { + "message": "Failed to set HVAC mode." + }, + "set_preset_mode_failed": { + "message": "Failed to set preset mode." + }, + "set_temperature_failed": { + "message": "Failed to set target temperature." + }, + "set_week_profile_failed": { + "message": "Failed to set week profile." + } + }, "options": { "step": { "init": { "data": { "override_type": "Override type" }, - "description": "Select override type \"Now\" to end override on next week profile change." + "data_description": { + "override_type": "Select \"Now\" to end the override on the next week profile change, or \"Constant\" to keep it until manually cleared." + }, + "description": "Configure how overrides are ended." + } + } + }, + "selector": { + "override_type": { + "options": { + "constant": "Constant", + "now": "Now" } } } diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 8fb6a5eaf3b..2b744e01d0d 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -1,7 +1,5 @@ """The Nord Pool component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/nordpool/binary_sensor.py b/homeassistant/components/nordpool/binary_sensor.py new file mode 100644 index 00000000000..76235e802e1 --- /dev/null +++ b/homeassistant/components/nordpool/binary_sensor.py @@ -0,0 +1,81 @@ +"""Binary sensor platform for Nord Pool integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.components.sensor import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NordPoolConfigEntry +from .const import CONF_AREAS +from .coordinator import NordPoolDataUpdateCoordinator +from .entity import NordpoolBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_tomorrow_price_available( + entity: NordpoolPriceBinarySensor, +) -> bool: + """Return tomorrow price availability.""" + data = entity.coordinator.get_data_tomorrow() + return bool(data and data.entries and entity.area in data.entries[0].entry) + + +@dataclass(frozen=True, kw_only=True) +class NordpoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Nord Pool binary sensor entity.""" + + value_fn: Callable[[NordpoolPriceBinarySensor], bool | None] + + +BINARY_SENSOR_TYPES: tuple[NordpoolBinarySensorEntityDescription, ...] = ( + NordpoolBinarySensorEntityDescription( + key="tomorrow_price_available", + translation_key="tomorrow_price_available", + value_fn=get_tomorrow_price_available, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NordPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Nord Pool binary sensor platform.""" + + coordinator = entry.runtime_data + areas = coordinator.config_entry.data[CONF_AREAS] + + async_add_entities( + NordpoolPriceBinarySensor(coordinator, description, area) + for description in BINARY_SENSOR_TYPES + for area in areas + ) + + +class NordpoolPriceBinarySensor(NordpoolBaseEntity, BinarySensorEntity): + """Representation of a Nord Pool binary sensor.""" + + entity_description: NordpoolBinarySensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolBinarySensorEntityDescription, + area: str, + ) -> None: + """Initiate Nord Pool binary sensor.""" + super().__init__(coordinator, entity_description, area) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index b3b807badad..3943ea2f69b 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Nord Pool integration.""" -from __future__ import annotations - from typing import Any from pynordpool import ( @@ -56,6 +54,8 @@ DATA_SCHEMA = vol.Schema( async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]: """Test fetch data from Nord Pool.""" + if not user_input.get(CONF_AREAS): + return {CONF_AREAS: "no_areas"} client = NordPoolClient(async_get_clientsession(hass)) try: await client.async_get_delivery_period( diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py index 1fd3009321b..cb0f3f30b07 100644 --- a/homeassistant/components/nordpool/const.py +++ b/homeassistant/components/nordpool/const.py @@ -8,7 +8,7 @@ LOGGER = logging.getLogger(__package__) DEFAULT_SCAN_INTERVAL = 60 DOMAIN = "nordpool" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] DEFAULT_NAME = "Nord Pool" CONF_AREAS = "areas" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index f2f41322aff..3372e6cf1e4 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Nord Pool integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING @@ -108,11 +106,11 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """Fetch data from Nord Pool.""" data = await self.api_call() if data and data.entries: - current_day = dt_util.utcnow().strftime("%Y-%m-%d") - for entry in data.entries: - if entry.requested_date == current_day: - LOGGER.debug("Data for current day found") - return data + current_day = dt_util.now().date() + if current_day in data.entries: + LOGGER.debug("Data for current day found") + return data + if data and not data.entries and not initial: # Empty response, use cache LOGGER.debug("No data entries received") @@ -158,16 +156,16 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): def merge_price_entries(self) -> list[DeliveryPeriodEntry]: """Return the merged price entries.""" merged_entries: list[DeliveryPeriodEntry] = [] - for del_period in self.data.entries: + for del_period in self.data.entries.values(): merged_entries.extend(del_period.entries) return merged_entries def get_data_current_day(self) -> DeliveryPeriodData: """Return the current day data.""" - current_day = dt_util.utcnow().strftime("%Y-%m-%d") - delivery_period: DeliveryPeriodData = self.data.entries[0] - for del_period in self.data.entries: - if del_period.requested_date == current_day: - delivery_period = del_period - break - return delivery_period + current_day = dt_util.now().date() + return self.data.entries[current_day] + + def get_data_tomorrow(self) -> DeliveryPeriodData | None: + """Return tomorrow's day data if available.""" + tomorrow = dt_util.now().date() + timedelta(days=1) + return self.data.entries.get(tomorrow) diff --git a/homeassistant/components/nordpool/diagnostics.py b/homeassistant/components/nordpool/diagnostics.py index 3160c2bfa6d..852380119ac 100644 --- a/homeassistant/components/nordpool/diagnostics.py +++ b/homeassistant/components/nordpool/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nord Pool.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py index ec3264cd2e3..98e966573cc 100644 --- a/homeassistant/components/nordpool/entity.py +++ b/homeassistant/components/nordpool/entity.py @@ -1,7 +1,5 @@ """Base entity for Nord Pool.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index 1ac32f28763..85e43a3545c 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynordpool"], "quality_scale": "platinum", - "requirements": ["pynordpool==0.3.2"], + "requirements": ["pynordpool==0.4.0"], "single_config_entry": true } diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index 4295691f8f4..80e31165b80 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Nord Pool integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -304,7 +302,8 @@ async def async_setup_entry( ) for block_prices in entry.runtime_data.get_data_current_day().block_prices: LOGGER.debug( - "Setting up block price sensors for area %s with currency %s in block %s", + "Setting up block price sensors for" + " area %s with currency %s in block %s", area, currency, block_prices.name, diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index b000bc17887..e796ba0e870 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -1,7 +1,5 @@ """Services for Nord Pool integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import date, datetime from functools import partial @@ -38,6 +36,22 @@ if TYPE_CHECKING: from . import NordPoolConfigEntry from .const import ATTR_RESOLUTION, DOMAIN + +def _validate_areas(areas: list[str]) -> list[str]: + """Validate the areas.""" + validated_areas: list[str] = [] + + for area in areas: + validated_area = cv.string(area) + validated_area = validated_area.upper() + if validated_area not in AREAS: + raise vol.Invalid(f"Area {area} is not valid") + + validated_areas.append(validated_area) + + return validated_areas + + _LOGGER = logging.getLogger(__name__) ATTR_CONFIG_ENTRY = "config_entry" ATTR_AREAS = "areas" @@ -49,9 +63,11 @@ SERVICE_GET_PRICES_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_DATE): cv.date, - vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]), + vol.Optional(ATTR_AREAS, default=[]): vol.All(cv.ensure_list, _validate_areas), vol.Optional(ATTR_CURRENCY): vol.All( - cv.string, vol.In([currency.value for currency in Currency]) + cv.string, + vol.Upper, + vol.In([currency.value for currency in Currency]), ), } ) @@ -78,20 +94,14 @@ def async_setup_services(hass: HomeAssistant) -> None: client = entry.runtime_data.client asked_date: date = call.data[ATTR_DATE] - areas: list[str] = entry.data[ATTR_AREAS] - if _areas := call.data.get(ATTR_AREAS): - areas = _areas + areas = call.data.get(ATTR_AREAS) + areas = areas or entry.data[ATTR_AREAS] - currency: str = entry.data[ATTR_CURRENCY] - if _currency := call.data.get(ATTR_CURRENCY): - currency = _currency + currency = call.data.get(ATTR_CURRENCY) + currency = currency or entry.data[ATTR_CURRENCY] - resolution: int = 60 - if _resolution := call.data.get(ATTR_RESOLUTION): - resolution = _resolution - - areas = [area.upper() for area in areas] - currency = currency.upper() + resolution = call.data.get(ATTR_RESOLUTION) + resolution = resolution or 60 return (client, asked_date, currency, areas, resolution) diff --git a/homeassistant/components/nordpool/services.yaml b/homeassistant/components/nordpool/services.yaml index f18d705f54b..5b4a8738069 100644 --- a/homeassistant/components/nordpool/services.yaml +++ b/homeassistant/components/nordpool/services.yaml @@ -12,6 +12,7 @@ get_prices_for_date: areas: selector: select: + multiple: true options: - "EE" - "LT" @@ -34,6 +35,8 @@ get_prices_for_date: - "SE2" - "SE3" - "SE4" + - "BG" + - "TEL" - "SYS" mode: dropdown currency: @@ -60,6 +63,7 @@ get_price_indices_for_date: areas: selector: select: + multiple: true options: - "EE" - "LT" @@ -82,6 +86,8 @@ get_price_indices_for_date: - "SE2" - "SE3" - "SE4" + - "BG" + - "TEL" - "SYS" mode: dropdown currency: diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 89e99c37908..085e342678b 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_areas": "No area(s) selected", "no_data": "API connected but the response was empty" }, "step": { @@ -31,6 +32,11 @@ } }, "entity": { + "binary_sensor": { + "tomorrow_price_available": { + "name": "Tomorrow price available" + } + }, "sensor": { "block_average": { "name": "{block} average" diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index 36de8c8b1ad..41e29fabf69 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -1,7 +1,5 @@ """Sensor for checking the air quality forecast around Norway.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 03ff092a13f..ccfc5873a39 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to notify people.""" -from __future__ import annotations - from datetime import timedelta from enum import IntFlag from functools import partial diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index f5703022e12..90d874819cb 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -1,7 +1,5 @@ """Handle legacy notification platforms.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine, Mapping from functools import partial @@ -14,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.loader import async_get_integration from homeassistant.setup import ( SetupPhases, async_prepare_setup_platform, @@ -159,7 +157,6 @@ def async_setup_legacy( ] -@bind_hass async def async_reload(hass: HomeAssistant, integration_name: str) -> None: """Register notify services for an integration.""" if not _async_integration_has_notify_services(hass, integration_name): @@ -173,7 +170,6 @@ async def async_reload(hass: HomeAssistant, integration_name: str) -> None: await asyncio.gather(*tasks) -@bind_hass async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: """Unregister notify services for an integration.""" notify_discovery_dispatcher = hass.data.get(NOTIFY_DISCOVERY_DISPATCHER) diff --git a/homeassistant/components/notify/repairs.py b/homeassistant/components/notify/repairs.py index 8969652d98e..cf5b5d85828 100644 --- a/homeassistant/components/notify/repairs.py +++ b/homeassistant/components/notify/repairs.py @@ -1,7 +1,5 @@ """Repairs support for notify integration.""" -from __future__ import annotations - from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/notify_events/notify.py b/homeassistant/components/notify_events/notify.py index 92628059d68..28c645bbf6c 100644 --- a/homeassistant/components/notify_events/notify.py +++ b/homeassistant/components/notify_events/notify.py @@ -1,7 +1,5 @@ """Notify.Events platform for notify component.""" -from __future__ import annotations - import logging import os.path from typing import Any diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index aef7d740860..cbf5434eede 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -1,7 +1,5 @@ """Support for Notion.""" -from __future__ import annotations - from datetime import timedelta from typing import Any from uuid import UUID diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 24b60088e6a..dbaa14bd624 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Notion binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Literal diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index f7347a8f595..d2ae9934137 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Notion integration.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 7963f7db4ac..12641164441 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Notion.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index 387eaf2e423..ee6316bcc8a 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -1,7 +1,5 @@ """Support for Notion.""" -from __future__ import annotations - from dataclasses import dataclass from aionotion.bridge.models import Bridge diff --git a/homeassistant/components/novy_cooker_hood/__init__.py b/homeassistant/components/novy_cooker_hood/__init__.py new file mode 100644 index 00000000000..148e90a78f6 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/__init__.py @@ -0,0 +1,18 @@ +"""The Novy Cooker Hood integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.FAN, Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Novy Cooker Hood from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py new file mode 100644 index 00000000000..4d7d9b919e1 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,173 @@ +"""Config flow for the Novy Cooker Hood integration.""" + +import asyncio +from typing import Any + +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton +import voluptuous as vol + +from homeassistant.components.radio_frequency import ( + async_get_transmitters, + async_send_command, +) +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_CODE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .const import ( + CODE_MAX, + CODE_MIN, + CONF_TRANSMITTER, + DEFAULT_CODE, + DOMAIN, + FREQUENCY, + MODULATION, +) + +_CODE_OPTIONS = [str(code) for code in range(CODE_MIN, CODE_MAX + 1)] +_TOGGLE_GAP = 1.5 + + +class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Novy Cooker Hood.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self._transmitter_entity_id: str | None = None + self._transmitter_id: str | None = None + self._code: int = DEFAULT_CODE + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick a transmitter and code for a new entry.""" + return await self._async_step_picker("user", user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick a transmitter and code to update an existing entry.""" + if user_input is None and self._transmitter_entity_id is None: + entry = self._get_reconfigure_entry() + transmitter = er.async_get(self.hass).async_get( + entry.data[CONF_TRANSMITTER] + ) + self._transmitter_entity_id = transmitter.entity_id if transmitter else None + self._code = entry.data[CONF_CODE] + return await self._async_step_picker("reconfigure", user_input) + + async def _async_step_picker( + self, step_id: str, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Show the transmitter+code picker shared by user and reconfigure steps.""" + try: + transmitters = async_get_transmitters(self.hass, FREQUENCY, MODULATION) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort( + reason="no_compatible_transmitters", + description_placeholders={ + "frequency": f"{FREQUENCY / 1_000_000} MHz", + "modulation": MODULATION.name, + }, + ) + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + code = int(user_input[CONF_CODE]) + unique_id = f"{entity_entry.id}_{code}" + await self.async_set_unique_id(unique_id) + if self.source == SOURCE_RECONFIGURE: + existing = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, unique_id + ) + reconfigure_entry = self._get_reconfigure_entry() + if existing and existing.entry_id != reconfigure_entry.entry_id: + return self.async_abort(reason="already_configured") + else: + self._abort_if_unique_id_configured() + self._transmitter_entity_id = entity_entry.entity_id + self._transmitter_id = entity_entry.id + self._code = code + return await self.async_step_test_light() + + schema: dict[Any, Any] = { + vol.Required( + CONF_TRANSMITTER, + default=self._transmitter_entity_id or vol.UNDEFINED, + ): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + vol.Required(CONF_CODE, default=str(self._code)): selector.SelectSelector( + selector.SelectSelectorConfig( + options=_CODE_OPTIONS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="code", + ) + ), + } + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema(schema), + ) + + async def async_step_test_light( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Toggle the hood light on then off so it ends in its starting state.""" + assert self._transmitter_entity_id is not None + command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) + try: + await async_send_command(self.hass, self._transmitter_entity_id, command) + await asyncio.sleep(_TOGGLE_GAP) + await async_send_command(self.hass, self._transmitter_entity_id, command) + except HomeAssistantError: + return await self.async_step_test_failed() + return self.async_show_menu( + step_id="test_light", + menu_options=["finish", "retry"], + description_placeholders={"code": str(self._code)}, + ) + + async def async_step_test_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Re-show the failure menu (only Retry available).""" + return self.async_show_menu( + step_id="test_failed", + menu_options=["retry"], + description_placeholders={"code": str(self._code)}, + ) + + async def async_step_retry( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Return to the picker step matching the current source.""" + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure() + return await self.async_step_user() + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create or update the config entry.""" + assert self._transmitter_id is not None + data = {CONF_TRANSMITTER: self._transmitter_id, CONF_CODE: self._code} + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=data, + unique_id=f"{self._transmitter_id}_{self._code}", + ) + return self.async_create_entry(title="Novy Cooker Hood", data=data) diff --git a/homeassistant/components/novy_cooker_hood/const.py b/homeassistant/components/novy_cooker_hood/const.py new file mode 100644 index 00000000000..8cbf129f9d6 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -0,0 +1,18 @@ +"""Constants for the Novy Cooker Hood integration.""" + +from typing import Final + +from rf_protocols import ModulationType + +DOMAIN: Final = "novy_cooker_hood" + +CONF_TRANSMITTER: Final = "transmitter" + +CODE_MIN: Final = 1 +CODE_MAX: Final = 10 +DEFAULT_CODE: Final = 1 + +FREQUENCY: Final = 433_920_000 +MODULATION: Final = ModulationType.OOK + +SPEED_COUNT: Final = 4 diff --git a/homeassistant/components/novy_cooker_hood/diagnostics.py b/homeassistant/components/novy_cooker_hood/diagnostics.py new file mode 100644 index 00000000000..266c19c6233 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for the Novy Cooker Hood integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONF_TRANSMITTER + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + registry = er.async_get(hass) + entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id) + transmitter = registry.async_get(config_entry.data[CONF_TRANSMITTER]) + transmitter_state = hass.states.get(transmitter.entity_id) if transmitter else None + return { + "config_entry": config_entry.as_dict(), + "entities": [ + entity.extended_dict + for entity in sorted(entities, key=lambda entity: entity.entity_id) + ], + "transmitter": { + "entity_id": transmitter.entity_id if transmitter else None, + "state": transmitter_state.as_dict() if transmitter_state else None, + }, + } diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py new file mode 100644 index 00000000000..817102bf754 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -0,0 +1,74 @@ +"""Common entity for the Novy Cooker Hood integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NovyCookerHoodEntity(Entity): + """Novy Cooker Hood base entity.""" + + _attr_assumed_state = True + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Novy", + model="Cooker Hood", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py new file mode 100644 index 00000000000..3243f8a7113 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,144 @@ +"""Fan platform for the Novy Cooker Hood (calibrated speed control).""" + +import math +from typing import Any + +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton +from rf_protocols.commands.novy import NovyCookerHoodCommand + +from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import SPEED_COUNT +from .entity import NovyCookerHoodEntity + +PARALLEL_UPDATES = 1 + +_SPEED_RANGE = (1, SPEED_COUNT) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood fan platform.""" + async_add_entities([NovyCookerHoodFan(config_entry)]) + + +class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): + """Calibration-based fan: each change resets to off then climbs to target.""" + + _attr_name = None + _attr_speed_count = SPEED_COUNT + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the fan.""" + super().__init__(entry) + self._code: int = entry.data[CONF_CODE] + self._level = 0 + self._attr_unique_id = entry.entry_id + + @property + def is_on(self) -> bool: + """Return whether the fan is currently on.""" + return self._level > 0 + + @property + def percentage(self) -> int: + """Return the current speed as a percentage.""" + if self._level == 0: + return 0 + return ranged_value_to_percentage(_SPEED_RANGE, self._level) + + async def async_added_to_hass(self) -> None: + """Restore the last known speed level from the saved percentage.""" + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is None: + return + last_pct = last.attributes.get(ATTR_PERCENTAGE) + if isinstance(last_pct, (int, float)) and last_pct > 0: + self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on at the requested level (default = 1).""" + if percentage is None or percentage <= 0: + level = 1 + else: + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off by sending the calibration sequence to level 0.""" + await self._async_set_level(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed via calibration.""" + if percentage <= 0: + await self._async_set_level(0) + return + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + async def async_increase_speed(self, percentage_step: int | None = None) -> None: + """Bump speed up by N hardware levels (no recalibration).""" + steps = self._steps_from_percentage(percentage_step) + plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) + for _ in range(steps): + await self._async_send(plus) + self._level = min(SPEED_COUNT, self._level + steps) + self.async_write_ha_state() + + async def async_decrease_speed(self, percentage_step: int | None = None) -> None: + """Bump speed down by N hardware levels (no recalibration).""" + steps = self._steps_from_percentage(percentage_step) + minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) + for _ in range(steps): + await self._async_send(minus) + self._level = max(0, self._level - steps) + self.async_write_ha_state() + + @staticmethod + def _steps_from_percentage(percentage_step: int | None) -> int: + """Convert a percentage step into a number of hardware level presses.""" + if percentage_step is None: + return 1 + return math.ceil(percentage_step * SPEED_COUNT / 100) + + async def _async_set_level(self, level: int) -> None: + """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" + minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) + for _ in range(SPEED_COUNT): + await self._async_send(minus) + if level > 0: + plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) + for _ in range(level): + await self._async_send(plus) + self._level = level + self.async_write_ha_state() + + async def _async_send(self, command: NovyCookerHoodCommand) -> None: + """Send a single RF command via the configured transmitter.""" + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py new file mode 100644 index 00000000000..db31075de8c --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -0,0 +1,65 @@ +"""Light platform for the Novy Cooker Hood.""" + +from typing import Any + +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .entity import NovyCookerHoodEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood light platform.""" + async_add_entities([NovyCookerHoodLight(config_entry)]) + + +class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): + """Novy cooker hood light toggled via a single RF press.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the light.""" + super().__init__(entry) + self._code = entry.data[CONF_CODE] + self._attr_unique_id = entry.entry_id + + async def async_added_to_hass(self) -> None: + """Restore the last known on/off state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on by sending the toggle command.""" + await self._async_send_light() + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off by sending the toggle command.""" + await self._async_send_light() + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send_light(self) -> None: + """Send the light toggle command via the configured transmitter.""" + command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/manifest.json b/homeassistant/components/novy_cooker_hood/manifest.json new file mode 100644 index 00000000000..9589b4c308f --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "novy_cooker_hood", + "name": "Novy Cooker Hood", + "codeowners": ["@piitaya"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/novy_cooker_hood", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "gold" +} diff --git a/homeassistant/components/novy_cooker_hood/quality_scale.yaml b/homeassistant/components/novy_cooker_hood/quality_scale.yaml new file mode 100644 index 00000000000..9490fc0caea --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: done + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact at setup. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The light entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The light entity represents the primary device function. + entity-translations: done + exception-translations: + status: exempt + comment: | + The integration does not raise its own exceptions. Underlying transmit + errors already surface as translated `HomeAssistantError`s from the + `radio_frequency` integration. + icon-translations: + status: exempt + comment: | + The light entity uses the default icon for its state. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json new file mode 100644 index 00000000000..3ecaa9392a5 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -0,0 +1,72 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "[%key:common::config_flow::abort::no_compatible_radio_frequency_transmitters%]", + "no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "step": { + "reconfigure": { + "data": { + "code": "[%key:component::novy_cooker_hood::config::step::user::data::code%]", + "transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]" + }, + "data_description": { + "code": "[%key:component::novy_cooker_hood::config::step::user::data_description::code%]", + "transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]" + }, + "description": "[%key:component::novy_cooker_hood::config::step::user::description%]" + }, + "test_failed": { + "description": "Could not send the test command for code {code}. Check that your radio frequency transmitter is online, then press Retry.", + "menu_options": { + "retry": "Retry" + }, + "title": "Test failed" + }, + "test_light": { + "description": "Toggled the hood light on and off using code {code}. Did you see it react? Press Finish to save, or Retry to pick a different code.", + "menu_options": { + "finish": "Finish", + "retry": "Retry" + }, + "title": "Verify the code" + }, + "user": { + "data": { + "code": "Code", + "transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]" + }, + "data_description": { + "code": "The code your hood is paired with (1-10). Code 1 is the factory default.", + "transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]" + }, + "description": "After you submit, Home Assistant will toggle the hood light on and off to verify the code works." + } + } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } + }, + "selector": { + "code": { + "options": { + "1": "Code 1", + "2": "Code 2", + "3": "Code 3", + "4": "Code 4", + "5": "Code 5", + "6": "Code 6", + "7": "Code 7", + "8": "Code 8", + "9": "Code 9", + "10": "Code 10" + } + } + } +} diff --git a/homeassistant/components/nrgkick/__init__.py b/homeassistant/components/nrgkick/__init__.py index 974a6ba0622..d888c69fb41 100644 --- a/homeassistant/components/nrgkick/__init__.py +++ b/homeassistant/components/nrgkick/__init__.py @@ -1,7 +1,5 @@ """The NRGkick integration.""" -from __future__ import annotations - from nrgkick_api import NRGkickAPI from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform diff --git a/homeassistant/components/nrgkick/api.py b/homeassistant/components/nrgkick/api.py index 09c09363db8..5485aa04c5e 100644 --- a/homeassistant/components/nrgkick/api.py +++ b/homeassistant/components/nrgkick/api.py @@ -1,7 +1,5 @@ """API helpers and Home Assistant exceptions for the NRGkick integration.""" -from __future__ import annotations - from collections.abc import Awaitable import aiohttp diff --git a/homeassistant/components/nrgkick/binary_sensor.py b/homeassistant/components/nrgkick/binary_sensor.py index 41794f31730..750ff654dec 100644 --- a/homeassistant/components/nrgkick/binary_sensor.py +++ b/homeassistant/components/nrgkick/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for NRGkick.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/nrgkick/config_flow.py b/homeassistant/components/nrgkick/config_flow.py index b99402ab600..5a7c6a3b3eb 100644 --- a/homeassistant/components/nrgkick/config_flow.py +++ b/homeassistant/components/nrgkick/config_flow.py @@ -1,7 +1,5 @@ """Config flow for NRGkick integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/nrgkick/coordinator.py b/homeassistant/components/nrgkick/coordinator.py index d9cc6c99669..0614ae4b692 100644 --- a/homeassistant/components/nrgkick/coordinator.py +++ b/homeassistant/components/nrgkick/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for NRGkick integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/nrgkick/device_tracker.py b/homeassistant/components/nrgkick/device_tracker.py index 5e995e5f35c..970a1b08305 100644 --- a/homeassistant/components/nrgkick/device_tracker.py +++ b/homeassistant/components/nrgkick/device_tracker.py @@ -1,11 +1,8 @@ """Device tracker platform for NRGkick.""" -from __future__ import annotations - from typing import Any, Final -from homeassistant.components.device_tracker import SourceType -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -44,7 +41,6 @@ class NRGkickDeviceTracker(NRGkickEntity, TrackerEntity): """Representation of a NRGkick GPS device tracker.""" _attr_translation_key = TRACKER_KEY - _attr_source_type = SourceType.GPS def __init__( self, diff --git a/homeassistant/components/nrgkick/diagnostics.py b/homeassistant/components/nrgkick/diagnostics.py index c9b9716a212..dbe928cf9c2 100644 --- a/homeassistant/components/nrgkick/diagnostics.py +++ b/homeassistant/components/nrgkick/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for NRGkick.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/nrgkick/entity.py b/homeassistant/components/nrgkick/entity.py index 30b82b4ff78..2e3686b2fe4 100644 --- a/homeassistant/components/nrgkick/entity.py +++ b/homeassistant/components/nrgkick/entity.py @@ -1,7 +1,5 @@ """Base entity for NRGkick integration.""" -from __future__ import annotations - from collections.abc import Awaitable from typing import Any diff --git a/homeassistant/components/nrgkick/number.py b/homeassistant/components/nrgkick/number.py index aff9ccfc494..a426a47714f 100644 --- a/homeassistant/components/nrgkick/number.py +++ b/homeassistant/components/nrgkick/number.py @@ -1,7 +1,5 @@ """Number platform for NRGkick.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/nrgkick/quality_scale.yaml b/homeassistant/components/nrgkick/quality_scale.yaml index 7bdc82b665b..f2a89caff61 100644 --- a/homeassistant/components/nrgkick/quality_scale.yaml +++ b/homeassistant/components/nrgkick/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: done repair-issues: todo diff --git a/homeassistant/components/nrgkick/sensor.py b/homeassistant/components/nrgkick/sensor.py index cfbd9a9ec9d..34d88bb4780 100644 --- a/homeassistant/components/nrgkick/sensor.py +++ b/homeassistant/components/nrgkick/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for NRGkick.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/nrgkick/switch.py b/homeassistant/components/nrgkick/switch.py index ff52f80e14e..d1925cea061 100644 --- a/homeassistant/components/nrgkick/switch.py +++ b/homeassistant/components/nrgkick/switch.py @@ -1,7 +1,5 @@ """Switch platform for NRGkick.""" -from __future__ import annotations - from typing import Any from nrgkick_api.const import CONTROL_KEY_CHARGE_PAUSE diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index b1065d755f6..fce3fa50df2 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -1,7 +1,5 @@ """The nsw_fuel_station component.""" -from __future__ import annotations - from nsw_fuel import FuelCheckClient from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/nsw_fuel_station/coordinator.py b/homeassistant/components/nsw_fuel_station/coordinator.py index c089e01aeea..662e87f0f34 100644 --- a/homeassistant/components/nsw_fuel_station/coordinator.py +++ b/homeassistant/components/nsw_fuel_station/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the NSW Fuel Station integration.""" -from __future__ import annotations - from dataclasses import dataclass import datetime import logging diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 37e3e24b932..0583de9ab6a 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -1,7 +1,5 @@ """Sensor platform to display the current fuel prices at a NSW fuel station.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index 98efa90d780..ffe3e23390d 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -1,7 +1,5 @@ """Support for NSW Rural Fire Service Feeds.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index fc1196ebde7..07cfe84fbed 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -1,7 +1,5 @@ """The ntfy integration.""" -from __future__ import annotations - import logging from aiontfy import Ntfy diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index 5f168c977c4..8cf78bae3ec 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the ntfy integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import random @@ -20,10 +18,13 @@ from yarl import URL from homeassistant import data_entry_flow from homeassistant.config_entries import ( + SOURCE_USER, ConfigEntry, ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, + FlowType, + SubentryFlowContext, SubentryFlowResult, ) from homeassistant.const import ( @@ -56,6 +57,7 @@ from .const import ( DOMAIN, SECTION_AUTH, SECTION_FILTER, + SUBENTRY_TYPE_TOPIC, ) _LOGGER = logging.getLogger(__name__) @@ -146,6 +148,8 @@ TOPIC_FILTER_SCHEMA = vol.Schema( STEP_USER_TOPIC_SCHEMA = vol.Schema( { vol.Required(CONF_TOPIC): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME): str, vol.Required(SECTION_FILTER): data_entry_flow.section( TOPIC_FILTER_SCHEMA, @@ -166,7 +170,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"topic": TopicSubentryFlowHandler} + return {SUBENTRY_TYPE_TOPIC: TopicSubentryFlowHandler} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -231,6 +235,18 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Start subentry flow after creating main entry.""" + subentry_result = await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, SUBENTRY_TYPE_TOPIC), + context=SubentryFlowContext(source=SOURCE_USER), + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_result["flow_id"], + ) + return result + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -287,7 +303,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): "wrong_username": account.username, }, ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( entry, data_updates={CONF_TOKEN: token}, ) @@ -350,7 +366,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( entry, data_updates={CONF_TOKEN: token}, ) @@ -360,7 +376,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: account.username, } ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( entry, data_updates={ CONF_USERNAME: account.username, diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py index 753a46bdae7..58ddde12302 100644 --- a/homeassistant/components/ntfy/const.py +++ b/homeassistant/components/ntfy/const.py @@ -13,3 +13,5 @@ CONF_TAGS = "filter_tags" SECTION_AUTH = "auth" SECTION_FILTER = "filter" NTFY_EVENT = "ntfy_event" + +SUBENTRY_TYPE_TOPIC = "topic" diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py index 2421b6b8061..432bf6391fc 100644 --- a/homeassistant/components/ntfy/coordinator.py +++ b/homeassistant/components/ntfy/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for ntfy integration.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/ntfy/diagnostics.py b/homeassistant/components/ntfy/diagnostics.py index 5be239dfef6..7e2b3062920 100644 --- a/homeassistant/components/ntfy/diagnostics.py +++ b/homeassistant/components/ntfy/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for ntfy integration.""" -from __future__ import annotations - from typing import Any from yarl import URL diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py index 856303cd60d..75e1ec1d943 100644 --- a/homeassistant/components/ntfy/entity.py +++ b/homeassistant/components/ntfy/entity.py @@ -1,7 +1,5 @@ """Base entity for ntfy integration.""" -from __future__ import annotations - from yarl import URL from homeassistant.config_entries import ConfigSubentry @@ -28,7 +26,11 @@ class NtfyBaseEntity(Entity): """Initialize the entity.""" self.topic = subentry.data[CONF_TOPIC] - self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + self._attr_unique_id = ( + f"{config_entry.entry_id}" + f"_{subentry.subentry_id}" + f"_{self.entity_description.key}" + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index 8f5d8d7b621..7b7bdfe8e2f 100644 --- a/homeassistant/components/ntfy/event.py +++ b/homeassistant/components/ntfy/event.py @@ -1,7 +1,5 @@ """Event platform for ntfy integration.""" -from __future__ import annotations - import asyncio import logging from typing import TYPE_CHECKING @@ -125,7 +123,8 @@ class NtfyEventEntity(NtfyBaseEntity, EventEntity): except NtfyHTTPError as e: if self._attr_available: _LOGGER.error( - "Failed to connect to ntfy service due to a server error: %s (%s)", + "Failed to connect to ntfy service" + " due to a server error: %s (%s)", e.error, e.link, ) @@ -145,7 +144,8 @@ class NtfyEventEntity(NtfyBaseEntity, EventEntity): except Exception: if self._attr_available: _LOGGER.exception( - "Failed to connect to ntfy service due to an unexpected exception" + "Failed to connect to ntfy service" + " due to an unexpected exception" ) self._attr_available = False finally: diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index f033f1e8369..c59fac55a88 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.4"] + "requirements": ["aiontfy==0.8.5"] } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index d23ebcc8b16..35d1147b596 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -1,7 +1,5 @@ """ntfy notification entity.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -65,11 +63,16 @@ class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity): _attr_supported_features = NotifyEntityFeature.TITLE async def async_send_message(self, message: str, title: str | None = None) -> None: - """Publish a message to a topic.""" - await self.publish(message=message, title=title) + """Publish a message to a topic via notify.send_message action.""" + await self._publish(message=message, title=title) async def publish(self, **kwargs: Any) -> None: - """Publish a message to a topic.""" + """Publish a message to a topic via ntfy.publish action.""" + await self._publish(**kwargs) + self._async_record_notification() + + async def _publish(self, **kwargs: Any) -> None: + """Shared internal helper to publish a message to a topic.""" attachment = None params: dict[str, Any] = kwargs delay: timedelta | None = params.get("delay") diff --git a/homeassistant/components/ntfy/repairs.py b/homeassistant/components/ntfy/repairs.py index e87ca3ddcad..6f8f59d4caf 100644 --- a/homeassistant/components/ntfy/repairs.py +++ b/homeassistant/components/ntfy/repairs.py @@ -1,11 +1,12 @@ """Repairs for ntfy integration.""" -from __future__ import annotations - import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,14 +23,14 @@ class TopicProtectedRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Init repair flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Confirm repair flow.""" if user_input is not None: er.async_get(self.hass).async_update_entity( diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py index 89a30493c1f..bb85c79ddee 100644 --- a/homeassistant/components/ntfy/sensor.py +++ b/homeassistant/components/ntfy/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for ntfy integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/ntfy/services.py b/homeassistant/components/ntfy/services.py index 45d87e5b9bb..b5fe790851f 100644 --- a/homeassistant/components/ntfy/services.py +++ b/homeassistant/components/ntfy/services.py @@ -12,6 +12,7 @@ from homeassistant.components.notify import ( ATTR_TITLE, DOMAIN as NOTIFY_DOMAIN, ) +from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.selector import MediaSelector @@ -26,7 +27,6 @@ ATTR_CALL = "call" ATTR_CLICK = "click" ATTR_DELAY = "delay" ATTR_EMAIL = "email" -ATTR_ICON = "icon" ATTR_MARKDOWN = "markdown" ATTR_PRIORITY = "priority" ATTR_TAGS = "tags" diff --git a/homeassistant/components/ntfy/update.py b/homeassistant/components/ntfy/update.py index 039be5a5096..445a0b5da3d 100644 --- a/homeassistant/components/ntfy/update.py +++ b/homeassistant/components/ntfy/update.py @@ -1,7 +1,5 @@ """Update platform for the ntfy integration.""" -from __future__ import annotations - from enum import StrEnum from homeassistant.components.update import ( diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index ae7f9fb4140..051b5c0a428 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,10 +1,9 @@ """The nuki component.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging +from typing import Any from aiohttp import web from pynuki import NukiBridge, NukiLock, NukiOpener @@ -38,6 +37,15 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +def _get_bridge_data( + host: str, token: str, port: int, encrypted_token: bool +) -> tuple[NukiBridge, list[NukiLock], list[NukiOpener], dict[str, Any]]: + """Get Nuki bridge and bridge data.""" + bridge = NukiBridge(host, token, port, encrypted_token, DEFAULT_TIMEOUT) + locks, openers = _get_bridge_devices(bridge) + return bridge, locks, openers, bridge.info() + + async def _create_webhook( hass: HomeAssistant, entry: NukiConfigEntry, bridge: NukiBridge ) -> None: @@ -155,23 +163,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: NukiConfigEntry) -> bool hass.config_entries.async_update_entry(entry, **params) try: - bridge = await hass.async_add_executor_job( - NukiBridge, + bridge, locks, openers, info = await hass.async_add_executor_job( + _get_bridge_data, entry.data[CONF_HOST], entry.data[CONF_TOKEN], entry.data[CONF_PORT], entry.data.get(CONF_ENCRYPT_TOKEN, True), - DEFAULT_TIMEOUT, ) - - locks, openers = await hass.async_add_executor_job(_get_bridge_devices, bridge) except InvalidCredentialsException as err: raise exceptions.ConfigEntryAuthFailed from err except RequestException as err: raise exceptions.ConfigEntryNotReady from err # Device registration for the bridge - info = bridge.info() bridge_id = parse_id(info["ids"]["hardwareId"]) dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 247ebfe0d71..10e8360dc1d 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -1,7 +1,5 @@ """Doorsensor Support for the Nuki Lock.""" -from __future__ import annotations - from pynuki.constants import STATE_DOORSENSOR_OPENED from pynuki.device import NukiDevice diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index f170d56feda..4da76fb0c91 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -35,6 +35,18 @@ REAUTH_SCHEMA = vol.Schema( ) +def _get_bridge_info(data: dict[str, Any]) -> dict[str, Any]: + """Get Nuki bridge info.""" + bridge = NukiBridge( + data[CONF_HOST], + data[CONF_TOKEN], + data[CONF_PORT], + data.get(CONF_ENCRYPT_TOKEN, True), + DEFAULT_TIMEOUT, + ) + return bridge.info() + + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. @@ -42,16 +54,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """ try: - bridge = await hass.async_add_executor_job( - NukiBridge, - data[CONF_HOST], - data[CONF_TOKEN], - data[CONF_PORT], - data.get(CONF_ENCRYPT_TOKEN, True), - DEFAULT_TIMEOUT, - ) - - info = await hass.async_add_executor_job(bridge.info) + info = await hass.async_add_executor_job(_get_bridge_info, data) except InvalidCredentialsException as err: raise InvalidAuth from err except RequestException as err: diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py index 36bed1b5d46..f089556bec5 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the nuki component.""" -from __future__ import annotations - import asyncio from collections import defaultdict from dataclasses import dataclass @@ -99,7 +97,8 @@ class NukiCoordinator(DataUpdateCoordinator[None]): """Update the Nuki devices. Returns: - A dict with the events to be fired. The event type is the key and the device ids are the value + A dict with the events to be fired. The event + type is the key and the device ids are the value """ diff --git a/homeassistant/components/nuki/entity.py b/homeassistant/components/nuki/entity.py index 2de1827c416..23b3e0ca527 100644 --- a/homeassistant/components/nuki/entity.py +++ b/homeassistant/components/nuki/entity.py @@ -1,7 +1,5 @@ """The nuki component.""" -from __future__ import annotations - from pynuki.device import NukiDevice from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 8ff36ba6f91..dbaa15f8666 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,7 +1,5 @@ """Nuki.io lock platform.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 0f2a49a8b5e..1cba7477eb9 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -1,7 +1,5 @@ """Battery sensor for the Nuki Lock.""" -from __future__ import annotations - from pynuki.device import NukiDevice from homeassistant.components.sensor import ( diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index d3882bea290..c00e2ff2090 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_BINARY_SENSORS, + CONF_DEVICES, CONF_ID, CONF_NAME, CONF_SENSORS, @@ -28,7 +29,6 @@ DOMAIN = "numato" CONF_INVERT_LOGIC = "invert_logic" CONF_DISCOVER = "discover" -CONF_DEVICES = "devices" CONF_DEVICE_ID = "id" CONF_PORTS = "ports" CONF_SRC_RANGE = "source_range" diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index c1c251e0074..1057e526e9b 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -1,14 +1,12 @@ """Binary sensor platform integration for Numato USB GPIO expanders.""" -from __future__ import annotations - from functools import partial import logging from numato_gpio import NumatoGpioError from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.const import CONF_DEVICES, DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,7 +14,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( CONF_BINARY_SENSORS, - CONF_DEVICES, CONF_ID, CONF_INVERT_LOGIC, CONF_PORTS, diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 99ef69baa7b..a443facbc9f 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -1,19 +1,16 @@ """Sensor platform integration for ADC ports of Numato USB GPIO expanders.""" -from __future__ import annotations - import logging from numato_gpio import NumatoGpioError from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_ID, CONF_NAME, CONF_SENSORS +from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME, CONF_SENSORS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( - CONF_DEVICES, CONF_DST_RANGE, CONF_DST_UNIT, CONF_PORTS, diff --git a/homeassistant/components/numato/switch.py b/homeassistant/components/numato/switch.py index 0a7522c8b11..72756050d38 100644 --- a/homeassistant/components/numato/switch.py +++ b/homeassistant/components/numato/switch.py @@ -1,7 +1,5 @@ """Switch platform integration for Numato USB GPIO expanders.""" -from __future__ import annotations - import logging from typing import Any @@ -88,6 +86,7 @@ class NumatoGpioSwitch(SwitchEntity): ) self._attr_is_on = True self.schedule_update_ha_state() + # pylint: disable-next=home-assistant-action-swallowed-exception except NumatoGpioError as err: _LOGGER.error( "Failed to turn on Numato device %s port %s: %s", @@ -104,6 +103,7 @@ class NumatoGpioSwitch(SwitchEntity): ) self._attr_is_on = False self.schedule_update_ha_state() + # pylint: disable-next=home-assistant-action-swallowed-exception except NumatoGpioError as err: _LOGGER.error( "Failed to turn off Numato device %s port %s: %s", diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index b30c9425b0a..3cf49182597 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -1,7 +1,5 @@ """Component to allow numeric input for platforms.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress import dataclasses @@ -406,9 +404,12 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): if native_unit_of_measurement is not None: raise ValueError( - f"Number entity {type(self)} from integration '{self.platform.platform_name}' " - f"has a translation key for unit_of_measurement '{unit_of_measurement}', " - f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + f"Number entity {type(self)} from integration" + f" '{self.platform.platform_name}' has a" + " translation key for unit_of_measurement" + f" '{unit_of_measurement}', but also has a" + " native_unit_of_measurement" + f" '{native_unit_of_measurement}'" ) return unit_of_measurement diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 30dafa575f4..d4b58d77dfc 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -1,7 +1,5 @@ """Provides the constants needed for the component.""" -from __future__ import annotations - from enum import StrEnum from typing import Final @@ -60,6 +58,7 @@ from homeassistant.util.unit_conversion import ( ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -150,7 +149,8 @@ class NumberDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³` + Unit of measurement: `ppb` (parts per billion), + `ppm` (parts per million), `mg/m³`, `μg/m³` """ CO2 = "carbon_dioxide" @@ -168,7 +168,7 @@ class NumberDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -200,14 +200,17 @@ class NumberDeviceClass(StrEnum): ENERGY = "energy" """Energy. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, + `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, + `Mcal`, `Gcal` """ ENERGY_DISTANCE = "energy_distance" """Energy distance. - Use this device class for sensors measuring energy by distance, for example the amount - of electric energy consumed by an electric car. + Use this device class for sensors measuring energy by + distance, for example the amount of electric energy + consumed by an electric car. Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` """ @@ -215,10 +218,14 @@ class NumberDeviceClass(StrEnum): ENERGY_STORAGE = "energy_storage" """Stored energy. - Use this device class for sensors measuring stored energy, for example the amount - of electric energy currently stored in a battery or the capacity of a battery. + Use this device class for sensors measuring stored + energy, for example the amount of electric energy + currently stored in a battery or the capacity of a + battery. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, + `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, + `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -436,20 +443,22 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `VOLUME_*` units - SI / metric: `mL`, `L`, `m³` - - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in - USCS/imperial units are currently assumed to be US volumes) + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, + `gal` (warning: volumes expressed in USCS/imperial + units are currently assumed to be US volumes) """ VOLUME_STORAGE = "volume_storage" """Generic stored volume. - Use this device class for sensors measuring stored volume, for example the amount - of fuel in a fuel tank. + Use this device class for sensors measuring stored + volume, for example the amount of fuel in a fuel tank. Unit of measurement: `VOLUME_*` units - SI / metric: `mL`, `L`, `m³` - - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in - USCS/imperial units are currently assumed to be US volumes) + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, + `gal` (warning: volumes expressed in USCS/imperial + units are currently assumed to be US volumes) """ VOLUME_FLOW_RATE = "volume_flow_rate" @@ -629,6 +638,7 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.ENERGY: EnergyConverter, NumberDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, NumberDeviceClass.ENERGY_STORAGE: EnergyConverter, + NumberDeviceClass.FREQUENCY: FrequencyConverter, NumberDeviceClass.GAS: VolumeConverter, NumberDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter, NumberDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter, diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 6dd85e000bd..e67720a5858 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Number.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/number/reproduce_state.py b/homeassistant/components/number/reproduce_state.py index e92573fb40e..160b58c049b 100644 --- a/homeassistant/components/number/reproduce_state.py +++ b/homeassistant/components/number/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce a Number entity state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/number/significant_change.py b/homeassistant/components/number/significant_change.py index c8a3a1d7270..8959f04ff2e 100644 --- a/homeassistant/components/number/significant_change.py +++ b/homeassistant/components/number/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Number state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.const import ( diff --git a/homeassistant/components/number/websocket_api.py b/homeassistant/components/number/websocket_api.py index 5c8730c9eaa..59be04345e4 100644 --- a/homeassistant/components/number/websocket_api.py +++ b/homeassistant/components/number/websocket_api.py @@ -1,7 +1,5 @@ """The sensor websocket API.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 90daacaaa34..f5a83b03f2e 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -1,7 +1,5 @@ """The nut component.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py index 7f4a5cdf073..8f4e25deb20 100644 --- a/homeassistant/components/nut/button.py +++ b/homeassistant/components/nut/button.py @@ -1,7 +1,5 @@ """Provides a switch for switchable NUT outlets.""" -from __future__ import annotations - import logging from homeassistant.components.button import ( @@ -46,7 +44,6 @@ async def async_setup_entry( translation_key="outlet_number_load_cycle", translation_placeholders={"outlet_name": outlet_name}, device_class=ButtonDeviceClass.RESTART, - entity_registry_enabled_default=True, ), } diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 8a498b99680..edc2ee155e9 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Network UPS Tools (NUT) integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 175e971a12a..93c1f47944a 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -1,7 +1,5 @@ """The nut component.""" -from __future__ import annotations - from homeassistant.const import Platform DOMAIN = "nut" diff --git a/homeassistant/components/nut/coordinator.py b/homeassistant/components/nut/coordinator.py index 4ecfb9f3f90..2a7b2951af7 100644 --- a/homeassistant/components/nut/coordinator.py +++ b/homeassistant/components/nut/coordinator.py @@ -1,7 +1,5 @@ """The NUT coordinator.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index 5d613fa2b74..9e627c0e002 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Network UPS Tools (NUT).""" -from __future__ import annotations - from typing import cast import voluptuous as vol diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index d7a266a5b41..1bda5ab4e4d 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Nut.""" -from __future__ import annotations - from typing import Any import attr diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index 7ade4dcb3bf..c71a8da6c7b 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -1,7 +1,5 @@ """Base entity for the NUT integration.""" -from __future__ import annotations - from dataclasses import asdict from typing import cast diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 8ed64416547..80cb5faa5e3 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,7 +1,5 @@ """Provides a sensor to track various status aspects of a NUT device.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py index 0964a225d02..47fd2f3db07 100644 --- a/homeassistant/components/nut/switch.py +++ b/homeassistant/components/nut/switch.py @@ -1,7 +1,5 @@ """Provides a switch for switchable NUT outlets.""" -from __future__ import annotations - import logging from typing import Any @@ -47,7 +45,6 @@ async def async_setup_entry( or str(outlet_num) }, device_class=SwitchDeviceClass.OUTLET, - entity_registry_enabled_default=True, ) for outlet_num in range(1, int(num_outlets) + 1) if ( diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 633619bcf05..de620ce1ad9 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -1,7 +1,5 @@ """The National Weather Service integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime diff --git a/homeassistant/components/nws/config_flow.py b/homeassistant/components/nws/config_flow.py index 22a4adf3d85..77c2a3f9626 100644 --- a/homeassistant/components/nws/config_flow.py +++ b/homeassistant/components/nws/config_flow.py @@ -1,7 +1,5 @@ """Config flow for National Weather Service (NWS) integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 80e2d0b237a..762ec68ad70 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -1,7 +1,5 @@ """Constants for National Weather Service Integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index 4e6560947e8..d1e0002b4c2 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -1,7 +1,5 @@ """The NWS coordinator.""" -from __future__ import annotations - from datetime import datetime import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/nws/diagnostics.py b/homeassistant/components/nws/diagnostics.py index 230991d04df..251b26949d8 100644 --- a/homeassistant/components/nws/diagnostics.py +++ b/homeassistant/components/nws/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for NWS.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 348d9ade7a3..341e7242e19 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -1,7 +1,5 @@ """Sensors for National Weather Service (NWS).""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index c44869939ff..cf7660ee74a 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,7 +1,5 @@ """Support for NWS weather service.""" -from __future__ import annotations - from collections.abc import Mapping from functools import partial from typing import Any, Required, TypedDict, cast diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index 6622eec530f..fee4dc5fb3f 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for NX584 alarm control panels.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index b3292bde64c..3c0301b4c9c 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -1,7 +1,5 @@ """Support for exposing NX584 elements as sensors.""" -from __future__ import annotations - import logging import threading import time diff --git a/homeassistant/components/nyt_games/__init__.py b/homeassistant/components/nyt_games/__init__.py index d1c6ca5c2a4..c0c2423a72c 100644 --- a/homeassistant/components/nyt_games/__init__.py +++ b/homeassistant/components/nyt_games/__init__.py @@ -1,7 +1,5 @@ """The NYT Games integration.""" -from __future__ import annotations - from nyt_games import NYTGamesClient from homeassistant.const import CONF_TOKEN, Platform diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index ae9ea4f03a0..f5c11672cd2 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching NYT Games data.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index c13333f7a94..fb43625037f 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -1,7 +1,5 @@ """Config flow for NZBGet.""" -from __future__ import annotations - import logging from typing import Any @@ -17,8 +15,16 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.data_entry_flow import SectionConfig, section -from .const import DEFAULT_NAME, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .const import ( + CONF_MORE_OPTIONS, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, +) from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) @@ -53,8 +59,10 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if CONF_VERIFY_SSL not in user_input: - user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) + user_input[CONF_VERIFY_SSL] = more_options.get( + CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL + ) try: await self.hass.async_add_executor_job(_validate_input, user_input) @@ -69,22 +77,31 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - data_schema = { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, - } - - if self.show_advanced_options: - data_schema[vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL)] = ( - bool - ) + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): bool, + } + ), + SectionConfig(collapsed=True), + ), + } + ) return self.async_show_form( step_id="user", - data_schema=vol.Schema(data_schema), + data_schema=data_schema, errors=errors or {}, ) diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index cc704e9ae86..238ed437b93 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -6,6 +6,7 @@ DOMAIN = "nzbget" ATTR_SPEED = "speed" # Defaults +CONF_MORE_OPTIONS = "more_options" DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 DEFAULT_SPEED_LIMIT = 1000 # 1 Megabyte/Sec diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 65d01aebf52..641c6967609 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,7 +1,5 @@ """Monitor the NZBGet API.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index 83b1c8a7ec0..76698efec22 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -15,8 +15,15 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", - "username": "[%key:common::config_flow::data::username%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "username": "[%key:common::config_flow::data::username%]" + }, + "sections": { + "more_options": { + "data": { + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "name": "More options" + } }, "title": "Connect to NZBGet" } diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index 05373345494..0963f9e51c0 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -1,7 +1,5 @@ """Support for NZBGet switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json index 7365081a959..194c481b6f1 100644 --- a/homeassistant/components/oasa_telematics/manifest.json +++ b/homeassistant/components/oasa_telematics/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["oasatelematics"], "quality_scale": "legacy", - "requirements": ["oasatelematics==0.3"] + "requirements": ["oasatelematics==0.4"] } diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 920af78b4ee..80cf85e4e4f 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -1,7 +1,5 @@ """Support for OASA Telematics from telematics.oasa.gr.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from operator import itemgetter diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index f1a244fee42..c9c4419daf5 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -1,7 +1,5 @@ """Obihai button module.""" -from __future__ import annotations - from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 03f6348ebac..7c610b89c24 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Obihai integration.""" -from __future__ import annotations - from socket import gaierror, gethostbyname from typing import Any @@ -45,7 +43,8 @@ async def async_validate_creds( user_input[CONF_PASSWORD], ) - # Don't bother authenticating if we've already determined the credentials are invalid + # Don't bother authenticating if we've already determined + # the credentials are invalid return None diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 1ab3095a5a8..c4db7fa7daf 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -1,7 +1,5 @@ """Support for Obihai Connectivity.""" -from __future__ import annotations - from pyobihai import PyObihai from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 03a11c14001..c3113723b38 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -1,7 +1,5 @@ """Support for Obihai Sensors.""" -from __future__ import annotations - import datetime from requests.exceptions import RequestException diff --git a/homeassistant/components/occupancy/__init__.py b/homeassistant/components/occupancy/__init__.py index d9c1e38fd93..d3f6cc45079 100644 --- a/homeassistant/components/occupancy/__init__.py +++ b/homeassistant/components/occupancy/__init__.py @@ -1,7 +1,5 @@ """Integration for occupancy triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/occupancy/conditions.yaml b/homeassistant/components/occupancy/conditions.yaml index 1f3cb7346b0..98ac1d9a174 100644 --- a/homeassistant/components/occupancy/conditions.yaml +++ b/homeassistant/components/occupancy/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_detected: fields: *condition_common_fields diff --git a/homeassistant/components/occupancy/strings.json b/homeassistant/components/occupancy/strings.json index 062dfa8e336..bd33a97b9eb 100644 --- a/homeassistant/components/occupancy/strings.json +++ b/homeassistant/components/occupancy/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_detected": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::condition_for_name%]" } }, "name": "Occupancy is detected" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::condition_for_name%]" } }, "name": "Occupancy is not detected" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Occupancy", "triggers": { "cleared": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::trigger_for_name%]" } }, "name": "Occupancy cleared" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::trigger_for_name%]" } }, "name": "Occupancy detected" diff --git a/homeassistant/components/occupancy/triggers.yaml b/homeassistant/components/occupancy/triggers.yaml index 9613e28c4ce..2e9a3b93c4f 100644 --- a/homeassistant/components/occupancy/triggers.yaml +++ b/homeassistant/components/occupancy/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: detected: fields: *trigger_common_fields diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index a6c45ef26a3..c955db817fa 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -1,7 +1,5 @@ """Support for monitoring OctoPrint 3D printers.""" -from __future__ import annotations - import logging from typing import cast @@ -220,6 +218,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OctoprintConfigEntry) -> ) if not hass.services.has_service(DOMAIN, SERVICE_CONNECT): + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_CONNECT, @@ -245,7 +244,7 @@ def async_get_client_for_service_call( if device_entry := device_registry.async_get(device_id): for entry_id in device_entry.config_entries: if entry := hass.config_entries.async_get_entry(entry_id): - if entry.domain == DOMAIN and entry.state == ConfigEntryState.LOADED: + if entry.domain == DOMAIN and entry.state is ConfigEntryState.LOADED: return cast(OctoprintConfigEntry, entry).runtime_data.octoprint raise ServiceValidationError( diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index deb3059458f..89da70cf889 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring OctoPrint binary sensors.""" -from __future__ import annotations - from abc import abstractmethod from pyoctoprintapi import OctoprintPrinterInfo diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 118f892ed5b..c3b528d9bfc 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -1,7 +1,5 @@ """Support for OctoPrint binary camera.""" -from __future__ import annotations - from pyoctoprintapi import WebcamSettings from homeassistant.components.mjpeg import MjpegCamera diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index e20eea0a61f..670be1a9c56 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OctoPrint integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -154,6 +152,8 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): except ApiError as err: _LOGGER.error("Failed to connect to printer") raise CannotConnect from err + finally: + await self._sessions.pop().close() await self.async_set_unique_id(discovery.upnp_uuid, raise_on_progress=False) self._abort_if_unique_id_configured() @@ -262,9 +262,12 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): assert self._user_input is not None octoprint = self._get_octoprint_client(self._user_input) - self._user_input[CONF_API_KEY] = await octoprint.request_app_key( - "Home Assistant", self._user_input[CONF_USERNAME], 300 - ) + try: + self._user_input[CONF_API_KEY] = await octoprint.request_app_key( + "Home Assistant", self._user_input[CONF_USERNAME], 300 + ) + finally: + await self._sessions.pop().close() def _get_octoprint_client(self, user_input: dict[str, Any]) -> OctoprintClient: """Build an octoprint client from the user_input.""" @@ -287,11 +290,6 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): path=user_input[CONF_PATH], ) - def async_remove(self) -> None: - """Detach the session.""" - for session in self._sessions: - session.detach() - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index f37fbc82f54..3326389495a 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -1,7 +1,5 @@ """The data update coordinator for OctoPrint.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import cast diff --git a/homeassistant/components/octoprint/number.py b/homeassistant/components/octoprint/number.py index abe27006dfd..3bb51493e86 100644 --- a/homeassistant/components/octoprint/number.py +++ b/homeassistant/components/octoprint/number.py @@ -1,7 +1,5 @@ """Support for OctoPrint number entities.""" -from __future__ import annotations - import logging from pyoctoprintapi import OctoprintClient diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 485126b4828..5a7205983f4 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring OctoPrint sensors.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging @@ -104,7 +102,8 @@ class OctoPrintSensorBase( self._attr_device_info = coordinator.device_info -# Map the strings returned by the OctoPrint API back into values based on the underlying OctoPrint constants. +# Map the strings returned by the OctoPrint API back into values +# based on the underlying OctoPrint constants. # See octoprint.util.comm.MahcineCom.getStateString(): # https://github.com/OctoPrint/OctoPrint/blob/7e7d418dac467e308b24c669a03e8b4256f04b45/src/octoprint/util/comm.py#L965 _API_STATE_VALUE = { @@ -152,7 +151,8 @@ class OctoPrintStatusSensor(OctoPrintSensorBase): if not printer: return None - # Translate the string from the API into an internal state value, or return None (Unknown) if no match + # Translate the string from the API into an internal + # state value, or return None (Unknown) if no match return _API_STATE_VALUE.get(printer.state.text) @property diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index e4bb6141191..8d0b5c05adc 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -1,7 +1,5 @@ """OpenEnergyMonitor Thermostat Support.""" -from __future__ import annotations - from typing import Any from oemthermostat import Thermostat diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index 19000da2104..f548d0bf724 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -1,7 +1,5 @@ """Support for OhmConnect.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 41782ea4a2d..6b986fa68c2 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -1,7 +1,5 @@ """Platform for button.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py index 71ac7e1794f..ff66942781f 100644 --- a/homeassistant/components/ohme/coordinator.py +++ b/homeassistant/components/ohme/coordinator.py @@ -1,7 +1,5 @@ """Ohme coordinators.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py index fe03d335c80..f5a1349f4c2 100644 --- a/homeassistant/components/ohme/diagnostics.py +++ b/homeassistant/components/ohme/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Ohme.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 6982f1ef46c..aec6a587924 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -9,6 +9,9 @@ "preconditioning_duration": { "default": "mdi:fan-clock" }, + "state_of_charge_input": { + "default": "mdi:battery" + }, "target_percentage": { "default": "mdi:battery-heart" } @@ -57,6 +60,9 @@ "state": { "off": "mdi:sleep-off" } + }, + "solar_boost": { + "default": "mdi:solar-power" } }, "time": { diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 192dede3dbc..fb5aee3af79 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["ohme==1.7.1"] + "requirements": ["ohme==1.9.1"] } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index f412c658085..fac14704aad 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -28,6 +28,18 @@ class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): NUMBER_DESCRIPTION = [ + OhmeNumberDescription( + key="state_of_charge_input", + translation_key="state_of_charge_input", + value_fn=lambda client: client.battery, + set_fn=lambda client, value: client.async_set_state_of_charge(int(value)), + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + available_fn=lambda client: client.status.value != "unplugged", + ), OhmeNumberDescription( key="target_percentage", translation_key="target_percentage", diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index d8d9c52c3b6..b12c1c11121 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -1,7 +1,5 @@ """Platform for Ohme selects.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index ac58553d0c6..222cc6c1f78 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index c30a35d26c5..837c581d6fd 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -53,6 +53,9 @@ "preconditioning_duration": { "name": "Preconditioning duration" }, + "state_of_charge_input": { + "name": "State of charge input" + }, "target_percentage": { "name": "Target percentage" } @@ -104,6 +107,9 @@ }, "sleep_when_inactive": { "name": "Sleep when inactive" + }, + "solar_boost": { + "name": "Solar boost" } }, "time": { diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index 47e3bf8a99d..2f92ad4030f 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -68,6 +68,14 @@ SWITCH_DESCRIPTION = [ on_fn=lambda client: client.async_change_price_cap(True), off_fn=lambda client: client.async_change_price_cap(False), ), + OhmeSwitchDescription( + key="solar_boost", + translation_key="solar_boost", + is_supported_fn=lambda client: client.is_capable("solar"), + is_on_fn=lambda client: client.solar_enabled, + on_fn=lambda client: client.async_set_solar_mode(True), + off_fn=lambda client: client.async_set_solar_mode(False), + ), ] diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f95f8c8881f..b0ad4d43341 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -1,7 +1,5 @@ """The Ollama integration.""" -from __future__ import annotations - import asyncio import logging from types import MappingProxyType @@ -10,7 +8,13 @@ import httpx import ollama from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_MODEL, + CONF_PROMPT, + CONF_URL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -28,9 +32,7 @@ from homeassistant.util.ssl import get_default_context from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, - CONF_MODEL, CONF_NUM_CTX, - CONF_PROMPT, CONF_THINK, DEFAULT_AI_TASK_NAME, DEFAULT_NAME, @@ -85,8 +87,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo raise ConfigEntryAuthFailed from err if err.status_code >= 500 or err.status_code == 429: raise ConfigEntryNotReady(err) from err - # If the response is a 4xx error other than 401 or 403, it likely means the URL is valid but not an Ollama instance, - # so we raise ConfigEntryError to show an error in the UI, instead of ConfigEntryNotReady which would just keep retrying. + # If the response is a 4xx error other than 401 or 403, + # it likely means the URL is valid but not an Ollama + # instance, so we raise ConfigEntryError to show an error + # in the UI, instead of ConfigEntryNotReady which would + # just keep retrying. raise ConfigEntryError(err) from err except (TimeoutError, httpx.ConnectError) as err: raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py index 43c50abd16a..1fc6b55b33f 100644 --- a/homeassistant/components/ollama/ai_task.py +++ b/homeassistant/components/ollama/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for Ollama.""" -from __future__ import annotations - from json import JSONDecodeError import logging diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 7222e07bc79..caa8366aff9 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ollama integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -20,7 +18,14 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL +from homeassistant.const import ( + CONF_API_KEY, + CONF_LLM_HASS_API, + CONF_MODEL, + CONF_NAME, + CONF_PROMPT, + CONF_URL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( @@ -42,9 +47,7 @@ from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, - CONF_MODEL, CONF_NUM_CTX, - CONF_PROMPT, CONF_THINK, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, @@ -261,7 +264,7 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle model selection and configuration step.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") if user_input is None: @@ -398,7 +401,7 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow): def filter_invalid_llm_apis(hass: HomeAssistant, selected_apis: list[str]) -> list[str]: - """Accepts a list of LLM API IDs and filters this against those currently available.""" + """Filter a list of LLM API IDs against those available.""" valid_llm_apis = [api.id for api in llm.async_get_apis(hass)] @@ -420,6 +423,8 @@ def ollama_config_option_schema( default_name = DEFAULT_CONVERSATION_NAME schema: dict = { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=default_name): str, } else: diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index ac645145c02..5abf32baf9d 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -4,8 +4,6 @@ DOMAIN = "ollama" DEFAULT_NAME = "Ollama" -CONF_MODEL = "model" -CONF_PROMPT = "prompt" CONF_THINK = "think" CONF_KEEP_ALIVE = "keep_alive" diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index cba8559e826..d1214ded0a2 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -1,17 +1,15 @@ """The conversation platform for the Ollama integration.""" -from __future__ import annotations - from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry -from .const import CONF_PROMPT, DOMAIN +from .const import DOMAIN from .entity import OllamaBaseLLMEntity diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 946f0fea917..6e96b571546 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -1,7 +1,5 @@ """Base entity for the Ollama integration.""" -from __future__ import annotations - from collections.abc import AsyncGenerator, AsyncIterator, Callable import json import logging @@ -13,6 +11,7 @@ from voluptuous_openapi import convert from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -22,7 +21,6 @@ from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, - CONF_MODEL, CONF_NUM_CTX, CONF_THINK, DEFAULT_KEEP_ALIVE, @@ -143,9 +141,11 @@ async def _transform_stream( response: message=Message(role="assistant", content="Paris") response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", content=""), + done: True, done_reason: "stop" response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", content=""), + done: True, done_reason: "stop" This generator conforms to the chatlog delta stream expectations in that it yields deltas, then the role only once the response is done. diff --git a/homeassistant/components/ombi/const.py b/homeassistant/components/ombi/const.py index 6616cd9219d..08469149cd1 100644 --- a/homeassistant/components/ombi/const.py +++ b/homeassistant/components/ombi/const.py @@ -1,7 +1,5 @@ """Support for Ombi.""" -from __future__ import annotations - ATTR_SEASON = "season" CONF_URLBASE = "urlbase" diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index ab9df9ad111..75625d383f5 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -1,7 +1,5 @@ """Support for Ombi.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/omie/__init__.py b/homeassistant/components/omie/__init__.py new file mode 100644 index 00000000000..a0e1334ff4c --- /dev/null +++ b/homeassistant/components/omie/__init__.py @@ -0,0 +1,22 @@ +"""The OMIE - Spain and Portugal electricity prices integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import OMIEConfigEntry, OMIECoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: + """Set up from a config entry.""" + entry.runtime_data = OMIECoordinator(hass, entry) + + await entry.runtime_data.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/omie/config_flow.py b/homeassistant/components/omie/config_flow.py new file mode 100644 index 00000000000..39737c871f3 --- /dev/null +++ b/homeassistant/components/omie/config_flow.py @@ -0,0 +1,40 @@ +"""Config flow for OMIE - Spain and Portugal electricity prices integration.""" + +from typing import Any, Final + +from aiohttp import ClientError +import pyomie.main as pyomie + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .util import CET + +DEFAULT_NAME: Final = "OMIE" + + +class OMIEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """OMIE config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the first and only step.""" + if user_input is not None: + errors: dict[str, str] = {} + session = async_get_clientsession(self.hass) + cet_today = dt_util.now().astimezone(CET).date() + try: + await pyomie.spot_price(session, cet_today) + except ClientError, TimeoutError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + return self.async_show_form(step_id="user", errors=errors) + + return self.async_show_form(step_id="user") diff --git a/homeassistant/components/omie/const.py b/homeassistant/components/omie/const.py new file mode 100644 index 00000000000..f199eeba3d7 --- /dev/null +++ b/homeassistant/components/omie/const.py @@ -0,0 +1,5 @@ +"""Constants for the OMIE - Spain and Portugal electricity prices integration.""" + +from typing import Final + +DOMAIN: Final = "omie" diff --git a/homeassistant/components/omie/coordinator.py b/homeassistant/components/omie/coordinator.py new file mode 100644 index 00000000000..2b9139fce8b --- /dev/null +++ b/homeassistant/components/omie/coordinator.py @@ -0,0 +1,70 @@ +"""Coordinator for the OMIE - Spain and Portugal electricity prices integration.""" + +import datetime as dt +from datetime import timedelta +import logging + +import pyomie.main as pyomie +from pyomie.model import OMIEResults, SpotData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .util import CET, current_quarter_hour_cet + +_LOGGER = logging.getLogger(__name__) + +_UPDATE_INTERVAL_PADDING = timedelta(seconds=1) +"""Padding to add to the update interval to work around early refresh scheduling by + DataUpdateCoordinator.""" + +type OMIEConfigEntry = ConfigEntry[OMIECoordinator] + + +class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]): + """Coordinator that manages OMIE data for the current CET day.""" + + def __init__(self, hass: HomeAssistant, config_entry: OMIEConfigEntry) -> None: + """Initialize OMIE coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=dt.timedelta(minutes=1), + ) + self._client_session = async_get_clientsession(hass) + + async def _async_update_data(self) -> OMIEResults[SpotData]: + """Update OMIE data, fetching the current CET day.""" + cet_today = dt_util.now().astimezone(CET).date() + if self.data and self.data.market_date == cet_today: + data = self.data + else: + data = await self._spot_price(cet_today) + + self._set_update_interval() + return data + + def _set_update_interval(self) -> None: + """Schedule the next refresh at the start of the next quarter-hour.""" + now = dt_util.now() + self.update_interval = calc_update_interval(now) + _LOGGER.debug("Next refresh at %s", (now + self.update_interval).isoformat()) + + async def _spot_price(self, date: dt.date) -> OMIEResults[SpotData]: + """Fetch OMIE spot price data for the given date.""" + _LOGGER.debug("Fetching OMIE spot data for %s", date) + return await pyomie.spot_price(self._client_session, date) + + +def calc_update_interval(now: dt.datetime) -> dt.timedelta: + """Calculate the update_interval for the next 15-min boundary.""" + current_quarter = current_quarter_hour_cet(now) + next_quarter = current_quarter + dt.timedelta(minutes=15) + + return next_quarter - now + _UPDATE_INTERVAL_PADDING diff --git a/homeassistant/components/omie/manifest.json b/homeassistant/components/omie/manifest.json new file mode 100644 index 00000000000..a12fa7c4b9b --- /dev/null +++ b/homeassistant/components/omie/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "omie", + "name": "OMIE - Spain and Portugal electricity prices", + "codeowners": ["@luuuis"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/omie", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["pyomie==1.1.1"], + "single_config_entry": true +} diff --git a/homeassistant/components/omie/quality_scale.yaml b/homeassistant/components/omie/quality_scale.yaml new file mode 100644 index 00000000000..29baf4db514 --- /dev/null +++ b/homeassistant/components/omie/quality_scale.yaml @@ -0,0 +1,45 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom service actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom service actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions in entity lifecycle. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + OMIE API is public data service that doesn't require authentication. + Coordinators handle any connection issues gracefully during runtime. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: No custom service actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: OMIE API is public data service that doesn't require authentication. + test-coverage: done diff --git a/homeassistant/components/omie/sensor.py b/homeassistant/components/omie/sensor.py new file mode 100644 index 00000000000..3e7d591d58d --- /dev/null +++ b/homeassistant/components/omie/sensor.py @@ -0,0 +1,107 @@ +"""Sensor for the OMIE - Spain and Portugal electricity prices integration.""" + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import CURRENCY_EURO, UnitOfEnergy +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import util +from .const import DOMAIN +from .coordinator import OMIEConfigEntry, OMIECoordinator + +PARALLEL_UPDATES = 0 + +_ATTRIBUTION = "Data provided by OMIE.es" + +SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { + key: SensorEntityDescription( + key=key, + translation_key=key, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=4, + ) + for key in ("pt_spot_price", "es_spot_price") +} + + +class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity): + """OMIE price sensor.""" + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_attribution = _ATTRIBUTION + + def __init__( + self, + coordinator: OMIECoordinator, + device_info: DeviceInfo, + pyomie_series_name: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = SENSOR_DESCRIPTIONS[pyomie_series_name] + self._attr_device_info = device_info + self._attr_unique_id = pyomie_series_name + self._pyomie_series_name = pyomie_series_name + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Update this sensor's state from the coordinator results.""" + value = self._get_current_quarter_hour_value() + self._attr_available = value is not None + self._attr_native_value = value if self._attr_available else None + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._attr_available + + def _get_current_quarter_hour_value(self) -> float | None: + """Get current quarter-hour's price value from coordinator data.""" + current_quarter_hour_cet = util.current_quarter_hour_cet(dt_util.now()) + + pyomie_results = self.coordinator.data + pyomie_quarter_hours = util.pick_series_cet( + pyomie_results, self._pyomie_series_name + ) + + # Convert to €/kWh + value_mwh = pyomie_quarter_hours.get(current_quarter_hour_cet) + return value_mwh / 1000 if value_mwh is not None else None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OMIEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OMIE from its config entry.""" + coordinator = entry.runtime_data + + device_info = DeviceInfo( + configuration_url="https://www.omie.es/en/market-results", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, DOMAIN)}, + name="OMIE", + ) + + sensors = [ + OMIEPriceSensor(coordinator, device_info, pyomie_series_name="pt_spot_price"), + OMIEPriceSensor(coordinator, device_info, pyomie_series_name="es_spot_price"), + ] + + async_add_entities(sensors) diff --git a/homeassistant/components/omie/strings.json b/homeassistant/components/omie/strings.json new file mode 100644 index 00000000000..fe6e2f85e5b --- /dev/null +++ b/homeassistant/components/omie/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + } + }, + "entity": { + "sensor": { + "es_spot_price": { + "name": "Spain spot price" + }, + "pt_spot_price": { + "name": "Portugal spot price" + } + } + } +} diff --git a/homeassistant/components/omie/util.py b/homeassistant/components/omie/util.py new file mode 100644 index 00000000000..9f9559eb249 --- /dev/null +++ b/homeassistant/components/omie/util.py @@ -0,0 +1,37 @@ +"""Utility functions for OMIE - Spain and Portugal electricity prices integration.""" + +import datetime as dt +from typing import Final +from zoneinfo import ZoneInfo + +from pyomie.model import OMIEResults, SpotData +from pyomie.util import localize_quarter_hourly_data + +CET: Final = ZoneInfo("CET") + + +def current_quarter_hour_cet(current_time: dt.datetime) -> dt.datetime: + """Return the start of the quarter-hour for the passed in time in CET.""" + current_quarter_begin = current_time.minute // 15 * 15 + return current_time.replace( + minute=current_quarter_begin, second=0, microsecond=0 + ).astimezone(CET) + + +def pick_series_cet( + res: OMIEResults[SpotData] | None, + series_name: str, +) -> dict[dt.datetime, float]: + """Pick values for this series from market data, keyed by CET.""" + if res is None: + return {} + + market_date = res.market_date + series_data = getattr(res.contents, series_name, []) + + return { + dt.datetime.fromisoformat(dt_str).astimezone(CET): series_values + for dt_str, series_values in localize_quarter_hourly_data( + market_date, series_data + ).items() + } diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 4e3e2962d03..4535d07a159 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -2,7 +2,7 @@ def check_guard(state_key, item, entity_setting): - """Validate that this entity passes the defined guard conditions defined at setup.""" + """Validate that this entity passes the guard conditions.""" if state_key not in item: return True diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index dfbd010ea98..986a7fc842e 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Omnilogic integration.""" -from __future__ import annotations - import logging from typing import Any @@ -90,6 +88,8 @@ class OptionsFlowHandler(OptionsFlow): step_id="init", data_schema=vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/omnilogic/entity.py b/homeassistant/components/omnilogic/entity.py index 99aac699589..42b22fef48f 100644 --- a/homeassistant/components/omnilogic/entity.py +++ b/homeassistant/components/omnilogic/entity.py @@ -40,7 +40,10 @@ class OmniLogicEntity(CoordinatorEntity[OmniLogicUpdateCoordinator]): f"{entity_friendly_name}{coordinator.data[bow_id]['Name']} " ) else: - entity_friendly_name = f"{entity_friendly_name}{coordinator.data[bow_id]['Operation']['VirtualHeater']['Name']} " + heater_name = coordinator.data[bow_id]["Operation"]["VirtualHeater"][ + "Name" + ] + entity_friendly_name = f"{entity_friendly_name}{heater_name} " unique_id = f"{unique_id}_{coordinator.data[item_id]['systemId']}_{kind}" diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 097cddd6603..85322c45ceb 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -1,7 +1,5 @@ """Support to help onboard new users.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, TypedDict @@ -10,7 +8,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from . import views from .const import ( @@ -64,7 +61,6 @@ class OnboardingStorage(Store[OnboardingStoreData]): return old_data -@bind_hass @callback def async_is_onboarded(hass: HomeAssistant) -> bool: """Return if Home Assistant has been onboarded.""" @@ -72,7 +68,6 @@ def async_is_onboarded(hass: HomeAssistant) -> bool: return data is None or data.onboarded is True -@bind_hass @callback def async_is_user_onboarded(hass: HomeAssistant) -> bool: """Return if a user has been created as part of onboarding.""" diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b78b789d5e2..a4cd0198968 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -1,7 +1,5 @@ """Onboarding views.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index 53c54290bf9..78b85c173ee 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -1,7 +1,5 @@ """The Oncue integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py index 696acf1b2d6..27082b8be73 100644 --- a/homeassistant/components/ondilo_ico/api.py +++ b/homeassistant/components/ondilo_ico/api.py @@ -26,7 +26,7 @@ class OndiloClient(Ondilo): super().__init__(self.session.token) def refresh_tokens(self) -> dict: - """Refresh and return new Ondilo ICO tokens using Home Assistant OAuth2 session.""" + """Refresh and return new Ondilo ICO tokens using HA OAuth2 session.""" run_coroutine_threadsafe( self.session.async_ensure_token_valid(), self.hass.loop ).result() diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 3fbe82a536d..6ef24a950d7 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -1,7 +1,5 @@ """Define an object to coordinate fetching Ondilo ICO data.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, field from datetime import datetime, timedelta diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 61080d2577b..c076e9c2bed 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index b137c7725f1..35ccf3d5083 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -1,7 +1,5 @@ """The OneDrive integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from html import unescape from json import dumps, loads diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index fdec23a6da2..c0e82933d34 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -1,7 +1,5 @@ """Support for OneDrive backup.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging @@ -83,6 +81,7 @@ def handle_backup_errors[_R, **P]( return await func(self, *args, **kwargs) except AuthenticationError as err: self._entry.async_start_reauth(self._hass) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Authentication error") from err except OneDriveException as err: _LOGGER.error( @@ -91,12 +90,14 @@ def handle_backup_errors[_R, **P]( err, ) _LOGGER.debug("Full error: %s", err, exc_info=True) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Backup operation failed") from err except TimeoutError as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, ) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Backup operation timed out") from err return wrapper @@ -185,13 +186,15 @@ class OneDriveBackupAgent(BackupAgent): ), ) except HashMismatchError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError( "Hash validation failed, backup file might be corrupt" ) from err _LOGGER.debug("Uploaded backup to %s", backup_filename) - # Store metadata in separate metadata file (just backup.as_dict(), no extra fields) + # Store metadata in separate metadata file + # (just backup.as_dict(), no extra fields) metadata_content = json_dumps(backup.as_dict()) try: await self._client.upload_file( @@ -293,4 +296,5 @@ class OneDriveBackupAgent(BackupAgent): if backup := metadata_files.get(backup_id): return backup + # pylint: disable-next=home-assistant-exception-not-translated raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 34a711cb219..f1d50230521 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OneDrive.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, cast diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index fd21d84369c..8755ab767e1 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -11,7 +11,8 @@ CONF_FOLDER_ID: Final = "folder_id" CONF_DELETE_PERMANENTLY: Final = "delete_permanently" -# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +# replace "consumers" with "common", when adding +# SharePoint or OneDrive for Business support OAUTH2_AUTHORIZE: Final = ( "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" ) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 02260e931ee..da972e78cb4 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for OneDrive.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py index 0e1ed94e155..ba1d2b5c4d9 100644 --- a/homeassistant/components/onedrive/diagnostics.py +++ b/homeassistant/components/onedrive/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for OneDrive.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json index 66f510b8e82..a8933728ae0 100644 --- a/homeassistant/components/onedrive/icons.json +++ b/homeassistant/components/onedrive/icons.json @@ -22,6 +22,9 @@ } }, "services": { + "delete": { + "service": "mdi:cloud-remove" + }, "upload": { "service": "mdi:cloud-upload" } diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index 1e579b82a0f..1693454f386 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -1,10 +1,8 @@ """OneDrive services.""" -from __future__ import annotations - import asyncio from dataclasses import asdict -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import cast from onedrive_personal_sdk.exceptions import OneDriveException @@ -21,11 +19,12 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN +from .const import CONF_DELETE_PERMANENTLY, DOMAIN from .coordinator import OneDriveConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" CONF_DESTINATION_FOLDER = "destination_folder" +CONF_DESTINATION_PATH = "destination_path" UPLOAD_SERVICE = "upload" UPLOAD_SERVICE_SCHEMA = vol.Schema( @@ -35,6 +34,17 @@ UPLOAD_SERVICE_SCHEMA = vol.Schema( vol.Required(CONF_DESTINATION_FOLDER): cv.string, } ) + +DELETE_SERVICE = "delete" +DELETE_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_DESTINATION_PATH): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + } +) + CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 @@ -42,7 +52,7 @@ def _read_file_contents( hass: HomeAssistant, filenames: list[str] ) -> list[tuple[str, bytes]]: """Return the mime types and file contents for each file.""" - results = [] + missing: list[str] = [] for filename in filenames: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( @@ -50,20 +60,27 @@ def _read_file_contents( translation_key="no_access_to_path", translation_placeholders={"filename": filename}, ) + if not Path(filename).exists(): + missing.append(filename) + if missing: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filenames_do_not_exist", + translation_placeholders={ + "filenames": ", ".join(f"`{f}`" for f in missing) + }, + ) + results = [] + for filename in filenames: filename_path = Path(filename) - if not filename_path.exists(): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="filename_does_not_exist", - translation_placeholders={"filename": filename}, - ) - if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + file_size = filename_path.stat().st_size + if file_size > CONTENT_SIZE_LIMIT: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="file_too_large", translation_placeholders={ "filename": filename, - "size": str(filename_path.stat().st_size), + "size": str(file_size), "limit": str(CONTENT_SIZE_LIMIT), }, ) @@ -71,6 +88,29 @@ def _read_file_contents( return results +def _raise_invalid_destination_path(destination_path: str) -> None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_destination_path", + translation_placeholders={"destination_path": destination_path}, + ) + + +def _validate_destination_path(destination_path: str) -> str: + """Validate and normalize a remote destination path. + + Returns the normalized path or raises HomeAssistantError. + """ + normalized = destination_path.strip("/") + if not normalized: + _raise_invalid_destination_path(destination_path) + parts = PurePosixPath(normalized).parts + for part in parts: + if part == ".." or ":" in part: + _raise_invalid_destination_path(destination_path) + return str(PurePosixPath(normalized)) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register OneDrive services.""" @@ -117,6 +157,50 @@ def async_setup_services(hass: HomeAssistant) -> None: return {"files": [asdict(item_result) for item_result in upload_results]} return None + async def async_handle_delete(call: ServiceCall) -> None: + """Delete one or more files from OneDrive.""" + config_entry: OneDriveConfigEntry = service.async_get_config_entry( + hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID] + ) + client = config_entry.runtime_data.client + delete_permanently = config_entry.options.get(CONF_DELETE_PERMANENTLY, False) + file_paths = [ + _validate_destination_path(p) + for p in cast(list[str], call.data[CONF_DESTINATION_PATH]) + ] + + try: + approot_id = (await client.get_approot()).id + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + + results = await asyncio.gather( + *[ + client.delete_drive_item( + f"{approot_id}:/{file_path}:", delete_permanently + ) + for file_path in file_paths + ], + return_exceptions=True, + ) + failures: list[tuple[str, OneDriveException]] = [] + for file_path, result in zip(file_paths, results, strict=True): + if isinstance(result, OneDriveException): + failures.append((file_path, result)) + if failures: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="delete_error", + translation_placeholders={ + "paths": ", ".join(f"`{path}`" for path, _ in failures) + }, + ) from ExceptionGroup( + "OneDrive delete errors", [err for _, err in failures] + ) + hass.services.async_register( DOMAIN, UPLOAD_SERVICE, @@ -125,3 +209,10 @@ def async_setup_services(hass: HomeAssistant) -> None: supports_response=SupportsResponse.OPTIONAL, description_placeholders={"example_image_path": "/config/www/image.jpg"}, ) + + hass.services.async_register( + DOMAIN, + DELETE_SERVICE, + async_handle_delete, + schema=DELETE_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml index 0cf0faf6b60..d39968d74f0 100644 --- a/homeassistant/components/onedrive/services.yaml +++ b/homeassistant/components/onedrive/services.yaml @@ -6,10 +6,24 @@ upload: config_entry: integration: onedrive filename: - required: false + required: true selector: - object: + text: + multiple: true destination_folder: required: true selector: text: + +delete: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: onedrive + destination_path: + required: true + selector: + text: + multiple: true diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 4f780b239c9..5ba210929b0 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -90,9 +90,15 @@ "authentication_failed": { "message": "Authentication failed" }, + "connection_error": { + "message": "[%key:component::onedrive::config::abort::connection_error%]" + }, "create_folder_error": { "message": "Failed to create folder: {message}" }, + "delete_error": { + "message": "Failed to delete from OneDrive: {paths}" + }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" }, @@ -102,8 +108,11 @@ "file_too_large": { "message": "`{filename}` is too large ({size} > {limit})" }, - "filename_does_not_exist": { - "message": "`{filename}` does not exist" + "filenames_do_not_exist": { + "message": "The following files do not exist: {filenames}" + }, + "invalid_destination_path": { + "message": "Invalid destination path `{destination_path}`: must be non-empty, must not contain `:` or `..` path segments" }, "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" @@ -142,25 +151,40 @@ } }, "services": { + "delete": { + "description": "Deletes one or more files from OneDrive.", + "fields": { + "config_entry_id": { + "description": "The config entry representing the OneDrive you want to delete from.", + "name": "Config entry ID" + }, + "destination_path": { + "description": "One or more paths to files inside the OneDrive app folder (Apps/Home Assistant) to delete.", + "example": "[\"photos/snapshots/image.jpg\", \"photos/snapshots/image2.jpg\"]", + "name": "Destination paths" + } + }, + "name": "Delete files" + }, "upload": { - "description": "Uploads files to OneDrive.", + "description": "Uploads one or more files to OneDrive.", "fields": { "config_entry_id": { "description": "The config entry representing the OneDrive you want to upload to.", "name": "Config entry ID" }, "destination_folder": { - "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.", + "description": "Folder inside the OneDrive app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.", "example": "photos/snapshots", "name": "Destination folder" }, "filename": { - "description": "Path to the file to upload.", + "description": "One or more paths to files to upload.", "example": "{example_image_path}", - "name": "Filename" + "name": "Filenames" } }, - "name": "Upload file" + "name": "Upload files" } } } diff --git a/homeassistant/components/onedrive_for_business/__init__.py b/homeassistant/components/onedrive_for_business/__init__.py index e2eb4b06e2c..4144da4c152 100644 --- a/homeassistant/components/onedrive_for_business/__init__.py +++ b/homeassistant/components/onedrive_for_business/__init__.py @@ -1,7 +1,5 @@ """The OneDrive for Business integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable import logging from typing import cast diff --git a/homeassistant/components/onedrive_for_business/application_credentials.py b/homeassistant/components/onedrive_for_business/application_credentials.py index c2db8b1df56..e9dd1502d54 100644 --- a/homeassistant/components/onedrive_for_business/application_credentials.py +++ b/homeassistant/components/onedrive_for_business/application_credentials.py @@ -1,7 +1,5 @@ """Application credentials platform for the OneDrive for Business integration.""" -from __future__ import annotations - from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar diff --git a/homeassistant/components/onedrive_for_business/backup.py b/homeassistant/components/onedrive_for_business/backup.py index dc35ae79743..d378d00dacc 100644 --- a/homeassistant/components/onedrive_for_business/backup.py +++ b/homeassistant/components/onedrive_for_business/backup.py @@ -1,7 +1,5 @@ """Support for OneDrive backup.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging @@ -83,6 +81,7 @@ def handle_backup_errors[_R, **P]( return await func(self, *args, **kwargs) except AuthenticationError as err: self._entry.async_start_reauth(self._hass) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Authentication error") from err except OneDriveException as err: _LOGGER.error( @@ -91,12 +90,14 @@ def handle_backup_errors[_R, **P]( err, ) _LOGGER.debug("Full error: %s", err, exc_info=True) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Backup operation failed") from err except TimeoutError as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, ) + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError("Backup operation timed out") from err return wrapper @@ -179,13 +180,15 @@ class OneDriveBackupAgent(BackupAgent): ), ) except HashMismatchError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise BackupAgentError( "Hash validation failed, backup file might be corrupt" ) from err _LOGGER.debug("Uploaded backup to %s", backup_filename) - # Store metadata in separate metadata file (just backup.as_dict(), no extra fields) + # Store metadata in separate metadata file + # (just backup.as_dict(), no extra fields) metadata_content = json_dumps(backup.as_dict()) try: await self._client.upload_file( @@ -281,4 +284,5 @@ class OneDriveBackupAgent(BackupAgent): if backup := metadata_files.get(backup_id): return backup + # pylint: disable-next=home-assistant-exception-not-translated raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/onedrive_for_business/config_flow.py b/homeassistant/components/onedrive_for_business/config_flow.py index c9b3c047317..5c5bc65b48d 100644 --- a/homeassistant/components/onedrive_for_business/config_flow.py +++ b/homeassistant/components/onedrive_for_business/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OneDrive for Business.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, cast @@ -146,7 +144,8 @@ class OneDriveForBusinessConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): errors["base"] = "folder_creation_error" if not errors: title = ( - f"{self.drive.owner.user.display_name}'s OneDrive ({self.drive.owner.user.email})" + f"{self.drive.owner.user.display_name}'s" + f" OneDrive ({self.drive.owner.user.email})" if self.drive.owner and self.drive.owner.user and self.drive.owner.user.display_name diff --git a/homeassistant/components/onedrive_for_business/coordinator.py b/homeassistant/components/onedrive_for_business/coordinator.py index ee5abb96528..b7a0a7fa7d1 100644 --- a/homeassistant/components/onedrive_for_business/coordinator.py +++ b/homeassistant/components/onedrive_for_business/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for OneDrive for Business.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/onedrive_for_business/diagnostics.py b/homeassistant/components/onedrive_for_business/diagnostics.py index 404cb3b507d..dc7c9964820 100644 --- a/homeassistant/components/onedrive_for_business/diagnostics.py +++ b/homeassistant/components/onedrive_for_business/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for OneDrive for Business.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 50127f96af3..9b15be8b01f 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -1,7 +1,5 @@ """Support for 1-Wire binary sensors.""" -from __future__ import annotations - from datetime import timedelta import os diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index f10692061ae..abba3c8f419 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -1,7 +1,5 @@ """Config flow for 1-Wire component.""" -from __future__ import annotations - from copy import deepcopy from typing import Any diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index dabe2f560f4..579530b8296 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -1,7 +1,5 @@ """Constants for 1-Wire component.""" -from __future__ import annotations - DEFAULT_HOST = "localhost" DEFAULT_PORT = 4304 @@ -29,6 +27,7 @@ DEVICE_SUPPORT = { "3B": (), "42": (), "7E": ("EDS0065", "EDS0066", "EDS0068"), + "81": (), "A6": (), "EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"), } diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index b87d9a90897..01ec9a4de71 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for 1-Wire.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index acc6499b01d..6dd64367397 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -1,7 +1,5 @@ """Support for 1-Wire entities.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index a59953dcd25..2b8734cdcf3 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -1,7 +1,5 @@ """Type definitions for 1-Wire integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 9175d9f21b4..96cc26b82de 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -1,7 +1,5 @@ """Hub for communication with 1-Wire server or mount_dir.""" -from __future__ import annotations - import contextlib from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index 60c0c985ab0..009a73997df 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -1,7 +1,5 @@ """Support for 1-Wire environment select entities.""" -from __future__ import annotations - from datetime import timedelta import os diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index b627a1d5a4d..24354899eb5 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -1,7 +1,5 @@ """Support for 1-Wire environment sensors.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import dataclasses from datetime import timedelta diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index c8615110071..30bd75c9af0 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -1,7 +1,5 @@ """Support for 1-Wire environment switches.""" -from __future__ import annotations - from datetime import timedelta import os from typing import Any diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index f4a85f56acb..5699d1527aa 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( @@ -47,8 +47,6 @@ from .util import get_meaning _LOGGER = logging.getLogger(__name__) -CONF_DEVICE = "device" - INPUT_SOURCES_DEFAULT: list[InputSource] = [] LISTENING_MODES_DEFAULT: list[ListeningMode] = [] INPUT_SOURCES_ALL_MEANINGS = { diff --git a/homeassistant/components/onkyo/coordinator.py b/homeassistant/components/onkyo/coordinator.py index 5c1713e992d..dac39f43e88 100644 --- a/homeassistant/components/onkyo/coordinator.py +++ b/homeassistant/components/onkyo/coordinator.py @@ -1,7 +1,5 @@ """Onkyo coordinators.""" -from __future__ import annotations - import asyncio from enum import StrEnum import logging @@ -43,8 +41,8 @@ class Channel(StrEnum): SUBWOOFER_2 = "subwoofer_2" -ChannelMutingData = dict[Channel, status.ChannelMuting.Param] -ChannelMutingDesired = dict[Channel, command.ChannelMuting.Param] +type ChannelMutingData = dict[Channel, status.ChannelMuting.Param] +type ChannelMutingDesired = dict[Channel, command.ChannelMuting.Param] class ChannelMutingCoordinator(DataUpdateCoordinator[ChannelMutingData]): @@ -69,8 +67,8 @@ class ChannelMutingCoordinator(DataUpdateCoordinator[ChannelMutingData]): self.manager = manager - self.data = ChannelMutingData() - self._desired = ChannelMutingDesired() + self.data: ChannelMutingData = {} + self._desired: ChannelMutingDesired = {} self._entities_added = False @@ -162,6 +160,6 @@ class ChannelMutingCoordinator(DataUpdateCoordinator[ChannelMutingData]): self._desired = { channel: desired for channel, desired in self._desired.items() - if self.data[channel] != desired + if self.data[channel] is not desired } self.async_set_updated_data(self.data) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index e69c9ef0543..722ec68a5b0 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,7 +1,5 @@ """Media player platform.""" -from __future__ import annotations - import asyncio import logging from typing import TYPE_CHECKING, Any @@ -143,7 +141,8 @@ async def async_setup_entry( if entity.enabled: entity.process_update(message) elif not isinstance(message, status.NotAvailable): - # When we receive a valid status for a zone, then that zone is available on the receiver, + # When we receive a valid status for a zone, then + # that zone is available on the receiver, # so we create the entity for it. _LOGGER.debug( "Discovered %s on %s (%s)", @@ -198,7 +197,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): name = manager.info.model_name identifier = manager.info.identifier - self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}" + self._attr_name = f"{name}{' ' + ZONES[zone] if zone is not Zone.MAIN else ''}" self._attr_unique_id = f"{identifier}_{zone.value}" self._volume_resolution = volume_resolution @@ -227,7 +226,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_supported_features = SUPPORTED_FEATURES_BASE - if zone == Zone.MAIN: + if zone is Zone.MAIN: self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._supports_volume = True self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE @@ -260,7 +259,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): await self._manager.write(query.TunerPreset(self._zone)) if self._supports_sound_mode is not None: await self._manager.write(query.ListeningMode(self._zone)) - if self._zone == Zone.MAIN: + if self._zone is Zone.MAIN: await self._manager.write(query.HDMIOutput()) await self._manager.write(query.AudioInformation()) await self._manager.write(query.VideoInformation()) @@ -387,7 +386,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_volume_level = min(1, volume_level) case status.Muting(param=muting): - self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON) + self._attr_is_volume_muted = bool(muting is status.Muting.Param.ON) case status.InputSource(param=source): if source in self._source_mapping: @@ -395,7 +394,8 @@ class OnkyoMediaPlayer(MediaPlayerEntity): else: source_meaning = get_meaning(source) _LOGGER.warning( - 'Input source "%s" for entity: %s is not in the list. Check integration options', + 'Input source "%s" for entity: %s is not' + " in the list. Check integration options", source_meaning, self.entity_id, ) @@ -415,7 +415,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): else: sound_mode_meaning = get_meaning(sound_mode) _LOGGER.warning( - 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + 'Listening mode "%s" for entity: %s is' + " not in the list. Check integration" + " options", sound_mode_meaning, self.entity_id, ) diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index ed2ec295bfa..c0d6b613400 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -1,7 +1,5 @@ """Onkyo receiver.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Iterable import contextlib diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index 219a8843a44..1fe85ba9c4e 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -1,7 +1,5 @@ """Onkyo services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/onkyo/switch.py b/homeassistant/components/onkyo/switch.py index f60c1c1ddcb..a68ca71dd55 100644 --- a/homeassistant/components/onkyo/switch.py +++ b/homeassistant/components/onkyo/switch.py @@ -1,7 +1,5 @@ """Switch platform.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any @@ -91,6 +89,6 @@ class OnkyoChannelMutingSwitch( """Handle updated data from the coordinator.""" value = self.coordinator.data.get(self._channel) self._attr_is_on = ( - None if value is None else value == status.ChannelMuting.Param.ON + None if value is None else value is status.ChannelMuting.Param.ON ) super()._handle_coordinator_update() diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 39ffb97e09d..48eed28f4b2 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,6 +1,5 @@ """The ONVIF integration.""" -import asyncio from contextlib import AsyncExitStack, suppress from http import HTTPStatus import logging @@ -12,7 +11,6 @@ from zeep.exceptions import Fault, TransportError from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, HTTP_BASIC_AUTHENTICATION, @@ -28,22 +26,19 @@ from .const import ( CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, - DOMAIN, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Set up ONVIF from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if not entry.options: await async_populate_options(hass, entry) device = ONVIFDevice(hass, entry) + camera_address = f"{device.host}:{device.port}" async with AsyncExitStack() as stack: # Register cleanup callback for device @@ -57,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_populate_snapshot_auth(hass, device, entry) except (TimeoutError, aiohttp.ClientError) as err: raise ConfigEntryNotReady( - f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" + f"Could not connect to camera {camera_address}: {err}" ) from err except Fault as err: if is_auth_error(err): @@ -69,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err except ONVIFError as err: raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" + f"Could not setup camera {camera_address}: {stringify_onvif_error(err)}" ) from err except TransportError as err: stringified_onvif_error = stringify_onvif_error(err) @@ -81,13 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Auth Failed: {stringified_onvif_error}" ) from err raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" - ) from err - except asyncio.CancelledError as err: - # After https://github.com/agronholm/anyio/issues/374 is resolved - # this may be able to be removed - raise ConfigEntryNotReady( - f"Setup was unexpectedly canceled: {err}" + f"Could not setup camera {camera_address}: {stringified_onvif_error}" ) from err if not device.available: @@ -96,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If we get here, setup was successful - prevent cleanup stack.pop_all() - hass.data[DOMAIN][entry.unique_id] = device + entry.runtime_data = device device.platforms = [Platform.BUTTON, Platform.CAMERA] @@ -127,9 +116,9 @@ async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None: await device.device.close() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Unload a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data await _async_stop_device(hass, device) return await hass.config_entries.async_unload_platforms(entry, device.platforms) @@ -149,7 +138,7 @@ async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: async def async_populate_snapshot_auth( - hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry + hass: HomeAssistant, device: ONVIFDevice, entry: ONVIFConfigEntry ) -> None: """Check if digest auth for snapshots is possible.""" if auth := await _get_snapshot_auth(device): @@ -158,7 +147,7 @@ async def async_populate_snapshot_auth( ) -async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_populate_options(hass: HomeAssistant, entry: ONVIFConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, @@ -171,7 +160,7 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non @callback def _async_migrate_camera_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice + hass: HomeAssistant, config_entry: ONVIFConfigEntry, device: ONVIFDevice ) -> None: """Migrate unique ids of camera entities from profile index to profile token.""" entity_reg = er.async_get(hass) @@ -199,7 +188,9 @@ def _async_migrate_camera_entities_unique_ids( index = int(entity.unique_id[len(old_uid_start) :]) except ValueError: LOGGER.error( - "Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'", + "Failed to migrate unique id for '%s' as the" + " ONVIF profile index could not be parsed" + " from unique id '%s'", entity.entity_id, entity.unique_id, ) @@ -208,7 +199,9 @@ def _async_migrate_camera_entities_unique_ids( token = device.profiles[index].token except IndexError: LOGGER.error( - "Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found", + "Failed to migrate unique id for '%s' as the" + " ONVIF profile index '%d' parsed from" + " unique id '%s' could not be found", entity.entity_id, index, entity.unique_id, diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 3c740d445d8..076ad2c57ed 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -1,12 +1,9 @@ """Support for ONVIF binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -14,19 +11,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF binary sensor platform.""" - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data events = device.events.get_platform("binary_sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index 8e92cb07a8c..1551e089dfb 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -1,23 +1,21 @@ """ONVIF Buttons.""" from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF button based on a config entry.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities([RebootButton(device), SetSystemDateAndTimeButton(device)]) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index bd5b7db6069..2678c72f9f2 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,7 +1,5 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" -from __future__ import annotations - import asyncio from haffmpeg.camera import CameraMjpeg @@ -17,7 +15,6 @@ from homeassistant.components.stream import ( CONF_USE_WALLCLOCK_AS_TIMESTAMPS, RTSP_TRANSPORTS, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -40,7 +37,6 @@ from .const import ( DIR_LEFT, DIR_RIGHT, DIR_UP, - DOMAIN, GOTOPRESET_MOVE, LOGGER, RELATIVE_MOVE, @@ -49,14 +45,14 @@ from .const import ( ZOOM_IN, ZOOM_OUT, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ONVIF camera video stream.""" @@ -86,7 +82,7 @@ async def async_setup_entry( "async_perform_ptz", ) - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( [ONVIFCameraEntity(device, profile) for profile in device.profiles] ) @@ -140,6 +136,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self.profile.token, self._basic_auth ): return image + # pylint: disable-next=home-assistant-action-swallowed-exception except ONVIFError as err: LOGGER.error( "Fetch snapshot image failed from %s, falling back to FFmpeg; %s", diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index f645444f9c6..8505718935d 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ONVIF.""" -from __future__ import annotations - from collections.abc import Mapping import logging from pprint import pformat @@ -37,7 +35,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -45,6 +43,7 @@ from .const import ( CONF_DEVICE_ID, CONF_ENABLE_WEBHOOKS, CONF_HARDWARE, + CONF_MORE_OPTIONS, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, DEFAULT_PORT, @@ -276,6 +275,8 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): step_id="configure", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=conf(CONF_NAME)): str, vol.Required(CONF_HOST, default=conf(CONF_HOST)): str, vol.Required(CONF_PORT, default=conf(CONF_PORT, DEFAULT_PORT)): int, @@ -409,30 +410,16 @@ class OnvifOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the ONVIF devices options.""" if user_input is not None: + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) self.options[CONF_EXTRA_ARGUMENTS] = user_input[CONF_EXTRA_ARGUMENTS] self.options[CONF_RTSP_TRANSPORT] = user_input[CONF_RTSP_TRANSPORT] - self.options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = user_input.get( + self.options[CONF_ENABLE_WEBHOOKS] = user_input[CONF_ENABLE_WEBHOOKS] + self.options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = more_options.get( CONF_USE_WALLCLOCK_AS_TIMESTAMPS, self.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), ) - self.options[CONF_ENABLE_WEBHOOKS] = user_input.get( - CONF_ENABLE_WEBHOOKS, - self.config_entry.options.get( - CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS - ), - ) return self.async_create_entry(title="", data=self.options) - advanced_options = {} - if self.show_advanced_options: - advanced_options[ - vol.Optional( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, - default=self.config_entry.options.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False - ), - ) - ] = bool return self.async_show_form( step_id="onvif_devices", data_schema=vol.Schema( @@ -455,7 +442,19 @@ class OnvifOptionsFlowHandler(OptionsFlow): CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS ), ): bool, - **advanced_options, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Optional( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + default=self.config_entry.options.get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False + ), + ): bool, + } + ), + SectionConfig(collapsed=True), + ), } ), ) diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index ec006a2db8d..1de75978582 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -18,6 +18,7 @@ CONF_DEVICE_ID = "deviceid" CONF_HARDWARE = "hardware" CONF_SNAPSHOT_AUTH = "snapshot_auth" CONF_ENABLE_WEBHOOKS = "enable_webhooks" +CONF_MORE_OPTIONS = "more_options" DEFAULT_ENABLE_WEBHOOKS = True ATTR_PAN = "pan" diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 7bcdd33809b..0f802e222d8 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -1,7 +1,5 @@ """ONVIF device abstraction.""" -from __future__ import annotations - import asyncio from contextlib import suppress import datetime as dt @@ -44,6 +42,8 @@ from .const import ( from .event_manager import EventManager from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video +type ONVIFConfigEntry = ConfigEntry[ONVIFDevice] + class ONVIFDevice: """Manages an ONVIF device.""" @@ -165,7 +165,7 @@ class ONVIFDevice: # Bind the listener to the ONVIFDevice instance since # async_update_listener only creates a weak reference to the listener # and we need to make sure it doesn't get garbage collected since only - # the ONVIFDevice instance is stored in hass.data + # the ONVIFDevice instance is stored in config_entry.runtime_data self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) ) @@ -218,8 +218,9 @@ class ONVIFDevice: try: await device_mgmt.SetSystemDateAndTime(dt_param) LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) - # Some cameras don't support setting the timezone and will throw an IndexError - # if we try to set it. If we get an error, try again without the timezone. + # Some cameras don't support setting the timezone + # and will throw an IndexError if we try to set it. + # If we get an error, try again without the timezone. except IndexError, Fault: if idx == timezone_max_idx: raise @@ -333,8 +334,9 @@ class ONVIFDevice: try: device_info = await device_mgmt.GetDeviceInformation() except (XMLParseError, XMLSyntaxError, TransportError) as ex: - # Some cameras have invalid UTF-8 in their device information (TransportError) - # and others have completely invalid XML (XMLParseError, XMLSyntaxError) + # Some cameras have invalid UTF-8 in their device + # information (TransportError) and others have + # completely invalid XML (XMLParseError, XMLSyntaxError) LOGGER.warning("%s: Failed to fetch device information: %s", self.name, ex) else: manufacturer = device_info.Manufacturer @@ -498,7 +500,12 @@ class ONVIFDevice: tilt=None, zoom=None, ): - """Perform a PTZ action on the camera.""" + """Perform a PTZ action on the camera. + + For ContinuousMove operations, calling this service with + continuous_duration = 0 disables the automatic Stop call; other move + modes do not auto-stop. + """ if not self.capabilities.ptz: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return @@ -542,12 +549,17 @@ class ONVIFDevice: req.Velocity = velocity await ptz_service.ContinuousMove(req) - await asyncio.sleep(continuous_duration) - req = ptz_service.create_type("Stop") - req.ProfileToken = profile.token - await ptz_service.Stop( - {"ProfileToken": req.ProfileToken, "PanTilt": True, "Zoom": False} - ) + if continuous_duration > 0: + await asyncio.sleep(continuous_duration) + req = ptz_service.create_type("Stop") + req.ProfileToken = profile.token + await ptz_service.Stop( + { + "ProfileToken": req.ProfileToken, + "PanTilt": True, + "Zoom": False, + } + ) elif move_mode == RELATIVE_MOVE: # Guard against unsupported operation if not profile.ptz or not profile.ptz.relative: diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index aa2042f3321..54c6d58535d 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -1,26 +1,22 @@ """Diagnostics support for ONVIF.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry REDACT_CONFIG = {CONF_HOST, CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ONVIFConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data data: dict[str, Any] = {} data["config"] = async_redact_data(entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py index 783df743e86..055503d694a 100644 --- a/homeassistant/components/onvif/entity.py +++ b/homeassistant/components/onvif/entity.py @@ -1,7 +1,5 @@ """Base classes for ONVIF entities.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/onvif/event_manager.py b/homeassistant/components/onvif/event_manager.py index 4770f8828b8..3f801bd9bd7 100644 --- a/homeassistant/components/onvif/event_manager.py +++ b/homeassistant/components/onvif/event_manager.py @@ -1,7 +1,5 @@ """ONVIF event abstraction.""" -from __future__ import annotations - import asyncio from collections.abc import Callable import datetime as dt @@ -129,8 +127,8 @@ class EventManager: def started(self) -> bool: """Return True if event manager is started.""" return ( - self.webhook_manager.state == WebHookManagerState.STARTED - or self.pullpoint_manager.state == PullPointManagerState.STARTED + self.webhook_manager.state is WebHookManagerState.STARTED + or self.pullpoint_manager.state is PullPointManagerState.STARTED ) @callback @@ -262,7 +260,7 @@ class EventManager: @callback def async_webhook_failed(self) -> None: """Mark webhook as failed.""" - if self.pullpoint_manager.state != PullPointManagerState.PAUSED: + if self.pullpoint_manager.state is not PullPointManagerState.PAUSED: return LOGGER.debug("%s: Switching to PullPoint for events", self.name) self.pullpoint_manager.async_resume() @@ -270,14 +268,14 @@ class EventManager: @callback def async_webhook_working(self) -> None: """Mark webhook as working.""" - if self.pullpoint_manager.state != PullPointManagerState.STARTED: + if self.pullpoint_manager.state is not PullPointManagerState.STARTED: return LOGGER.debug("%s: Switching to webhook for events", self.name) self.pullpoint_manager.async_pause() @callback def async_mark_events_stale(self) -> None: - """Mark all events as stale when the subscriptions fail since we are out of sync.""" + """Mark all events as stale when subscriptions fail.""" self._events.clear() self.async_callback_listeners() @@ -310,7 +308,7 @@ class PullPointManager: async def async_start(self) -> bool: """Start pullpoint subscription.""" - assert self.state == PullPointManagerState.STOPPED, ( + assert self.state is PullPointManagerState.STOPPED, ( "PullPoint manager already started" ) LOGGER.debug("%s: Starting PullPoint manager", self._name) @@ -355,7 +353,8 @@ class PullPointManager: await self._async_create_pullpoint_subscription() except CREATE_ERRORS as err: LOGGER.debug( - "%s: Device does not support PullPoint service or has too many subscriptions: %s", + "%s: Device does not support PullPoint service" + " or has too many subscriptions: %s", self._name, stringify_onvif_error(err), ) @@ -423,8 +422,9 @@ class PullPointManager: ) except aiohttp.ServerDisconnectedError as err: # Either a shutdown event or the camera closed the connection. Because - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal. Some + # servers are allowed to close the connection at any time: + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 + # we treat this as a normal. Some # cameras may close the connection if there are no messages to pull. LOGGER.debug( "%s: PullPoint subscription encountered a server disconnected error " @@ -450,8 +450,9 @@ class PullPointManager: TransportError, ) as err: LOGGER.debug( - "%s: PullPoint subscription encountered an unexpected error and will be retried " - "(this is normal for some cameras): %s", + "%s: PullPoint subscription encountered an" + " unexpected error and will be retried" + " (this is normal for some cameras): %s", self._name, stringify_onvif_error(err), ) @@ -461,11 +462,12 @@ class PullPointManager: finally: self.async_schedule_pull_messages(next_pull_delay) - if self.state != PullPointManagerState.STARTED: + if self.state is not PullPointManagerState.STARTED: # If the webhook became started working during the long poll, # and we got paused, our data is stale and we should not process it. LOGGER.debug( - "%s: PullPoint state is %s (likely due to working webhook), skipping PullPoint messages", + "%s: PullPoint state is %s (likely due to working" + " webhook), skipping PullPoint messages", self._name, self.state, ) @@ -505,7 +507,7 @@ class PullPointManager: Must not check if the webhook is working. """ self.async_cancel_pull_messages() - if self.state != PullPointManagerState.STARTED: + if self.state is not PullPointManagerState.STARTED: return if self._pullpoint_manager: when = delay if delay is not None else PULLPOINT_COOLDOWN_TIME @@ -564,7 +566,7 @@ class WebHookManager: async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) - assert self.state == WebHookManagerState.STOPPED, ( + assert self.state is WebHookManagerState.STOPPED, ( "Webhook manager already started" ) assert self._webhook_url is None, "Webhook already registered" @@ -618,7 +620,8 @@ class WebHookManager: except CREATE_ERRORS as err: self._event_manager.async_webhook_failed() LOGGER.debug( - "%s: Device does not support notification service or too many subscriptions: %s", + "%s: Device does not support notification service" + " or too many subscriptions: %s", self._name, stringify_onvif_error(err), ) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 62b440b1c90..d29370d58d8 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], "requirements": [ - "onvif-zeep-async==4.0.4", + "onvif-zeep-async==4.2.0", "onvif_parsers==2.3.0", "WSDiscovery==2.1.2" ] diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index ad91a514e88..968d9ce19f0 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -1,7 +1,5 @@ """ONVIF models.""" -from __future__ import annotations - from dataclasses import dataclass from enum import Enum from typing import Any diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 15e2144b510..a02d93a89a9 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -1,31 +1,27 @@ """Support for ONVIF binary sensors.""" -from __future__ import annotations - from datetime import date, datetime from decimal import Decimal from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF sensor platform.""" - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = config_entry.runtime_data events = device.events.get_platform("sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index e115a72705d..65a8c01f0fc 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -77,8 +77,15 @@ "data": { "enable_webhooks": "Enable webhooks", "extra_arguments": "Extra FFmpeg arguments", - "rtsp_transport": "RTSP transport mechanism", - "use_wallclock_as_timestamps": "Use wall clock as timestamps" + "rtsp_transport": "RTSP transport mechanism" + }, + "sections": { + "more_options": { + "data": { + "use_wallclock_as_timestamps": "Use wall clock as timestamps" + }, + "name": "More options" + } }, "title": "ONVIF device options" } diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index d8e1020c6a3..bf246334f7a 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -1,18 +1,14 @@ """ONVIF switches for controlling cameras.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile @@ -65,11 +61,11 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF switch platform.""" - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( ONVIFSwitch(device, description) diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index aaa045abb18..03a3c601260 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -1,7 +1,5 @@ """ONVIF util.""" -from __future__ import annotations - from collections import defaultdict from typing import Any diff --git a/homeassistant/components/open_meteo/__init__.py b/homeassistant/components/open_meteo/__init__.py index 34495d4bd0b..e701e7fd643 100644 --- a/homeassistant/components/open_meteo/__init__.py +++ b/homeassistant/components/open_meteo/__init__.py @@ -1,7 +1,5 @@ """Support for Open-Meteo.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/open_meteo/config_flow.py b/homeassistant/components/open_meteo/config_flow.py index 128e9f17f37..d0857ca5baf 100644 --- a/homeassistant/components/open_meteo/config_flow.py +++ b/homeassistant/components/open_meteo/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Open-Meteo integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/open_meteo/const.py b/homeassistant/components/open_meteo/const.py index 09ceba06b62..a3ce48b49a1 100644 --- a/homeassistant/components/open_meteo/const.py +++ b/homeassistant/components/open_meteo/const.py @@ -1,7 +1,5 @@ """Constants for the Open-Meteo integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py index 9e2f262db78..657ba515fc8 100644 --- a/homeassistant/components/open_meteo/coordinator.py +++ b/homeassistant/components/open_meteo/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Open-Meteo integration.""" -from __future__ import annotations - from open_meteo import ( DailyParameters, Forecast, diff --git a/homeassistant/components/open_meteo/diagnostics.py b/homeassistant/components/open_meteo/diagnostics.py index 44bf7d60e24..47cc6009c78 100644 --- a/homeassistant/components/open_meteo/diagnostics.py +++ b/homeassistant/components/open_meteo/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Open-Meteo.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 9782051ab22..118deb9d7ae 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -1,7 +1,5 @@ """Support for Open-Meteo weather.""" -from __future__ import annotations - from datetime import datetime, time from open_meteo import Forecast as OpenMeteoForecast diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index 57b23c796db..a28a082ce0f 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -1,7 +1,5 @@ """The OpenRouter integration.""" -from __future__ import annotations - from openai import AsyncOpenAI, AuthenticationError, OpenAIError from homeassistant.config_entries import ConfigEntry @@ -25,7 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) - http_client=get_async_client(hass), ) - # Cache current platform data which gets added to each request (caching done by library) + # Cache current platform data which gets added to each request + # (caching done by library) _ = await hass.async_add_executor_job(client.platform_headers) try: diff --git a/homeassistant/components/open_router/ai_task.py b/homeassistant/components/open_router/ai_task.py index 6c254b050c1..96915b2264d 100644 --- a/homeassistant/components/open_router/ai_task.py +++ b/homeassistant/components/open_router/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for OpenRouter.""" -from __future__ import annotations - from json import JSONDecodeError import logging diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index 85ae4ca3744..a7374ec7238 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenRouter integration.""" -from __future__ import annotations - import logging from typing import Any @@ -143,7 +141,7 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Manage conversation agent configuration.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") if user_input is not None: @@ -258,7 +256,7 @@ class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Manage AI task configuration.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") if user_input is not None: diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index 0bf9fd38ec7..ea3aa9d1d82 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -1,7 +1,5 @@ """Base entity for Open Router.""" -from __future__ import annotations - import base64 from collections.abc import AsyncGenerator, Callable import json @@ -90,9 +88,13 @@ def _format_tool( custom_serializer: Callable[[Any], Any] | None, ) -> ChatCompletionFunctionToolParam: """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + tool_spec = FunctionDefinition( name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), + parameters=schema, ) if tool.description: tool_spec["description"] = tool.description diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 44fed05e136..97805bd82aa 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -1,7 +1,5 @@ """The OpenAI Conversation integration.""" -from __future__ import annotations - from pathlib import Path from types import MappingProxyType @@ -17,7 +15,7 @@ from openai.types.responses import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_PROMPT, Platform from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -44,8 +42,9 @@ from .const import ( CONF_CHAT_MODEL, CONF_FILENAMES, CONF_MAX_TOKENS, - CONF_PROMPT, CONF_REASONING_EFFORT, + CONF_REASONING_SUMMARY, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, DEFAULT_AI_TASK_NAME, @@ -58,6 +57,8 @@ from .const import ( RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_REASONING_SUMMARY, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, @@ -208,7 +209,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE ), "user": call.context.user_id, - "store": False, + "store": conversation_subentry.data.get( + CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES + ), } if model.startswith("o"): @@ -282,7 +285,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo http_client=get_async_client(hass), ) - # Cache current platform data which gets added to each request (caching done by library) + # Cache current platform data which gets added to each request + # (caching done by library) _ = await hass.async_add_executor_job(client.platform_headers) try: @@ -486,6 +490,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> _add_stt_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=6) + if entry.version == 2 and entry.minor_version == 6: + for subentry in entry.subentries.values(): + if subentry.subentry_type in ("conversation", "ai_task_data"): + data = dict(subentry.data) + updated = False + if data.get(CONF_REASONING_SUMMARY) == "short": + data[CONF_REASONING_SUMMARY] = "concise" + updated = True + if data.get(CONF_REASONING_SUMMARY) == "concise" and not data.get( + CONF_CHAT_MODEL, "" + ).startswith("gpt-5"): + data[CONF_REASONING_SUMMARY] = RECOMMENDED_REASONING_SUMMARY + updated = True + if updated: + hass.config_entries.async_update_subentry( + entry, subentry, data=data + ) + hass.config_entries.async_update_entry(entry, minor_version=7) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index d917a957771..853e2ed9c47 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for OpenAI.""" -from __future__ import annotations - import base64 from json import JSONDecodeError import logging diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 5843e2f36c8..225191dc43e 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenAI Conversation integration.""" -from __future__ import annotations - from collections.abc import Mapping import json import logging @@ -27,6 +25,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, + CONF_PROMPT, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm @@ -50,11 +49,11 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, - CONF_PROMPT, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_TTS_SPEED, @@ -82,6 +81,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, @@ -125,7 +125,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 6 + MINOR_VERSION = 7 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -255,7 +255,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Manage initial options.""" # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") options = self.options @@ -357,6 +357,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): CONF_TEMPERATURE, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + vol.Optional( + CONF_STORE_RESPONSES, + default=RECOMMENDED_STORE_RESPONSES, + ): bool, } if user_input is not None: @@ -429,23 +433,37 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): mode=SelectSelectorMode.DROPDOWN, ) ), + } + ) + elif CONF_VERBOSITY in options: + options.pop(CONF_VERBOSITY) + + if model.startswith(("o", "gpt-5")): + reasoning_summary_options = ["off", "auto", "concise", "detailed"] + if model.startswith("o"): + reasoning_summary_options.remove("concise") + stored_summary = options.get( + CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY + ) + if stored_summary not in reasoning_summary_options: + stored_summary = RECOMMENDED_REASONING_SUMMARY + options[CONF_REASONING_SUMMARY] = stored_summary + step_schema.update( + { vol.Optional( CONF_REASONING_SUMMARY, - default=RECOMMENDED_REASONING_SUMMARY, + default=stored_summary, ): SelectSelector( SelectSelectorConfig( - options=["off", "auto", "short", "detailed"], + options=reasoning_summary_options, translation_key=CONF_REASONING_SUMMARY, mode=SelectSelectorMode.DROPDOWN, ) ), } ) - elif CONF_VERBOSITY in options: - options.pop(CONF_VERBOSITY) - if CONF_REASONING_SUMMARY in options: - if not model.startswith("gpt-5"): - options.pop(CONF_REASONING_SUMMARY) + elif CONF_REASONING_SUMMARY in options: + options.pop(CONF_REASONING_SUMMARY) service_tiers = self._get_service_tiers(model) if "flex" in service_tiers or "priority" in service_tiers: @@ -519,7 +537,12 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): vol.Optional(CONF_IMAGE_MODEL, default=RECOMMENDED_IMAGE_MODEL) ] = SelectSelector( SelectSelectorConfig( - options=["gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"], + options=[ + "gpt-image-2", + "gpt-image-1.5", + "gpt-image-1", + "gpt-image-1-mini", + ], mode=SelectSelectorMode.DROPDOWN, ) ) @@ -568,8 +591,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { - ("gpt-5.2-pro", "gpt-5.4-pro"): ["medium", "high", "xhigh"], - ("gpt-5.2", "gpt-5.3", "gpt-5.4"): [ + ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], + ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", "low", "medium", @@ -613,7 +636,9 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): { vol.Optional( CONF_WEB_SEARCH_CITY, - description="Free text input for the city, e.g. `San Francisco`", + description=( + "Free text input for the city, e.g. `San Francisco`" + ), ): str, vol.Optional( CONF_WEB_SEARCH_REGION, @@ -641,7 +666,9 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): "strict": False, } }, - store=False, + store=self.options.get( + CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES + ), ) location_data = location_schema(json.loads(response.output_text) or {}) @@ -683,7 +710,7 @@ class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Manage initial options.""" # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") options = self.options @@ -772,7 +799,7 @@ class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Manage initial options.""" # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") options = self.options diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 2acf2aa9791..5236a0d9f53 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -3,7 +3,7 @@ import logging from typing import Any -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT from homeassistant.helpers import llm DOMAIN = "openai_conversation" @@ -20,10 +20,10 @@ CONF_IMAGE_MODEL = "image_model" CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" -CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" +CONF_STORE_RESPONSES = "store_responses" CONF_SERVICE_TIER = "service_tier" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" @@ -39,9 +39,10 @@ CONF_WEB_SEARCH_TIMEZONE = "timezone" CONF_WEB_SEARCH_INLINE_CITATIONS = "inline_citations" RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" -RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5" +RECOMMENDED_IMAGE_MODEL = "gpt-image-2" RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" +RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" RECOMMENDED_SERVICE_TIER = "auto" RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe" @@ -70,7 +71,6 @@ UNSUPPORTED_MODELS: list[str] = [ ] UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ - "gpt-5-nano", "gpt-3.5", "gpt-4-turbo", "gpt-4.1-nano", diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 803825c2810..c1f233fade3 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -4,12 +4,12 @@ from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry -from .const import CONF_PROMPT, DOMAIN +from .const import DOMAIN from .entity import OpenAIBaseLLMEntity # Max number of back and forth with the LLM to generate a response diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 50a4f6f8f7e..5ac94beb19a 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -1,7 +1,5 @@ """Base entity for OpenAI.""" -from __future__ import annotations - import base64 from collections.abc import AsyncGenerator, Callable, Iterable import json @@ -43,7 +41,10 @@ from openai.types.responses import ( ToolParam, WebSearchToolParam, ) -from openai.types.responses.response_create_params import ResponseCreateParamsStreaming +from openai.types.responses.response_create_params import ( + Reasoning, + ResponseCreateParamsStreaming, +) from openai.types.responses.response_input_param import ( FunctionCallOutput, ImageGenerationCall as ImageGenerationCallParam, @@ -75,6 +76,7 @@ from .const import ( CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_VERBOSITY, @@ -94,6 +96,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, @@ -112,7 +115,7 @@ MAX_TOOL_ITERATIONS = 10 def _adjust_schema(schema: dict[str, Any]) -> None: - """Adjust the schema to be compatible with OpenAI API.""" + """Adjust the output schema to be compatible with OpenAI API.""" if schema["type"] == "object": schema.setdefault("strict", True) schema.setdefault("additionalProperties", False) @@ -156,10 +159,15 @@ def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None ) -> FunctionToolParam: """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf", "enum", "not"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + if unsupported_keys.intersection(schema): + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + return FunctionToolParam( type="function", name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), + parameters=schema, description=tool.description, strict=False, ) @@ -508,21 +516,24 @@ class OpenAIBaseLLMEntity(Entity): max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), user=chat_log.conversation_id, service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER), - store=False, + store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES), stream=True, ) if model_args["model"].startswith(("o", "gpt-5")): - model_args["reasoning"] = { + reasoning: Reasoning = { "effort": options.get( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) if not model_args["model"].startswith("gpt-5-pro") else "high", # GPT-5 pro only supports reasoning.effort: high - "summary": options.get( - CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY - ), } + reasoning_summary = options.get( + CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY + ) + if reasoning_summary != "off": + reasoning["summary"] = reasoning_summary + model_args["reasoning"] = reasoning model_args["include"] = ["reasoning.encrypted_content"] if ( @@ -584,8 +595,8 @@ class OpenAIBaseLLMEntity(Entity): ) ) - if "reasoning" not in model_args: - # Reasoning models handle this correctly with just a prompt + if not model_args["model"].startswith("o"): + # o-series models handle this correctly with just a prompt remove_citations = True tools.append(web_search) @@ -608,11 +619,13 @@ class OpenAIBaseLLMEntity(Entity): model=image_model, output_format="png", ) - if image_model != "gpt-image-1-mini": + if image_model not in ("gpt-image-1-mini", "gpt-image-2"): image_tool["input_fidelity"] = "high" tools.append(image_tool) + # Keep image state on OpenAI so follow-up prompts can continue by + # conversation ID without resending the generated image data. + model_args["store"] = True model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation") - model_args["store"] = True # Avoid sending image data back and forth if tools: model_args["tools"] = tools @@ -632,8 +645,8 @@ class OpenAIBaseLLMEntity(Entity): and isinstance(last_message["content"], str) ) last_message["content"] = [ - {"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item] - *files, # type: ignore[list-item] + {"type": "input_text", "text": last_message["content"]}, + *files, ] if structure and structure_name: @@ -652,15 +665,13 @@ class OpenAIBaseLLMEntity(Entity): try: stream = await client.responses.create(**model_args) + content_stream = chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_log, stream, remove_citations), + ) messages.extend( _convert_content_to_param( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, remove_citations), - ) - ] + [content async for content in content_stream] ) ) except openai.RateLimitError as err: @@ -669,7 +680,8 @@ class OpenAIBaseLLMEntity(Entity): and "resource unavailable" in (err.message or "").lower() ): LOGGER.info( - "Flex tier is not available at the moment, continuing with default tier" + "Flex tier is not available at the moment," + " continuing with default tier" ) model_args["service_tier"] = "default" continue diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 18e4c09905d..7460bf938a7 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "openai_conversation", "name": "OpenAI", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": [], + "codeowners": ["@Shulyaka"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/openai_conversation", diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 178910ae097..3193581a3e5 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -51,9 +51,13 @@ "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%]" }, + "data_description": { + "store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]" + }, "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]" }, "init": { @@ -109,9 +113,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", + "store_responses": "Store requests and responses in OpenAI", "temperature": "Temperature", "top_p": "Top P" }, + "data_description": { + "store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs" + }, "title": "Advanced settings" }, "init": { @@ -234,9 +242,9 @@ "reasoning_summary": { "options": { "auto": "[%key:common::state::auto%]", + "concise": "Concise", "detailed": "Detailed", - "off": "[%key:common::state::off%]", - "short": "Short" + "off": "[%key:common::state::off%]" } }, "search_context_size": { diff --git a/homeassistant/components/openai_conversation/stt.py b/homeassistant/components/openai_conversation/stt.py index 4542ead13ff..689eff4e745 100644 --- a/homeassistant/components/openai_conversation/stt.py +++ b/homeassistant/components/openai_conversation/stt.py @@ -1,7 +1,5 @@ """Speech to text support for OpenAI.""" -from __future__ import annotations - from collections.abc import AsyncIterable import io import logging @@ -11,15 +9,11 @@ import wave from openai import OpenAIError from homeassistant.components import stt +from homeassistant.const import CONF_PROMPT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_CHAT_MODEL, - CONF_PROMPT, - DEFAULT_STT_PROMPT, - RECOMMENDED_STT_MODEL, -) +from .const import CONF_CHAT_MODEL, DEFAULT_STT_PROMPT, RECOMMENDED_STT_MODEL from .entity import OpenAIBaseLLMEntity if TYPE_CHECKING: @@ -51,7 +45,8 @@ class OpenAISTTEntity(stt.SpeechToTextEntity, OpenAIBaseLLMEntity): def supported_languages(self) -> list[str]: """Return a list of supported languages.""" # https://developers.openai.com/api/docs/guides/speech-to-text#supported-languages - # The model may also transcribe the audio in other languages but with lower quality + # The model may also transcribe the audio in other + # languages but with lower quality return [ "af-ZA", # Afrikaans "ar-SA", # Arabic diff --git a/homeassistant/components/openai_conversation/tts.py b/homeassistant/components/openai_conversation/tts.py index f3ee614d747..e42462c2d49 100644 --- a/homeassistant/components/openai_conversation/tts.py +++ b/homeassistant/components/openai_conversation/tts.py @@ -1,10 +1,8 @@ """Text to speech support for OpenAI.""" -from __future__ import annotations - from collections.abc import Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from openai import OpenAIError from propcache.api import cached_property @@ -17,11 +15,12 @@ from homeassistant.components.tts import ( Voice, ) from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_PROMPT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_CHAT_MODEL, CONF_PROMPT, CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED +from .const import CONF_CHAT_MODEL, CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED from .entity import OpenAIBaseLLMEntity if TYPE_CHECKING: @@ -51,7 +50,8 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity): _attr_supported_options = [ATTR_VOICE, ATTR_PREFERRED_FORMAT] # https://platform.openai.com/docs/guides/text-to-speech#supported-languages - # The model may also generate the audio in different languages but with lower quality + # The model may also generate the audio in different + # languages but with lower quality _attr_supported_languages = [ "af-ZA", # Afrikaans "ar-SA", # Arabic @@ -166,14 +166,15 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity): client = self.entry.runtime_data response_format = options[ATTR_PREFERRED_FORMAT] - if response_format not in self._supported_formats: - # common aliases - if response_format == "ogg": - response_format = "opus" - elif response_format == "raw": - response_format = "pcm" - else: - response_format = self.default_options[ATTR_PREFERRED_FORMAT] + if response_format in ("ogg", "oga"): + codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus" + elif response_format == "raw": + response_format = codec = "pcm" + elif response_format not in self._supported_formats: + response_format = self.default_options[ATTR_PREFERRED_FORMAT] + codec = response_format + else: + codec = response_format try: async with client.audio.speech.with_streaming_response.create( @@ -182,7 +183,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity): input=message, instructions=str(options.get(CONF_PROMPT)), speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED), - response_format=response_format, + response_format=codec, ) as response: response_data = bytearray() async for chunk in response.iter_bytes(): diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index 3594555ebc4..b2d9c64da31 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -1,7 +1,5 @@ """Component that will help set the OpenALPR cloud for ALPR processing.""" -from __future__ import annotations - import asyncio from base64 import b64encode from http import HTTPStatus diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 30f88df8ed0..fb695f84ab4 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -1,7 +1,5 @@ """Integration for OpenDisplay BLE e-paper displays.""" -from __future__ import annotations - import asyncio import contextlib from dataclasses import dataclass @@ -17,7 +15,11 @@ from opendisplay import ( OpenDisplayError, ) -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -36,7 +38,7 @@ from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _BASE_PLATFORMS: list[Platform] = [] -_FLEX_PLATFORMS = [Platform.SENSOR] +_FLEX_PLATFORMS = [Platform.EVENT, Platform.SENSOR] @dataclass @@ -85,9 +87,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) ble_device = async_ble_device_from_address(hass, address, connectable=True) if ble_device is None: raise ConfigEntryNotReady( - f"Could not find OpenDisplay device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) - encryption_key = _get_encryption_key(entry) try: diff --git a/homeassistant/components/opendisplay/config_flow.py b/homeassistant/components/opendisplay/config_flow.py index 4551cfc3b6d..bcd93e47109 100644 --- a/homeassistant/components/opendisplay/config_flow.py +++ b/homeassistant/components/opendisplay/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenDisplay integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/opendisplay/coordinator.py b/homeassistant/components/opendisplay/coordinator.py index d7c9431c57f..ab65f0e0a12 100644 --- a/homeassistant/components/opendisplay/coordinator.py +++ b/homeassistant/components/opendisplay/coordinator.py @@ -1,12 +1,10 @@ """Passive BLE coordinator for OpenDisplay devices.""" -from __future__ import annotations - -from dataclasses import dataclass +from dataclasses import dataclass, field import logging -from opendisplay import MANUFACTURER_ID, parse_advertisement -from opendisplay.models.advertisement import AdvertisementData +from opendisplay import MANUFACTURER_ID, AdvertisementTracker, parse_advertisement +from opendisplay.models.advertisement import AdvertisementData, ButtonChangeEvent from homeassistant.components.bluetooth import ( BluetoothChange, @@ -27,6 +25,7 @@ class OpenDisplayUpdate: address: str advertisement: AdvertisementData + button_events: list[ButtonChangeEvent] = field(default_factory=list) class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -42,6 +41,7 @@ class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): connectable=True, ) self.data: OpenDisplayUpdate | None = None + self._tracker: AdvertisementTracker = AdvertisementTracker() @callback def _async_handle_unavailable( @@ -78,9 +78,11 @@ class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): exc_info=True, ) else: + button_events = self._tracker.update(service_info.address, advertisement) self.data = OpenDisplayUpdate( address=service_info.address, advertisement=advertisement, + button_events=button_events, ) super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/opendisplay/diagnostics.py b/homeassistant/components/opendisplay/diagnostics.py index f4d5375b5c8..0fd89c3c73a 100644 --- a/homeassistant/components/opendisplay/diagnostics.py +++ b/homeassistant/components/opendisplay/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for OpenDisplay.""" -from __future__ import annotations - import dataclasses from typing import Any diff --git a/homeassistant/components/opendisplay/entity.py b/homeassistant/components/opendisplay/entity.py index 863fdd7214c..622021871db 100644 --- a/homeassistant/components/opendisplay/entity.py +++ b/homeassistant/components/opendisplay/entity.py @@ -1,7 +1,5 @@ """Base entity for OpenDisplay devices.""" -from __future__ import annotations - from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) diff --git a/homeassistant/components/opendisplay/event.py b/homeassistant/components/opendisplay/event.py new file mode 100644 index 00000000000..df3d05c7332 --- /dev/null +++ b/homeassistant/components/opendisplay/event.py @@ -0,0 +1,91 @@ +"""Event platform for OpenDisplay devices — button press/release events.""" + +from dataclasses import dataclass + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenDisplayConfigEntry +from .entity import OpenDisplayEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenDisplayEventEntityDescription(EventEntityDescription): + """Describes an OpenDisplay button event entity.""" + + byte_index: int + button_id: int + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenDisplay event entities from binary_inputs device config.""" + coordinator = entry.runtime_data.coordinator + + descriptions: list[OpenDisplayEventEntityDescription] = [] + button_number = 0 + for bi in entry.runtime_data.device_config.binary_inputs: + for button_id in range(8): # input_flags is a bitmask over 8 pin slots + if bi.input_flags & (1 << button_id): + button_number += 1 + descriptions.append( + OpenDisplayEventEntityDescription( + key=f"button_{bi.instance_number}_{button_id}", + translation_key="button", + translation_placeholders={"number": str(button_number)}, + device_class=EventDeviceClass.BUTTON, + event_types=["button_down", "button_up"], + byte_index=bi.button_data_byte_index, + button_id=button_id, + ) + ) + + active_unique_ids = {f"{coordinator.address}-{d.key}" for d in descriptions} + button_unique_id_prefix = f"{coordinator.address}-button_" + entity_registry = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + if ( + entity_entry.domain == "event" + and entity_entry.unique_id.startswith(button_unique_id_prefix) + and entity_entry.unique_id not in active_unique_ids + ): + entity_registry.async_remove(entity_entry.entity_id) + + async_add_entities( + OpenDisplayEventEntity(coordinator, description) for description in descriptions + ) + + +class OpenDisplayEventEntity(OpenDisplayEntity, EventEntity): + """A button event entity for an OpenDisplay device.""" + + entity_description: OpenDisplayEventEntityDescription + _last_processed_data: object | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Fire events for button transitions reported by this coordinator update.""" + data = self.coordinator.data + if data is not None and data is not self._last_processed_data: + for event in data.button_events: + if ( + event.byte_index == self.entity_description.byte_index + and event.button_id == self.entity_description.button_id + and event.event_type in self.event_types + ): + self._trigger_event(event.event_type) + self._last_processed_data = data + self.async_write_ha_state() diff --git a/homeassistant/components/opendisplay/manifest.json b/homeassistant/components/opendisplay/manifest.json index 60b850eff51..d4335689e53 100644 --- a/homeassistant/components/opendisplay/manifest.json +++ b/homeassistant/components/opendisplay/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_push", "loggers": ["opendisplay"], "quality_scale": "silver", - "requirements": ["py-opendisplay==5.9.0"] + "requirements": ["py-opendisplay==7.2.3"] } diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml index 6a14ae56adc..7d488733d2c 100644 --- a/homeassistant/components/opendisplay/quality_scale.yaml +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -57,7 +57,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: status: exempt diff --git a/homeassistant/components/opendisplay/sensor.py b/homeassistant/components/opendisplay/sensor.py index 2f230ff6c76..c1aa02c8e51 100644 --- a/homeassistant/components/opendisplay/sensor.py +++ b/homeassistant/components/opendisplay/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for OpenDisplay devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py index bbbd129b294..c5a7824c39a 100644 --- a/homeassistant/components/opendisplay/services.py +++ b/homeassistant/components/opendisplay/services.py @@ -1,7 +1,5 @@ """Service registration for the OpenDisplay integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable import contextlib @@ -24,7 +22,11 @@ from opendisplay import ( from PIL import Image as PILImage, ImageOps import voluptuous as vol -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_source import async_resolve_media from homeassistant.config_entries import ConfigEntryState @@ -51,7 +53,7 @@ ATTR_TONE_COMPRESSION = "tone_compression" def _str_to_int_enum(enum_class: type[IntEnum]) -> Callable[[str], Any]: - """Return a validator that converts a lowercase enum name string to an enum member.""" + """Convert a lowercase enum name string to an enum member.""" members = {m.name.lower(): m for m in enum_class} def validate(value: str) -> IntEnum: @@ -110,7 +112,7 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry: if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="device_not_found", + translation_key="config_entry_not_found", translation_placeholders={"address": mac_address}, ) @@ -173,12 +175,20 @@ async def _async_upload_image(call: ServiceCall) -> None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_not_found", - translation_placeholders={"address": address}, + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + call.hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) current = asyncio.current_task() if (prev := entry.runtime_data.upload_task) is not None and not prev.done(): prev.cancel() + # pylint: disable-next=home-assistant-action-swallowed-exception with contextlib.suppress(asyncio.CancelledError): await prev entry.runtime_data.upload_task = current @@ -219,7 +229,7 @@ async def _async_upload_image(call: ServiceCall) -> None: pil_image, refresh_mode=refresh_mode, dither_mode=dither_mode, - tone_compression=tone_compression, + tone=tone_compression, fit=fit_mode, rotate=rotation, ) diff --git a/homeassistant/components/opendisplay/services.yaml b/homeassistant/components/opendisplay/services.yaml index 880da3711cb..856c942f95b 100644 --- a/homeassistant/components/opendisplay/services.yaml +++ b/homeassistant/components/opendisplay/services.yaml @@ -11,7 +11,7 @@ upload_image: media: accept: - image/* - advanced_options: + additional_fields: collapsed: true fields: rotation: diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index bfc4ffe9001..90eb2b716af 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -51,6 +51,19 @@ } }, "entity": { + "event": { + "button": { + "name": "Button {number}", + "state_attributes": { + "event_type": { + "state": { + "button_down": "Button down", + "button_up": "Button up" + } + } + } + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" @@ -61,8 +74,11 @@ "authentication_error": { "message": "Authentication failed. Please update the encryption key." }, + "config_entry_not_found": { + "message": "Config entry not found: `{address}`" + }, "device_not_found": { - "message": "Could not find Bluetooth device with address `{address}`." + "message": "Could not find Bluetooth device with address `{address}`. Reason: {reason}" }, "invalid_device_id": { "message": "Device `{device_id}` is not a valid OpenDisplay device." @@ -138,8 +154,8 @@ }, "name": "Upload image", "sections": { - "advanced_options": { - "name": "Advanced options" + "additional_fields": { + "name": "Additional options" } } } diff --git a/homeassistant/components/openerz/sensor.py b/homeassistant/components/openerz/sensor.py index f41b468b224..3297caae84e 100644 --- a/homeassistant/components/openerz/sensor.py +++ b/homeassistant/components/openerz/sensor.py @@ -1,7 +1,5 @@ """Support for OpenERZ API for Zurich city waste disposal system.""" -from __future__ import annotations - from datetime import timedelta from openerz_api.main import OpenERZConnector @@ -11,6 +9,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,7 +19,6 @@ SCAN_INTERVAL = timedelta(hours=12) CONF_ZIP = "zip" CONF_WASTE_TYPE = "waste_type" -CONF_NAME = "name" PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 1e792d19ba6..6b1090e11f7 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -1,17 +1,17 @@ """The OpenEVSE integration.""" -from __future__ import annotations - from openevsehttp.__main__ import OpenEVSE +from openevsehttp.exceptions import AuthenticationError from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator -PLATFORMS = [Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool: @@ -26,7 +26,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> try: await charger.test_and_get() except TimeoutError as ex: - raise ConfigEntryNotReady("Unable to connect to charger") from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from ex + except AuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from ex coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/openevse/binary_sensor.py b/homeassistant/components/openevse/binary_sensor.py new file mode 100644 index 00000000000..1fb74a52495 --- /dev/null +++ b/homeassistant/components/openevse/binary_sensor.py @@ -0,0 +1,120 @@ +"""Support for monitoring OpenEVSE Charger binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from openevsehttp.__main__ import OpenEVSE + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSEBinarySensorDescription(BinarySensorEntityDescription): + """Describes an OpenEVSE binary sensor entity.""" + + value_fn: Callable[[OpenEVSE], bool | None] + + +BINARY_SENSOR_TYPES: tuple[OpenEVSEBinarySensorDescription, ...] = ( + OpenEVSEBinarySensorDescription( + key="vehicle", + translation_key="vehicle", + device_class=BinarySensorDeviceClass.PLUG, + value_fn=lambda ev: ev.vehicle, + ), + OpenEVSEBinarySensorDescription( + key="divert_active", + translation_key="divert_active", + value_fn=lambda ev: ev.divert_active, + ), + OpenEVSEBinarySensorDescription( + key="using_ethernet", + translation_key="using_ethernet", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.using_ethernet, + ), + OpenEVSEBinarySensorDescription( + key="shaper_active", + translation_key="shaper_active", + value_fn=lambda ev: ev.shaper_active, + ), + OpenEVSEBinarySensorDescription( + key="has_limit", + translation_key="has_limit", + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.has_limit, + ), + OpenEVSEBinarySensorDescription( + key="mqtt_connected", + translation_key="mqtt_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.mqtt_connected, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE binary sensors based on config entry.""" + coordinator = entry.runtime_data + identifier = entry.unique_id or entry.entry_id + async_add_entities( + OpenEVSEBinarySensor(coordinator, description, identifier, entry.unique_id) + for description in BINARY_SENSOR_TYPES + ) + + +class OpenEVSEBinarySensor( + CoordinatorEntity[OpenEVSEDataUpdateCoordinator], BinarySensorEntity +): + """Implementation of an OpenEVSE binary sensor.""" + + _attr_has_entity_name = True + entity_description: OpenEVSEBinarySensorDescription + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSEBinarySensorDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + def is_on(self) -> bool | None: + """Return True if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.charger) diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index 264b306654c..cac86123254 100644 --- a/homeassistant/components/openevse/config_flow.py +++ b/homeassistant/components/openevse/config_flow.py @@ -1,5 +1,6 @@ """Config flow for OpenEVSE integration.""" +from collections.abc import Mapping from typing import Any from openevsehttp.__main__ import OpenEVSE @@ -7,12 +8,18 @@ from openevsehttp.exceptions import AuthenticationError, MissingSerial import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info import zeroconf -from .const import CONF_ID, CONF_SERIAL, DOMAIN +from .const import CONF_SERIAL, DOMAIN USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) @@ -170,3 +177,38 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication on an authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + errors, _ = await self.check_status( + reauth_entry.data[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input), + description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]}, + errors=errors, + ) diff --git a/homeassistant/components/openevse/const.py b/homeassistant/components/openevse/const.py index e15becdcec3..b9381f21e12 100644 --- a/homeassistant/components/openevse/const.py +++ b/homeassistant/components/openevse/const.py @@ -1,6 +1,5 @@ """Constants for the OpenEVSE integration.""" -CONF_ID = "id" CONF_SERIAL = "serial" DOMAIN = "openevse" INTEGRATION_TITLE = "OpenEVSE" diff --git a/homeassistant/components/openevse/coordinator.py b/homeassistant/components/openevse/coordinator.py index dfbb8cc6781..dceb61573f6 100644 --- a/homeassistant/components/openevse/coordinator.py +++ b/homeassistant/components/openevse/coordinator.py @@ -1,14 +1,14 @@ """Data update coordinator for OpenEVSE.""" -from __future__ import annotations - from datetime import timedelta import logging from openevsehttp.__main__ import OpenEVSE +from openevsehttp.exceptions import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -63,5 +63,11 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.charger.update() except TimeoutError as error: raise UpdateFailed( - f"Timeout communicating with charger: {error}" + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + except AuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", ) from error diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py new file mode 100644 index 00000000000..f055735650c --- /dev/null +++ b/homeassistant/components/openevse/diagnostics.py @@ -0,0 +1,102 @@ +"""Provide diagnostics for OpenEVSE.""" + +import asyncio +from datetime import date, datetime +from enum import Enum +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .coordinator import OpenEVSEConfigEntry + +REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME} +CHARGER_PROPERTIES = ( + "status", + "vehicle", + "mode", + "charge_mode", + "divertmode", + "manual_override", + "ota_update", + "service_level", + "charge_time_elapsed", + "vehicle_eta", + "charging_current", + "charging_voltage", + "charging_power", + "current_power", + "current_capacity", + "max_current", + "min_amps", + "max_amps", + "max_current_soft", + "available_current", + "smoothed_available_current", + "charge_rate", + "ambient_temperature", + "ir_temperature", + "rtc_temperature", + "esp_temperature", + "usage_session", + "usage_total", + "total_day", + "total_week", + "total_month", + "total_year", + "vehicle_soc", + "vehicle_range", + "wifi_signal", + "shaper_live_power", + "shaper_available_current", + "shaper_max_power", + "gfi_trip_count", + "no_gnd_trip_count", + "stuck_relay_trip_count", + "uptime", + "freeram", + "wifi_firmware", + "openevse_firmware", +) + + +def _to_json_safe(val: Any) -> Any: + """Coerce value to be JSON-serializable.""" + if isinstance(val, (datetime, date)): + return val.isoformat() + if isinstance(val, Enum): + return val.value + return val + + +async def async_get_config_entry_diagnostics( + _hass: HomeAssistant, config_entry: OpenEVSEConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + charger = coordinator.charger + + charger_data: dict[str, Any] = {} + + for prop in CHARGER_PROPERTIES: + try: + val = getattr(charger, prop) + except AttributeError: + continue + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 + charger_data[prop] = f"Error: {type(err).__name__}" + continue + + # Top-level callables on the charger object are omitted from diagnostics. + if callable(val): + continue + + charger_data[prop] = _to_json_safe(val) + + return { + "config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA), + "charger": charger_data, + } diff --git a/homeassistant/components/openevse/helpers.py b/homeassistant/components/openevse/helpers.py new file mode 100644 index 00000000000..b15cdccab93 --- /dev/null +++ b/homeassistant/components/openevse/helpers.py @@ -0,0 +1,52 @@ +"""Helpers for OpenEVSE.""" + +from collections.abc import Iterator +from contextlib import contextmanager + +from aiohttp import ContentTypeError, ServerTimeoutError +from openevsehttp.exceptions import ( + AuthenticationError, + ParseJSONError, + UnsupportedFeature, +) + +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) + +from .const import DOMAIN + + +@contextmanager +def openevse_exception_handler(value: float) -> Iterator[None]: + """Context manager to handle and translate OpenEVSE exceptions.""" + try: + yield + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value", + translation_placeholders={"value": str(value)}, + ) from err + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from err + except UnsupportedFeature as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_feature", + ) from err + except ( + TimeoutError, + ServerTimeoutError, + ContentTypeError, + ParseJSONError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index 3902ac70ca4..f23604ef14e 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["openevsehttp"], "quality_scale": "bronze", - "requirements": ["python-openevse-http==0.2.5"], + "requirements": ["python-openevse-http==0.3.4"], "zeroconf": ["_openevse._tcp.local."] } diff --git a/homeassistant/components/openevse/number.py b/homeassistant/components/openevse/number.py index d4d8541aee3..7918250465c 100644 --- a/homeassistant/components/openevse/number.py +++ b/homeassistant/components/openevse/number.py @@ -24,6 +24,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator +from .helpers import openevse_exception_handler PARALLEL_UPDATES = 0 @@ -113,4 +114,5 @@ class OpenEVSENumber(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], NumberEnt async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.entity_description.set_value_fn(self.coordinator.charger, value) + with openevse_exception_handler(value): + await self.entity_description.set_value_fn(self.coordinator.charger, value) diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 571c2dcaad8..f0e3564db46 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring an OpenEVSE Charger.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -101,7 +99,8 @@ SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = ( OpenEVSESensorDescription( key="charging_current", translation_key="charging_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ev: ev.charging_current, @@ -117,7 +116,8 @@ SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = ( OpenEVSESensorDescription( key="charging_power", translation_key="charging_power", - native_unit_of_measurement=UnitOfPower.WATT, + native_unit_of_measurement=UnitOfPower.MILLIWATT, + suggested_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ev: ev.charging_power, diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index 3a76b2bb27f..71b8fec9194 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "This charger is already configured", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unavailable_host": "Unable to connect to host" }, "error": { @@ -19,6 +20,17 @@ "username": "The username to access your OpenEVSE charger" } }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::openevse::config::step::auth::data_description::password%]", + "username": "[%key:component::openevse::config::step::auth::data_description::username%]" + }, + "description": "The credentials for your OpenEVSE charger at {host} are no longer valid. Please enter your current username and password." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -30,6 +42,26 @@ } }, "entity": { + "binary_sensor": { + "divert_active": { + "name": "Divert active" + }, + "has_limit": { + "name": "Limit active" + }, + "mqtt_connected": { + "name": "MQTT connected" + }, + "shaper_active": { + "name": "Shaper active" + }, + "using_ethernet": { + "name": "Ethernet connected" + }, + "vehicle": { + "name": "Vehicle connected" + } + }, "number": { "charge_rate": { "name": "Charge rate" @@ -154,6 +186,20 @@ } } }, + "exceptions": { + "authentication_error": { + "message": "Authentication failed" + }, + "communication_error": { + "message": "Failed to communicate with the charger" + }, + "invalid_value": { + "message": "Value {value} is invalid for the charger." + }, + "unsupported_feature": { + "message": "The charger does not support this feature." + } + }, "issues": { "deprecated_yaml_import_issue_unavailable_host": { "description": "Configuring {integration_title} using YAML is being removed but there was a connection error while trying to import the YAML configuration.\n\nEnsure your OpenEVSE charger is accessible and restart Home Assistant to try again.", diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index 4559c098acb..02e9b7a6e4c 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -1,7 +1,5 @@ """The Open Exchange Rates integration.""" -from __future__ import annotations - from homeassistant.const import CONF_API_KEY, CONF_BASE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index ffcc60bfa26..879afbf601a 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Open Exchange Rates integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 295e6f33d72..fd679972d9a 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -1,7 +1,5 @@ """Provide an OpenExchangeRates data coordinator.""" -from __future__ import annotations - import asyncio from datetime import timedelta diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index cb493ab5e84..1847fabed1b 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -1,7 +1,5 @@ """Support for openexchangerates.org exchange rates service.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index af494955375..73b6ca334eb 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -1,7 +1,5 @@ """The OpenGarage integration.""" -from __future__ import annotations - import opengarage from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL, Platform diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index d538f261db9..22d0274a692 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for the opengarage.io binary sensor component.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index 24920e80e19..6939448cfb2 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -1,7 +1,5 @@ """OpenGarage button.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py index e4576ae4b70..eff84d9039d 100644 --- a/homeassistant/components/opengarage/config_flow.py +++ b/homeassistant/components/opengarage/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenGarage integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/opengarage/coordinator.py b/homeassistant/components/opengarage/coordinator.py index f384bd47d26..e1ba656b151 100644 --- a/homeassistant/components/opengarage/coordinator.py +++ b/homeassistant/components/opengarage/coordinator.py @@ -1,7 +1,5 @@ """The OpenGarage integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 79b6200aeb1..1cd169b98a6 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,7 +1,5 @@ """Platform for the opengarage.io cover component.""" -from __future__ import annotations - import logging from typing import Any, cast diff --git a/homeassistant/components/opengarage/entity.py b/homeassistant/components/opengarage/entity.py index 60f7b323469..539c75c29b3 100644 --- a/homeassistant/components/opengarage/entity.py +++ b/homeassistant/components/opengarage/entity.py @@ -1,7 +1,5 @@ """Entity for the opengarage.io component.""" -from __future__ import annotations - from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index cf3625fabb1..edbe7da3183 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -1,7 +1,5 @@ """Platform for the opengarage.io sensor component.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index fe8511b4416..0234255817d 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -1,7 +1,5 @@ """Support for Open Hardware Monitor Sensor Platform.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py index 9cd6a79f012..ac8fff47900 100644 --- a/homeassistant/components/openhome/config_flow.py +++ b/homeassistant/components/openhome/config_flow.py @@ -37,11 +37,15 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") - _LOGGER.debug( - "async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN] - ) + udn = discovery_info.upnp[ATTR_UPNP_UDN] + if isinstance(udn, list): + if not udn: + return self.async_abort(reason="incomplete_discovery") + udn = udn[0] - await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) + _LOGGER.debug("async_step_ssdp: setting unique id %s", udn) + + await self.async_set_unique_id(udn) self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location}) _LOGGER.debug( diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 21730c401c4..3baa4573482 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -1,7 +1,5 @@ """Support for Openhome Devices.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine import functools import logging @@ -172,16 +170,19 @@ class OpenhomeDevice(MediaPlayerEntity): except TimeoutError, aiohttp.ClientError, UpnpError: self._attr_available = False + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_turn_on(self) -> None: """Bring device out of standby.""" await self._device.set_standby(False) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_turn_off(self) -> None: """Put device in standby.""" await self._device.set_standby(True) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any @@ -207,31 +208,37 @@ class OpenhomeDevice(MediaPlayerEntity): track_details = {"title": "Home Assistant", "uri": media_id} await self._device.play_media(track_details) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_media_pause(self) -> None: """Send pause command.""" await self._device.pause() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_media_stop(self) -> None: """Send stop command.""" await self._device.stop() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_media_play(self) -> None: """Send play command.""" await self._device.play() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_media_next_track(self) -> None: """Send next track command.""" await self._device.skip(1) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_media_previous_track(self) -> None: """Send previous track command.""" await self._device.skip(-1) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_select_source(self, source: str) -> None: """Select input source.""" @@ -248,21 +255,25 @@ class OpenhomeDevice(MediaPlayerEntity): except UpnpError: _LOGGER.error("Error invoking pin %s", pin) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_volume_up(self) -> None: """Volume up media player.""" await self._device.increase_volume() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_volume_down(self) -> None: """Volume down media player.""" await self._device.decrease_volume() + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._device.set_volume(int(volume * 100)) + # pylint: disable-next=home-assistant-action-swallowed-exception @catch_request_errors() async def async_mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" diff --git a/homeassistant/components/openhome/services.py b/homeassistant/components/openhome/services.py index 2edd8c2acab..d6f59d0902b 100644 --- a/homeassistant/components/openhome/services.py +++ b/homeassistant/components/openhome/services.py @@ -1,7 +1,5 @@ """Support for Openhome Devices.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index fc5f4bb2f7a..8212a3bae22 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -1,7 +1,5 @@ """Update entities for Linn devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py index 5b156e9e63c..82eb9fafe8d 100644 --- a/homeassistant/components/openrgb/__init__.py +++ b/homeassistant/components/openrgb/__init__.py @@ -1,7 +1,5 @@ """The OpenRGB integration.""" -from __future__ import annotations - from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/openrgb/config_flow.py b/homeassistant/components/openrgb/config_flow.py index 687cfdd3f99..431df9d11af 100644 --- a/homeassistant/components/openrgb/config_flow.py +++ b/homeassistant/components/openrgb/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the OpenRGB integration.""" -from __future__ import annotations - import logging from typing import Any @@ -19,6 +17,8 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME): str, vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, diff --git a/homeassistant/components/openrgb/coordinator.py b/homeassistant/components/openrgb/coordinator.py index c5189d807ab..ecc5c5ff992 100644 --- a/homeassistant/components/openrgb/coordinator.py +++ b/homeassistant/components/openrgb/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for OpenRGB.""" -from __future__ import annotations - import asyncio import logging @@ -66,6 +64,7 @@ class OpenRGBCoordinator(DataUpdateCoordinator[dict[str, Device]]): DEFAULT_CLIENT_NAME, ) except CONNECTION_ERRORS as err: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py index ce4e47c6fa7..630ee78d7a8 100644 --- a/homeassistant/components/openrgb/light.py +++ b/homeassistant/components/openrgb/light.py @@ -1,7 +1,5 @@ """OpenRGB light platform.""" -from __future__ import annotations - import asyncio from typing import Any @@ -187,7 +185,8 @@ class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity): # If mode is Off, retain previous color mode to avoid changing the UI color_mode = self._attr_color_mode else: - # If the current mode is not Off and does not support color, change to ON/OFF mode + # If the current mode is not Off and does not support + # color, change to ON/OFF mode color_mode = ColorMode.ONOFF if not on_by_color: @@ -360,8 +359,9 @@ class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity): and (self._attr_brightness is None or self._attr_rgb_color is None) ) - # If color/brightness restoration require color support but mode doesn't support it, - # switch to a color-capable mode + # If color/brightness restoration require color support + # but mode doesn't support it, switch to a color-capable + # mode if need_to_apply_color and not mode_supports_color: mode = self._preferred_no_effect_mode diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 368ba6cf4b2..77d1de6f537 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -1,7 +1,5 @@ """Select platform for OpenRGB integration.""" -from __future__ import annotations - from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/openrgb/strings.json b/homeassistant/components/openrgb/strings.json index 61b4d10a4a6..5554911a30f 100644 --- a/homeassistant/components/openrgb/strings.json +++ b/homeassistant/components/openrgb/strings.json @@ -73,6 +73,9 @@ } }, "exceptions": { + "cannot_connect": { + "message": "Failed to connect to OpenRGB SDK server {server_address}: {error}" + }, "communication_error": { "message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}" }, diff --git a/homeassistant/components/opensensemap/__init__.py b/homeassistant/components/opensensemap/__init__.py index e03f4133d88..85db0613008 100644 --- a/homeassistant/components/opensensemap/__init__.py +++ b/homeassistant/components/opensensemap/__init__.py @@ -1 +1,33 @@ -"""The opensensemap component.""" +"""The openSenseMap integration.""" + +from opensensemap_api import OpenSenseMap + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID +from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator + +PLATFORMS: list[Platform] = [Platform.AIR_QUALITY] + + +async def async_setup_entry( + hass: HomeAssistant, entry: OpenSenseMapConfigEntry +) -> bool: + """Set up openSenseMap from a config entry.""" + session = async_get_clientsession(hass) + api = OpenSenseMap(entry.data[CONF_STATION_ID], session) + coordinator = OpenSenseMapCoordinator(hass, entry, api) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: OpenSenseMapConfigEntry +) -> bool: + """Unload an openSenseMap config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 19d19f19a54..ce3719ebbb4 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -1,33 +1,31 @@ """Support for openSenseMap Air Quality data.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -from opensensemap_api import OpenSenseMap -from opensensemap_api.exceptions import OpenSenseMapError import voluptuous as vol from homeassistant.components.air_quality import ( PLATFORM_SCHEMA as AIR_QUALITY_PLATFORM_SCHEMA, AirQualityEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) - - -CONF_STATION_ID = "station_id" - -SCAN_INTERVAL = timedelta(minutes=10) +from .const import ( + CONF_STATION_ID, + DEPRECATED_YAML_BREAKS_IN_VERSION, + DOMAIN, + INTEGRATION_TITLE, + KNOWN_IMPORT_ABORT_REASONS, +) +from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator PLATFORM_SCHEMA = AIR_QUALITY_PLATFORM_SCHEMA.extend( {vol.Required(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME): cv.string} @@ -40,67 +38,88 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the openSenseMap air quality platform.""" + """Import legacy YAML configuration into a config entry.""" + # Keep the legacy platform entry point so existing YAML is migrated into a + # config entry instead of adding entities directly from YAML. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) - name = config.get(CONF_NAME) - station_id = config[CONF_STATION_ID] + if ( + result["type"] is FlowResultType.ABORT + and result["reason"] in KNOWN_IMPORT_ABORT_REASONS + ): + # Per-reason issue conveys the deprecation notice itself, so don't also + # raise the generic deprecated_yaml issue on top of it. + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION, + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + return - session = async_get_clientsession(hass) - osm_api = OpenSenseMapData(OpenSenseMap(station_id, session)) - - await osm_api.async_update() - - if "name" not in osm_api.api.data: - _LOGGER.error("Station %s is not available", station_id) - raise PlatformNotReady - - station_name = osm_api.api.data["name"] if name is None else name - - async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True) + # "deprecated_yaml" translation key lives under the "homeassistant" core domain. + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION, + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) -class OpenSenseMapQuality(AirQualityEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenSenseMapConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the openSenseMap air quality entity from a config entry.""" + async_add_entities( + [ + OpenSenseMapQuality( + entry.runtime_data, entry.data[CONF_STATION_ID], entry.title + ) + ] + ) + + +class OpenSenseMapQuality(CoordinatorEntity[OpenSenseMapCoordinator], AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" _attr_attribution = "Data provided by openSenseMap" - def __init__(self, name, osm): + def __init__( + self, coordinator: OpenSenseMapCoordinator, station_id: str, name: str + ) -> None: """Initialize the air quality entity.""" - self._name = name - self._osm = osm + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = station_id @property - def name(self): - """Return the name of the air quality entity.""" - return self._name - - @property - def particulate_matter_2_5(self): + def particulate_matter_2_5(self) -> float | None: """Return the particulate matter 2.5 level.""" - return self._osm.api.pm2_5 + return self.coordinator.data.pm2_5 @property - def particulate_matter_10(self): + def particulate_matter_10(self) -> float | None: """Return the particulate matter 10 level.""" - return self._osm.api.pm10 - - async def async_update(self): - """Get the latest data from the openSenseMap API.""" - await self._osm.async_update() - - -class OpenSenseMapData: - """Get the latest data and update the states.""" - - def __init__(self, api): - """Initialize the data object.""" - self.api = api - - @Throttle(SCAN_INTERVAL) - async def async_update(self): - """Get the latest data from the Pi-hole.""" - - try: - await self.api.get_data() - except OpenSenseMapError as err: - _LOGGER.error("Unable to fetch data: %s", err) + return self.coordinator.data.pm10 diff --git a/homeassistant/components/opensensemap/config_flow.py b/homeassistant/components/opensensemap/config_flow.py new file mode 100644 index 00000000000..b3056d73386 --- /dev/null +++ b/homeassistant/components/opensensemap/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for the openSenseMap integration.""" + +from typing import Any + +from opensensemap_api import OpenSenseMap +from opensensemap_api.exceptions import OpenSenseMapError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID, DOMAIN, ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION + + +class CannotConnect(HomeAssistantError): + """Error to indicate the openSenseMap API is unreachable.""" + + +class InvalidStation(HomeAssistantError): + """Error to indicate the station ID does not exist.""" + + +class OpenSenseMapConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for openSenseMap.""" + + VERSION = 1 + + async def _async_get_station_name(self, station_id: str) -> str: + """Validate the station ID and return its name.""" + session = async_get_clientsession(self.hass) + api = OpenSenseMap(station_id, session) + try: + # opensensemap_api wraps the request in a 5s aiohttp.ClientTimeout + # and re-raises asyncio.TimeoutError as OpenSenseMapConnectionError. + await api.get_data() + except OpenSenseMapError as err: + raise CannotConnect from err + if not api.data or not api.data.get("name"): + raise InvalidStation + return api.data["name"] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a user-initiated config flow.""" + errors: dict[str, str] = {} + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + try: + name = await self._async_get_station_name(station_id) + except CannotConnect: + errors["base"] = ERROR_CANNOT_CONNECT + except InvalidStation: + errors["base"] = ERROR_INVALID_STATION + else: + await self.async_set_unique_id(station_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={CONF_STATION_ID: station_id}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_STATION_ID): str}), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import of a YAML configuration.""" + station_id = import_data[CONF_STATION_ID] + await self.async_set_unique_id(station_id) + self._abort_if_unique_id_configured() + + # Even when YAML provides a display name, validate the station before + # migrating so broken YAML does not create an entry that cannot set up. + try: + name = await self._async_get_station_name(station_id) + except CannotConnect: + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + except InvalidStation: + return self.async_abort(reason=ERROR_INVALID_STATION) + + return self.async_create_entry( + title=import_data.get(CONF_NAME) or name, + data={CONF_STATION_ID: station_id}, + ) diff --git a/homeassistant/components/opensensemap/const.py b/homeassistant/components/opensensemap/const.py new file mode 100644 index 00000000000..b8f1f01f336 --- /dev/null +++ b/homeassistant/components/opensensemap/const.py @@ -0,0 +1,16 @@ +"""Constants for the openSenseMap integration.""" + +import logging + +DOMAIN = "opensensemap" + +LOGGER = logging.getLogger(__name__) + +CONF_STATION_ID = "station_id" + +INTEGRATION_TITLE = "openSenseMap" +DEPRECATED_YAML_BREAKS_IN_VERSION = "2026.12.0" + +ERROR_CANNOT_CONNECT = "cannot_connect" +ERROR_INVALID_STATION = "invalid_station" +KNOWN_IMPORT_ABORT_REASONS = (ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION) diff --git a/homeassistant/components/opensensemap/coordinator.py b/homeassistant/components/opensensemap/coordinator.py new file mode 100644 index 00000000000..fd94363a3f8 --- /dev/null +++ b/homeassistant/components/opensensemap/coordinator.py @@ -0,0 +1,58 @@ +"""Data update coordinator for the openSenseMap integration.""" + +from dataclasses import dataclass +from datetime import timedelta + +from opensensemap_api import OpenSenseMap +from opensensemap_api.exceptions import OpenSenseMapError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +SCAN_INTERVAL = timedelta(minutes=10) + + +@dataclass(slots=True, frozen=True) +class OpenSenseMapStationData: + """Immutable measurements for an openSenseMap station.""" + + pm2_5: float | None + pm10: float | None + + +type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator] + + +class OpenSenseMapCoordinator(DataUpdateCoordinator[OpenSenseMapStationData]): + """Coordinator to manage data updates for an openSenseMap station.""" + + config_entry: OpenSenseMapConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OpenSenseMapConfigEntry, + api: OpenSenseMap, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> OpenSenseMapStationData: + """Fetch latest data from the openSenseMap API.""" + try: + await self.api.get_data() + except OpenSenseMapError as err: + raise UpdateFailed( + f"Unable to fetch data from openSenseMap: {err}" + ) from err + return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10) diff --git a/homeassistant/components/opensensemap/manifest.json b/homeassistant/components/opensensemap/manifest.json index 0256ae42a3a..f7983ef7aac 100644 --- a/homeassistant/components/opensensemap/manifest.json +++ b/homeassistant/components/opensensemap/manifest.json @@ -1,10 +1,12 @@ { "domain": "opensensemap", "name": "openSenseMap", - "codeowners": [], + "codeowners": ["@AlCalzone"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensensemap", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["opensensemap_api"], "quality_scale": "legacy", - "requirements": ["opensensemap-api==0.2.0"] + "requirements": ["opensensemap-api==0.4.1"] } diff --git a/homeassistant/components/opensensemap/strings.json b/homeassistant/components/opensensemap/strings.json new file mode 100644 index 00000000000..4f6cdc53eff --- /dev/null +++ b/homeassistant/components/opensensemap/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Failed to connect to openSenseMap.", + "invalid_station": "The provided station ID does not exist on openSenseMap." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_station": "[%key:component::opensensemap::config::abort::invalid_station%]" + }, + "step": { + "user": { + "data": { + "station_id": "Station ID" + }, + "data_description": { + "station_id": "The unique identifier of your openSenseMap station. You can find it in the URL when viewing the station on opensensemap.org." + }, + "description": "Add an openSenseMap station to monitor its measurements.", + "title": "Add openSenseMap station" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.", + "title": "The {integration_title} YAML configuration import failed" + }, + "deprecated_yaml_import_issue_invalid_station": { + "description": "Configuring {integration_title} using YAML is being removed but the configured station could not be found.\n\nVerify the station ID and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.", + "title": "[%key:component::opensensemap::issues::deprecated_yaml_import_issue_cannot_connect::title%]" + } + } +} diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index 7cead9fa56d..386e37b9b59 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1,7 +1,5 @@ """The opensky component.""" -from __future__ import annotations - from aiohttp import BasicAuth from python_opensky import OpenSky from python_opensky.exceptions import OpenSkyError diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 0aeed008608..f8b45b680f1 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenSky integration.""" -from __future__ import annotations - from typing import Any from aiohttp import BasicAuth diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index 3e0ccbe380e..53f2444448b 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the OpenSky integration.""" -from __future__ import annotations - from datetime import timedelta from python_opensky import OpenSky, OpenSkyError, StateVector diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 8e34e6581a0..363b5f1de97 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -1,7 +1,5 @@ """Sensor for the Open Sky Network.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 2b20ad5a08c..9b560db52e3 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -199,7 +199,10 @@ class OpenThermGatewayHub: return self.gateway.connection.connected async def set_room_setpoint(self, temp) -> float: - """Set the room temperature setpoint on the gateway. Return the new temperature.""" + """Set the room temperature setpoint on the gateway. + + Return the new temperature. + """ return await self.gateway.set_target_temp( temp, self.options.get(CONF_TEMPORARY_OVRD_MODE, True) ) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index c7e107b1637..932486707ba 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -1,7 +1,5 @@ """Support for OpenTherm Gateway climate devices.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass import logging diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 688f7ac0d85..e1fda806654 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -1,7 +1,5 @@ """OpenTherm Gateway config flow.""" -from __future__ import annotations - import asyncio from typing import Any @@ -101,6 +99,8 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): step_id="init", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME): str, vol.Required(CONF_DEVICE): str, vol.Optional(CONF_ID): str, diff --git a/homeassistant/components/opentherm_gw/entity.py b/homeassistant/components/opentherm_gw/entity.py index 9254e1c6482..a8b989c3c5d 100644 --- a/homeassistant/components/opentherm_gw/entity.py +++ b/homeassistant/components/opentherm_gw/entity.py @@ -41,7 +41,11 @@ class OpenThermEntity(Entity): """Initialize the entity.""" self.entity_description = description self._gateway = gw_hub - self._attr_unique_id = f"{gw_hub.hub_id}-{description.device_description.device_identifier}-{description.key}" + self._attr_unique_id = ( + f"{gw_hub.hub_id}" + f"-{description.device_description.device_identifier}" + f"-{description.key}" + ) self._attr_device_info = DeviceInfo( identifiers={ ( diff --git a/homeassistant/components/opentherm_gw/services.py b/homeassistant/components/opentherm_gw/services.py index 2cb6d9443e9..44b29b4b378 100644 --- a/homeassistant/components/opentherm_gw/services.py +++ b/homeassistant/components/opentherm_gw/services.py @@ -1,7 +1,5 @@ """Support for OpenTherm Gateway devices.""" -from __future__ import annotations - from datetime import date, datetime from typing import TYPE_CHECKING diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index be6c99b3288..d24691c66c6 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -1,7 +1,5 @@ """Support for UV data from openuv.io.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 5d432d22e39..efb55aa9fc9 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the OpenUV component.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 7a3dc6cdb3d..d09f47c10ab 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -1,7 +1,5 @@ """Define an update coordinator for OpenUV.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable import datetime as dt from typing import Any, cast diff --git a/homeassistant/components/openuv/diagnostics.py b/homeassistant/components/openuv/diagnostics.py index 005d84f7629..c95dafe4197 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for OpenUV.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/openuv/entity.py b/homeassistant/components/openuv/entity.py index 2303f21f2b8..1d89022891a 100644 --- a/homeassistant/components/openuv/entity.py +++ b/homeassistant/components/openuv/entity.py @@ -1,7 +1,5 @@ """Support for UV data from openuv.io.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 7fdeaeb382b..d8bb70c577e 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,7 +1,5 @@ """Support for OpenUV sensors.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 8b2bfb17c95..ae8a1634cf6 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -1,7 +1,5 @@ """The openweathermap component.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 64545726f1e..7a53fde0cde 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OpenWeatherMap.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ( diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 9ede24ed1af..08408f01981 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -1,7 +1,5 @@ """Consts for the OpenWeatherMap.""" -from __future__ import annotations - from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 614bf3f193a..65810cdf409 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -1,7 +1,5 @@ """Data coordinator for the OpenWeatherMap (OWM) service.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 2bde5750ca4..6301405ccbc 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,11 +1,8 @@ """Issues for OpenWeatherMap.""" -from __future__ import annotations - from typing import TYPE_CHECKING, cast -from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.const import CONF_API_KEY, CONF_MODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -27,13 +24,13 @@ class DeprecatedV25RepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_form(step_id="migrate") async def async_step_migrate( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the migrate step of a fix flow.""" errors, description_placeholders = {}, {} new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30} diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 7e319578db6..93654e4b305 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -1,7 +1,5 @@ """Support for the OpenWeatherMap (OWM) service.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 37f8e117ee1..3c8151203e8 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -1,7 +1,5 @@ """Support for the OpenWeatherMap (OWM) service.""" -from __future__ import annotations - from homeassistant.components.weather import ( Forecast, SingleCoordinatorWeatherEntity, diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index 822851aca74..2029a019a7c 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -1,7 +1,5 @@ """Support for OPNsense Routers.""" -import logging - from aiopnsense import ( OPNsenseBelowMinFirmware, OPNsenseClient, @@ -15,22 +13,16 @@ from aiopnsense import ( ) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_API_SECRET, - CONF_INTERFACE_CLIENT, - CONF_TRACKER_INTERFACES, - DOMAIN, - OPNSENSE_DATA, -) - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN +from .types import OPNsenseConfigEntry, OPNsenseRuntimeData CONFIG_SCHEMA = vol.Schema( { @@ -49,86 +41,124 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [Platform.DEVICE_TRACKER] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the opnsense component.""" + """Set up the OPNsense component.""" + if DOMAIN not in config: + return True - conf = config[DOMAIN] - url = conf[CONF_URL] - api_key = conf[CONF_API_KEY] - api_secret = conf[CONF_API_SECRET] - verify_ssl = conf[CONF_VERIFY_SSL] - tracker_interfaces = conf[CONF_TRACKER_INTERFACES] + hass.async_create_task(_async_setup(hass, config)) - session = async_get_clientsession(hass, verify_ssl=verify_ssl) + return True + + +async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: + """Set up the OPNsense component from YAML.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: OPNsenseConfigEntry +) -> bool: + """Set up the OPNsense component from a config entry.""" + url = config_entry.data[CONF_URL] + session = async_get_clientsession( + hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL] + ) client = OPNsenseClient( url, - api_key, - api_secret, + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_API_SECRET], session, - opts={"verify_ssl": verify_ssl}, + opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]}, ) + tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, []) try: await client.validate() if tracker_interfaces: interfaces_resp = await client.get_interfaces() - except OPNsenseUnknownFirmware: - _LOGGER.error("Error checking the OPNsense firmware version at %s", url) - return False - except OPNsenseBelowMinFirmware: - _LOGGER.error( - "OPNsense Firmware is below the minimum supported version at %s", url - ) - return False - except OPNsenseInvalidURL: - _LOGGER.error( - "Invalid URL while connecting to OPNsense API endpoint at %s", url - ) - return False - except OPNsenseTimeoutError: - _LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url) - return False - except OPNsenseSSLError: - _LOGGER.error( - "Unable to verify SSL while connecting to OPNsense API endpoint at %s", url - ) - return False - except OPNsenseInvalidAuth: - _LOGGER.error( - "Authentication failure while connecting to OPNsense API endpoint at %s", - url, - ) - return False - except OPNsensePrivilegeMissing: - _LOGGER.error( - "Invalid Permissions while connecting to OPNsense API endpoint at %s", - url, - ) - return False - except OPNsenseConnectionError: - _LOGGER.error( - "Connection failure while connecting to OPNsense API endpoint at %s", - url, - ) - return False + except OPNsenseUnknownFirmware as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unknown_firmware", + translation_placeholders={"url": url}, + ) from err + except OPNsenseBelowMinFirmware as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="firmware_too_old", + translation_placeholders={"url": url}, + ) from err + except OPNsenseInvalidURL as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_url", + translation_placeholders={"url": url}, + ) from err + except OPNsenseTimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_connecting", + translation_placeholders={"url": url}, + ) from err + except OPNsenseSSLError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="ssl_error", + translation_placeholders={"url": url}, + ) from err + except OPNsenseInvalidAuth as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"url": url}, + ) from err + except OPNsensePrivilegeMissing as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="privilege_missing", + translation_placeholders={"url": url}, + ) from err + except OPNsenseConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"url": url}, + ) from err if tracker_interfaces: # Verify that specified tracker interfaces are valid known_interfaces = [ - ifinfo.get("name", "") for ifinfo in interfaces_resp.values() + name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name")) ] for intf_description in tracker_interfaces: if intf_description not in known_interfaces: - _LOGGER.error( - "Specified OPNsense tracker interface %s is not found", - intf_description, + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="tracker_interface_not_found", + translation_placeholders={ + "interface": intf_description, + "known": ", ".join(known_interfaces), + }, ) - return False - hass.data[OPNSENSE_DATA] = { - CONF_INTERFACE_CLIENT: client, - CONF_TRACKER_INTERFACES: tracker_interfaces, - } + config_entry.runtime_data = OPNsenseRuntimeData( + client=client, + tracker_interfaces=tracker_interfaces, + ) - load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: OPNsenseConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/opnsense/config_flow.py b/homeassistant/components/opnsense/config_flow.py new file mode 100644 index 00000000000..0680f9cac63 --- /dev/null +++ b/homeassistant/components/opnsense/config_flow.py @@ -0,0 +1,315 @@ +"""Config flow for OPNsense.""" + +import logging +from typing import Any + +from aiopnsense import ( + OPNsenseBelowMinFirmware, + OPNsenseClient, + OPNsenseConnectionError, + OPNsenseInvalidAuth, + OPNsenseInvalidURL, + OPNsensePrivilegeMissing, + OPNsenseSSLError, + OPNsenseTimeoutError, + OPNsenseUnknownFirmware, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_API_SECRET): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } +) + + +def tracker_interfaces_schema( + interfaces: list[str], selected: list[str] | None = None +) -> vol.Schema: + """Schema to display available interfaces for device tracking selection.""" + return vol.Schema( + { + vol.Optional( + CONF_TRACKER_INTERFACES, + default=selected or [], + ): SelectSelector( + SelectSelectorConfig( + options=interfaces, mode=SelectSelectorMode.DROPDOWN, multiple=True + ) + ), + } + ) + + +class OPNsenseConfigFlow(ConfigFlow, domain=DOMAIN): + """OPNsense config flow.""" + + def __init__(self) -> None: + """Initialize OPNsense config flow.""" + self.available_interfaces: list[str] | None = None + self._entry_data: dict[str, Any] = {} + + async def _show_setup_form( + self, + user_input: dict[Any, Any] | None = None, + errors: dict[Any, Any] | None = None, + ) -> ConfigFlowResult: + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + description_placeholders = { + "doc_url": "https://www.home-assistant.io/integrations/opnsense/" + } + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors or {}, + description_placeholders=description_placeholders, + ) + + async def _show_interfaces_form( + self, + user_input: dict[Any, Any], + errors: dict[Any, Any] | None = None, + ) -> ConfigFlowResult: + """Show the tracker interfaces selection form to the user.""" + return self.async_show_form( + step_id="interfaces", + data_schema=self.add_suggested_values_to_schema( + tracker_interfaces_schema( + self.available_interfaces or [], + user_input.get(CONF_TRACKER_INTERFACES), + ), + user_input, + ), + errors=errors or {}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user step: credentials and connection test.""" + errors = {} + + if user_input is None: + return await self._show_setup_form(user_input, None) + + verify_ssl = user_input[CONF_VERIFY_SSL] + session = async_get_clientsession(self.hass, verify_ssl=verify_ssl) + client = OPNsenseClient( + user_input[CONF_URL], + user_input[CONF_API_KEY], + user_input[CONF_API_SECRET], + session, + opts={"verify_ssl": verify_ssl}, + ) + + try: + await client.validate() + interfaces_resp = await client.get_interfaces() + known_interfaces = [ + name + for ifinfo in interfaces_resp.values() + if (name := ifinfo.get("name")) + ] + self.available_interfaces = list(known_interfaces) + except OPNsenseInvalidAuth: + errors["base"] = "invalid_auth" + except OPNsensePrivilegeMissing: + errors["base"] = "privilege_missing" + except OPNsenseInvalidURL: + errors["base"] = "invalid_url" + except OPNsenseSSLError: + errors["base"] = "ssl_error" + except OPNsenseConnectionError, OPNsenseTimeoutError: + errors["base"] = "cannot_connect" + except OPNsenseUnknownFirmware: + errors["base"] = "unknown_version" + except OPNsenseBelowMinFirmware: + errors["base"] = "invalid_version" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + unique_id = await client.get_device_unique_id() + if not unique_id: + return self.async_abort(reason="no_unique_id") + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + self._entry_data = user_input + return await self.async_step_interfaces() + + return await self._show_setup_form(user_input, errors) + + async def async_step_interfaces( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle tracker interface selection step.""" + if user_input is None: + return await self._show_interfaces_form({}, None) + + if user_input.get(CONF_TRACKER_INTERFACES): + self._entry_data[CONF_TRACKER_INTERFACES] = user_input[ + CONF_TRACKER_INTERFACES + ] + + return self.async_create_entry( + title=self._entry_data[CONF_URL], data=self._entry_data + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import a Yaml config.""" + # Test connection + session = async_get_clientsession( + self.hass, verify_ssl=import_data[CONF_VERIFY_SSL] + ) + client = OPNsenseClient( + import_data[CONF_URL], + import_data[CONF_API_KEY], + import_data[CONF_API_SECRET], + session, + opts={"verify_ssl": import_data[CONF_VERIFY_SSL]}, + ) + try: + await client.validate() + interfaces_resp = await client.get_interfaces() + except OPNsenseInvalidURL: + return self._abort_import(reason="invalid_url") + except OPNsenseInvalidAuth: + return self._abort_import(reason="invalid_auth") + except OPNsensePrivilegeMissing: + return self._abort_import(reason="privilege_missing") + except OPNsenseSSLError: + return self._abort_import(reason="ssl_error") + except OPNsenseConnectionError, OPNsenseTimeoutError: + return self._abort_import(reason="cannot_connect") + except OPNsenseUnknownFirmware: + return self._abort_import(reason="unknown_version") + except OPNsenseBelowMinFirmware: + return self._abort_import(reason="invalid_version") + except Exception: # Allowed in config flows + _LOGGER.exception("Unexpected exception during import") + return self._abort_import(reason="unknown") + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OPNsense", + }, + ) + + unique_id = await client.get_device_unique_id() + if not unique_id: + return self._abort_import(reason="no_unique_id") + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Validate CONF_TRACKER_INTERFACES if present and not empty + verified_data = dict(import_data) + if CONF_TRACKER_INTERFACES in verified_data: + if not verified_data[CONF_TRACKER_INTERFACES]: + verified_data.pop(CONF_TRACKER_INTERFACES) + else: + known_interfaces = [ + name + for ifinfo in interfaces_resp.values() + if (name := ifinfo.get("name")) + ] + self.available_interfaces = sorted(known_interfaces) + # Abort import if any specified tracker interface is not found + missing = [ + intf_description + for intf_description in verified_data[CONF_TRACKER_INTERFACES] + if intf_description not in known_interfaces + ] + if missing: + # Create a repair to guide the user + async_create_issue( + self.hass, + DOMAIN, + "import_failed_missing_interfaces", + breaks_in_ha_version="2026.12.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="import_failed_missing_interfaces", + translation_placeholders={ + "missing": ", ".join(missing), + "found": ", ".join(known_interfaces), + "integration_title": "OPNsense", + }, + ) + return self.async_abort( + reason="import_failed_missing_interfaces", + description_placeholders={ + "missing": ", ".join(missing), + "found": ", ".join(known_interfaces), + "integration_title": "OPNsense", + }, + ) + + # Clear any previous import issues if interfaces are now valid + async_delete_issue( + self.hass, + DOMAIN, + "import_failed_missing_interfaces", + ) + + return self.async_create_entry( + title=verified_data[CONF_URL], data=verified_data + ) + + def _abort_import(self, reason: str) -> ConfigFlowResult: + """Create an issue for import errors and abort the import.""" + async_create_issue( + self.hass, + DOMAIN, + f"import_failed_{reason}", + breaks_in_ha_version="2026.12.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key=f"import_failed_{reason}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OPNsense", + }, + ) + return self.async_abort( + reason=reason, + description_placeholders={ + "integration_title": "OPNsense", + }, + ) diff --git a/homeassistant/components/opnsense/const.py b/homeassistant/components/opnsense/const.py index 62ab16701f4..61b75702274 100644 --- a/homeassistant/components/opnsense/const.py +++ b/homeassistant/components/opnsense/const.py @@ -1,8 +1,11 @@ """Constants for OPNsense component.""" +from datetime import timedelta + DOMAIN = "opnsense" -OPNSENSE_DATA = DOMAIN CONF_API_SECRET = "api_secret" -CONF_INTERFACE_CLIENT = "interface_client" CONF_TRACKER_INTERFACES = "tracker_interfaces" + +# Update interval for device scanning +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/opnsense/coordinator.py b/homeassistant/components/opnsense/coordinator.py new file mode 100644 index 00000000000..b64c887c1a6 --- /dev/null +++ b/homeassistant/components/opnsense/coordinator.py @@ -0,0 +1,80 @@ +"""Coordinator for OPNsense device tracker updates.""" + +import logging + +from aiopnsense import ( + OPNsenseBelowMinFirmware, + OPNsenseClient, + OPNsenseConnectionError, + OPNsenseInvalidAuth, + OPNsenseInvalidURL, + OPNsensePrivilegeMissing, + OPNsenseSSLError, + OPNsenseTimeoutError, + OPNsenseUnknownFirmware, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SCAN_INTERVAL +from .types import DeviceDetails, DeviceDetailsByMAC, OPNsenseConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class OPNsenseDeviceTrackerCoordinator(DataUpdateCoordinator[DeviceDetailsByMAC]): + """Coordinator for OPNsense device tracker updates.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: OPNsenseConfigEntry, + client: OPNsenseClient, + interfaces: list[str], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="OPNsense Device Tracker", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + self.client = client + self.interfaces = interfaces + self.tracked_devices: set[str] = set() + + def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC: + """Create dict with mac address keys from list of devices.""" + out_devices: DeviceDetailsByMAC = {} + for device in devices: + if not self.interfaces or device["intf_description"] in self.interfaces: + formatted_mac = format_mac(device["mac"]) + out_devices[formatted_mac] = device + return out_devices + + async def _async_update_data(self) -> DeviceDetailsByMAC: + """Fetch data from OPNsense.""" + try: + devices = await self.client.get_arp_table(True) + except ( + OPNsenseInvalidAuth, + OPNsenseInvalidURL, + OPNsensePrivilegeMissing, + OPNsenseSSLError, + OPNsenseBelowMinFirmware, + OPNsenseUnknownFirmware, + ) as err: + raise ConfigEntryError(f"Error with OPNsense configuration: {err}") from err + except ( + OPNsenseConnectionError, + OPNsenseTimeoutError, + ) as err: + raise UpdateFailed( + f"Error communicating with OPNsense router: {err}" + ) from err + + return self._get_mac_addrs(devices) diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 259a6394e69..b8366022114 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -1,71 +1,98 @@ """Device tracker support for OPNsense routers.""" -from typing import Any, NewType - -from aiopnsense import OPNsenseClient - -from homeassistant.components.device_tracker import DeviceScanner +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA - -DeviceDetails = NewType("DeviceDetails", dict[str, Any]) -DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails]) +from .coordinator import OPNsenseDeviceTrackerCoordinator +from .types import DeviceDetails, OPNsenseConfigEntry -async def async_get_scanner( - hass: HomeAssistant, config: ConfigType -) -> DeviceScanner | None: - """Configure the OPNsense device_tracker.""" - return OPNsenseDeviceScanner( - hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT], - hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES], - ) +async def async_setup_entry( + hass: HomeAssistant, + entry: OPNsenseConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker for OPNsense component.""" + client = entry.runtime_data.client + interfaces = entry.runtime_data.tracker_interfaces + + coordinator = OPNsenseDeviceTrackerCoordinator(hass, entry, client, interfaces) + + def _async_add_new_entities() -> None: + """Add entities for newly discovered devices.""" + if not coordinator.data: + return + + entities = [] + for mac_address in coordinator.data: + if mac_address in coordinator.tracked_devices: + continue + entity = OPNsenseDeviceTrackerEntity(coordinator, mac_address) + coordinator.tracked_devices.add(mac_address) + entities.append(entity) + + if entities: + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities)) + + # Initial data fetch + await coordinator.async_config_entry_first_refresh() + _async_add_new_entities() -class OPNsenseDeviceScanner(DeviceScanner): - """This class queries a router running OPNsense.""" +class OPNsenseDeviceTrackerEntity( + CoordinatorEntity[OPNsenseDeviceTrackerCoordinator], ScannerEntity +): + """Representation of a tracked device.""" - def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None: - """Initialize the scanner.""" - self.last_results: dict[str, Any] = {} - self.client = client - self.interfaces = interfaces + def __init__( + self, + coordinator: OPNsenseDeviceTrackerCoordinator, + mac_address: str, + ) -> None: + """Initialize the device tracker entity.""" + super().__init__(coordinator) + self._attr_mac_address = mac_address - def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict: - """Create dict with mac address keys from list of devices.""" - out_devices = {} - for device in devices: - if not self.interfaces or device["intf_description"] in self.interfaces: - out_devices[device["mac"]] = device - return out_devices + @property + def device_data(self) -> DeviceDetails | None: + """Return device data for current device.""" + if self.coordinator.data and self.mac_address in self.coordinator.data: + return self.coordinator.data[self.mac_address] + return None - async def async_scan_devices(self) -> list[str]: - """Scan for new devices and return a list with found device IDs.""" - await self._async_update_info() - return list(self.last_results) + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return ( + self.coordinator.data is not None + and self.mac_address in self.coordinator.data + ) - def get_device_name(self, device: str) -> str | None: - """Return the name of the given device or None if we don't know.""" - if device not in self.last_results: - return None - return self.last_results[device].get("hostname") or None + @property + def name(self) -> str: + """Return device name.""" + device_data = self.device_data + if device_data and device_data.get("hostname"): + return str(device_data["hostname"]) + return f"OPNsense {self.mac_address}" - async def _async_update_info(self) -> bool: - """Ensure the information from the OPNsense router is up to date. + @property + def ip_address(self) -> str | None: + """Return the primary IP address of the device.""" + device_data = self.device_data + if device_data: + return device_data.get("ip") + return None - Return boolean if scanning successful. - """ - devices = await self.client.get_arp_table(True) - self.last_results = self._get_mac_addrs(devices) - return True - - def get_extra_attributes(self, device: str) -> dict[Any, Any]: - """Return the extra attrs of the given device.""" - if device not in self.last_results: - return {} - mfg = self.last_results[device].get("manufacturer") - if not mfg: - return {} - return {"manufacturer": mfg} + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + device_data = self.device_data + if device_data: + hostname = device_data.get("hostname") + return hostname or None + return None diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index b2d57e017c2..580e7d2e03f 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -2,6 +2,7 @@ "domain": "opnsense", "name": "OPNsense", "codeowners": ["@HarlemSquirrel", "@Snuffy2"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opnsense", "integration_type": "hub", "iot_class": "local_polling", diff --git a/homeassistant/components/opnsense/strings.json b/homeassistant/components/opnsense/strings.json new file mode 100644 index 00000000000..fe591940961 --- /dev/null +++ b/homeassistant/components/opnsense/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "import_failed_missing_interfaces": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "URL is invalid or unreachable", + "invalid_version": "Unsupported OPNsense firmware version", + "no_unique_id": "Could not determine a unique identifier for this OPNsense router. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.", + "privilege_missing": "The API key used does not have sufficient privileges. Please check the integration documentation for required permissions", + "ssl_error": "SSL certificate verification failed", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_interface": "Interface(s) do not exist", + "invalid_url": "URL is invalid or unreachable", + "invalid_version": "Unsupported OPNsense firmware version", + "privilege_missing": "[%key:component::opnsense::config::abort::privilege_missing%]", + "ssl_error": "SSL certificate verification failed", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions." + }, + "step": { + "interfaces": { + "data": { + "tracker_interfaces": "Interface(s) to use for tracking devices" + }, + "description": "Select the OPNsense interfaces to use for tracking devices. If no interfaces are selected then all interfaces will be used for tracking." + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_secret": "API secret", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Set required parameters to connect to your router. For more information, please refer to the [integration documentation]({doc_url})" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Connection failure while connecting to OPNsense API endpoint at {url}" + }, + "firmware_too_old": { + "message": "OPNsense firmware at {url} is below the minimum supported version" + }, + "invalid_auth": { + "message": "Authentication failure while connecting to OPNsense API endpoint at {url}" + }, + "invalid_url": { + "message": "Invalid URL while connecting to OPNsense API endpoint at {url}" + }, + "privilege_missing": { + "message": "The API user connecting to {url} does not have sufficient privileges" + }, + "ssl_error": { + "message": "Unable to verify SSL certificate while connecting to OPNsense API endpoint at {url}" + }, + "timeout_connecting": { + "message": "Timeout while connecting to OPNsense API endpoint at {url}" + }, + "tracker_interface_not_found": { + "message": "Configured tracker interface {interface} is not present on the OPNsense router. Known interfaces: {known}" + }, + "unknown_firmware": { + "message": "Could not determine the OPNsense firmware version at {url}" + } + }, + "issues": { + "import_failed_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_invalid_auth": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an authentication error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_invalid_url": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the URL provided is invalid or unreachable. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_invalid_version": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unsupported. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_missing_interfaces": { + "description": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config", + "title": "The {integration_title} YAML import failed: Missing tracker interfaces" + }, + "import_failed_no_unique_id": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a unique identifier for the router could not be determined. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_privilege_missing": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the API key used does not have sufficient privileges. Please check the integration documentation for required permissions, correct your YAML configuration, and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_ssl_error": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_unknown": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "import_failed_unknown_version": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unknown. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/components/opnsense/types.py b/homeassistant/components/opnsense/types.py new file mode 100644 index 00000000000..9204092c53a --- /dev/null +++ b/homeassistant/components/opnsense/types.py @@ -0,0 +1,21 @@ +"""Types for OPNsense routers.""" + +from dataclasses import dataclass +from typing import Any + +from aiopnsense import OPNsenseClient + +from homeassistant.config_entries import ConfigEntry + + +@dataclass(slots=True) +class OPNsenseRuntimeData: + """Runtime data for OPNsense config entries.""" + + client: OPNsenseClient + tracker_interfaces: list[str] + + +type DeviceDetails = dict[str, Any] +type DeviceDetailsByMAC = dict[str, DeviceDetails] +type OPNsenseConfigEntry = ConfigEntry[OPNsenseRuntimeData] diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 088083ef5db..5e6cd753e68 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -1,7 +1,5 @@ """The Opower integration.""" -from __future__ import annotations - from opower import select_utility from homeassistant.const import Platform diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index b66c4c6870e..9840cd88451 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Opower integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d53706b315c..d1b509a460c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -88,10 +88,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): def _dummy_listener() -> None: pass - # Force the coordinator to periodically update by registering at least one listener. - # Needed when the _async_update_data below returns {} for utilities that don't provide - # forecast, which results to no sensors added, no registered listeners, and thus - # _async_update_data not periodically getting called which is needed for _insert_statistics. + # Force the coordinator to periodically update by + # registering at least one listener. + # Needed when the _async_update_data below returns {} + # for utilities that don't provide forecast, which results + # to no sensors added, no registered listeners, and thus + # _async_update_data not periodically getting called which + # is needed for _insert_statistics. self.async_add_listener(_dummy_listener) async def _async_update_data( @@ -108,7 +111,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): raise ConfigEntryAuthFailed from err except CannotConnect as err: _LOGGER.error("Error during login: %s", err) - raise UpdateFailed(f"Error during login: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="login_error", + translation_placeholders={"error": str(err)}, + ) from err try: accounts = await self.api.async_get_accounts() @@ -189,12 +196,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): ) consumption_unit_class = ( EnergyConverter.UNIT_CLASS - if account.meter_type == MeterType.ELEC + if account.meter_type is MeterType.ELEC else VolumeConverter.UNIT_CLASS ) consumption_unit = ( UnitOfEnergy.KILO_WATT_HOUR - if account.meter_type == MeterType.ELEC + if account.meter_type is MeterType.ELEC else UnitOfVolume.CENTUM_CUBIC_FEET ) consumption_metadata = StatisticMetaData( @@ -244,7 +251,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): }, ) if migrated: - # Skip update to avoid working on old data since the migration is done + # Skip update to avoid working on old data since + # the migration is done # asynchronously. Update the statistics in the next refresh in 12h. _LOGGER.debug( "Statistics migration completed. Skipping update for now" @@ -393,7 +401,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): utility_account_id: The account ID (for issue_id). migration_map: Map from source statistic ID to target statistic ID (e.g., {cost_id: compensation_id}). - metadata_map: Map of all statistic IDs (source and target) to their metadata. + metadata_map: Map of all statistic IDs (source and target) + to their metadata. """ if not migration_map: @@ -547,7 +556,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) - if account.read_resolution == ReadResolution.BILLING: + if account.read_resolution is ReadResolution.BILLING: return cost_reads if start_time is None: @@ -567,7 +576,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) - if account.read_resolution == ReadResolution.DAY: + if account.read_resolution is ReadResolution.DAY: return cost_reads if start_time is None: diff --git a/homeassistant/components/opower/diagnostics.py b/homeassistant/components/opower/diagnostics.py index 23f695cbfda..35434acc453 100644 --- a/homeassistant/components/opower/diagnostics.py +++ b/homeassistant/components/opower/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Opower.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index eaec3a5ed89..d45e8f6e721 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "platinum", - "requirements": ["opower==0.18.0"] + "requirements": ["opower==0.18.3"] } diff --git a/homeassistant/components/opower/repairs.py b/homeassistant/components/opower/repairs.py index f78dee32194..9526569b901 100644 --- a/homeassistant/components/opower/repairs.py +++ b/homeassistant/components/opower/repairs.py @@ -1,10 +1,7 @@ """Repairs for Opower.""" -from __future__ import annotations - -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult class UnsupportedUtilityFixFlow(RepairsFlow): @@ -18,13 +15,13 @@ class UnsupportedUtilityFixFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: await self.hass.config_entries.async_remove(self._entry_id) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 08341146a48..08a19611f6a 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -1,7 +1,5 @@ """Support for Opower sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime @@ -235,13 +233,13 @@ async def async_setup_entry( ) sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS if ( - account.meter_type == MeterType.ELEC + account.meter_type is MeterType.ELEC and forecast is not None - and forecast.unit_of_measure == UnitOfMeasure.KWH + and forecast.unit_of_measure is UnitOfMeasure.KWH ): sensors += ELEC_SENSORS elif ( - account.meter_type == MeterType.GAS + account.meter_type is MeterType.GAS and forecast is not None and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF] ): diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index a990d89fadc..ac7f7aca31d 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -124,6 +124,11 @@ } } }, + "exceptions": { + "login_error": { + "message": "Error during login: {error}" + } + }, "issues": { "return_to_grid_migration": { "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.", diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index 2dba3b130f2..ff216a82e06 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -1,7 +1,5 @@ """Support for the Opple light.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/oralb/__init__.py b/homeassistant/components/oralb/__init__.py index a274c5414d2..8cb5319e3d2 100644 --- a/homeassistant/components/oralb/__init__.py +++ b/homeassistant/components/oralb/__init__.py @@ -1,7 +1,5 @@ """The OralB integration.""" -from __future__ import annotations - import logging from oralb_ble import OralBBluetoothDeviceData, SensorUpdate diff --git a/homeassistant/components/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py index bac2d32bb2f..f01f1e8b418 100644 --- a/homeassistant/components/oralb/config_flow.py +++ b/homeassistant/components/oralb/config_flow.py @@ -1,7 +1,5 @@ """Config flow for oralb ble integration.""" -from __future__ import annotations - from typing import Any from oralb_ble import OralBBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/oralb/device.py b/homeassistant/components/oralb/device.py index 0fb6b71981d..db67c0fbae2 100644 --- a/homeassistant/components/oralb/device.py +++ b/homeassistant/components/oralb/device.py @@ -1,7 +1,5 @@ """Support for OralB devices.""" -from __future__ import annotations - from oralb_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 17d68a6aaab..f5da8bd4e2f 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -1,7 +1,5 @@ """Support for OralB sensors.""" -from __future__ import annotations - from oralb_ble import OralBSensor, SensorUpdate from oralb_ble.parser import ( IO_SERIES_MODES, diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index 450c56ae50e..fa72a4c3b35 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/orvibo/config_flow.py b/homeassistant/components/orvibo/config_flow.py index 13f914e094e..b0a6354a672 100644 --- a/homeassistant/components/orvibo/config_flow.py +++ b/homeassistant/components/orvibo/config_flow.py @@ -90,7 +90,9 @@ class S20ConfigFlow(ConfigFlow, domain=DOMAIN): ) if not user_input.get(CONF_MAC): - # Using private attribute access here since S20 class doesn't have a public method to get the MAC without repeating discovery + # Using private attribute access here since S20 + # class doesn't have a public method to get + # the MAC without repeating discovery if not device._mac: # noqa: SLF001 return "cannot_discover" user_input[CONF_MAC] = format_mac(device._mac.hex()).lower() # noqa: SLF001 diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index a7a829d7b66..252c9eacd47 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -1,7 +1,5 @@ """Switch platform for the Orvibo integration.""" -from __future__ import annotations - import logging from typing import Any @@ -38,7 +36,8 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_DISCOVERY = False -# Library is not thread safe and uses global variables, so we limit to 1 update at a time +# Library is not thread safe and uses global variables, +# so we limit to 1 update at a time PARALLEL_UPDATES = 1 PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( @@ -79,7 +78,8 @@ async def async_setup_platform( ir.async_create_issue( hass, DOMAIN, - f"yaml_deprecation_import_issue_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", + f"yaml_deprecation_import_issue_{switch.get('host')}" + f"_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", breaks_in_ha_version="2026.9.0", is_fixable=False, is_persistent=False, @@ -97,7 +97,8 @@ async def async_setup_platform( ir.async_create_issue( hass, DOMAIN, - f"yaml_deprecation_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", + f"yaml_deprecation_{switch.get('host')}" + f"_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", breaks_in_ha_version="2026.9.0", is_fixable=False, is_persistent=False, diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index e38e502fc76..62126b32ca4 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -15,7 +15,7 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -45,8 +45,6 @@ SERVICE_GET_PROFILE = "get_profile" SERVICE_SET_PROFILE = "set_profile" SERVICE_SET_V40MIN = "set_v40_min" SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on" -SERVICE_TURN_OFF = "turn_off" -SERVICE_TURN_ON = "turn_on" async def async_setup_entry( @@ -151,11 +149,13 @@ def _get_local_hour(utc_hour: int) -> dt.datetime: def _convert_profile_to_local(values: list[float]) -> list[JsonValueType]: """Convert UTC profile to local. - Receives a device temperature schedule - 24 values for the day where the index represents the hour of the day in UTC. + Receives a device temperature schedule - 24 values for the day + where the index represents the hour of the day in UTC. Converts the schedule to local time. Args: - values: list of floats representing the 24 hour temperature schedule for the device + values: list of floats representing the 24 hour temperature + schedule for the device Returns: The device temperature schedule in local time. diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 8dad03d4bba..d47137d0c6c 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -1,7 +1,5 @@ """Support for Osram Lightify.""" -from __future__ import annotations - import logging import random from typing import Any diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 0756f32ab18..38c0bcc4aae 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -1,7 +1,5 @@ """The Open Thread Border Router integration.""" -from __future__ import annotations - import logging import aiohttp diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index a5fae381fbd..912677fb407 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Open Thread Border Router integration.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import TYPE_CHECKING, cast @@ -215,7 +213,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): or current_url.port == config["port"] ): # Reload the entry since OTBR has restarted - if current_entry.state == ConfigEntryState.LOADED: + if current_entry.state is ConfigEntryState.LOADED: assert current_entry.unique_id is not None await self.hass.config_entries.async_reload( current_entry.entry_id diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py index c38b3cc1250..cc3e4a9e6c3 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -1,7 +1,5 @@ """Constants for the Open Thread Border Router integration.""" -from __future__ import annotations - DOMAIN = "otbr" DEFAULT_CHANNEL = 15 diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py index 94193be1359..bf7962ef34d 100644 --- a/homeassistant/components/otbr/homeassistant_hardware.py +++ b/homeassistant/components/otbr/homeassistant_hardware.py @@ -1,7 +1,5 @@ """Home Assistant Hardware firmware utilities.""" -from __future__ import annotations - import logging from yarl import URL diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a33ca835e4..1f10ce2456d 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.9.0"] + "requirements": ["python-otbr-api==2.10.0"] } diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py index d97e6811e6d..dde80bf103b 100644 --- a/homeassistant/components/otbr/silabs_multiprotocol.py +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -1,7 +1,5 @@ """Silicon Labs Multiprotocol support.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps import logging diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 363b1385327..bdd66a9d362 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -1,7 +1,5 @@ """Utility functions for the Open Thread Border Router integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import dataclasses from functools import wraps diff --git a/homeassistant/components/otp/__init__.py b/homeassistant/components/otp/__init__.py index 5b18301874a..f114294e08c 100644 --- a/homeassistant/components/otp/__init__.py +++ b/homeassistant/components/otp/__init__.py @@ -1,7 +1,5 @@ """The One-Time Password (OTP) integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/otp/config_flow.py b/homeassistant/components/otp/config_flow.py index 8ddae9204c6..a9fe654e75f 100644 --- a/homeassistant/components/otp/config_flow.py +++ b/homeassistant/components/otp/config_flow.py @@ -1,7 +1,5 @@ """Config flow for One-Time Password (OTP) integration.""" -from __future__ import annotations - import binascii import logging from re import sub @@ -28,6 +26,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_TOKEN): str, vol.Optional(CONF_NEW_TOKEN): BooleanSelector(BooleanSelectorConfig()), + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=DEFAULT_NAME): str, } ) diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json index f6adbb20427..74c1c9eb264 100644 --- a/homeassistant/components/otp/manifest.json +++ b/homeassistant/components/otp/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/otp", + "integration_type": "helper", "iot_class": "local_polling", "loggers": ["pyotp"], "quality_scale": "internal", diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index af508d2e915..3e3a0bcba5f 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -1,7 +1,5 @@ """Support for One-Time Password (OTP).""" -from __future__ import annotations - import time import pyotp diff --git a/homeassistant/components/otp/strings.json b/homeassistant/components/otp/strings.json index af811a8ab2c..1e654bbd5a7 100644 --- a/homeassistant/components/otp/strings.json +++ b/homeassistant/components/otp/strings.json @@ -13,6 +13,9 @@ "data": { "code": "Verification code (OTP)" }, + "data_description": { + "code": "The six-digit code currently displayed in your authentication app." + }, "description": "Before completing the setup of One-Time Password (OTP), confirm with a verification code. Scan the QR code with your authentication app. If you don't have one, we recommend either {auth_app1} or {auth_app2}.\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", "title": "Verify One-Time Password (OTP)" }, @@ -21,7 +24,13 @@ "name": "[%key:common::config_flow::data::name%]", "new_token": "Generate a new token?", "token": "Authenticator token (OTP)" - } + }, + "data_description": { + "name": "The purpose of this sensor (for example, the name of the service or account for which the One-Time Password is used).", + "new_token": "Generate a new secret key. You will be able to scan a QR code to import this token into your preferred authenticator app in the next step.", + "token": "An existing secret key for import into Home Assistant." + }, + "description": "Creates a sensor that generates One-Time Passwords (OTP) for two-factor authentication." } } } diff --git a/homeassistant/components/ouman_eh_800/__init__.py b/homeassistant/components/ouman_eh_800/__init__.py new file mode 100644 index 00000000000..5e2bc8e4dd3 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/__init__.py @@ -0,0 +1,33 @@ +"""The Ouman EH-800 integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator + +_PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.VALVE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: OumanEh800ConfigEntry) -> bool: + """Set up Ouman EH-800 from a config entry.""" + coordinator = OumanEh800Coordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + coordinator.sync_circuit_device_names() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OumanEh800ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/ouman_eh_800/climate.py b/homeassistant/components/ouman_eh_800/climate.py new file mode 100644 index 00000000000..a0325ce4c70 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/climate.py @@ -0,0 +1,203 @@ +"""Climate platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass +from typing import Any + +from ouman_eh_800_api import ( + EnumControlOumanEndpoint, + IntControlOumanEndpoint, + L1BaseEndpoints, + L1RoomSensor, + L2BaseEndpoints, + L2RoomSensor, + NumberOumanEndpoint, + OperationMode, +) + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OumanDevice +from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator +from .entity import OumanEh800Entity, OumanEh800EntityDescription + +PARALLEL_UPDATES = 1 + +# Operation modes that map to HVACMode.HEAT and use the climate's room +# temperature setpoint. The remaining modes (NORMAL_TEMPERATURE, +# MANUAL_VALVE_CONTROL, SHUTDOWN) ignore the setpoint and are reported as +# HVACMode.OFF. +_HEAT_OPERATION_MODES: tuple[OperationMode, ...] = ( + OperationMode.AUTOMATIC, + OperationMode.TEMPERATURE_DROP, + OperationMode.BIG_TEMPERATURE_DROP, +) +_PRESET_TO_OPERATION_MODE: dict[str, OperationMode] = { + mode.name.lower(): mode for mode in _HEAT_OPERATION_MODES +} +# Operation mode written when the user switches to HVACMode.HEAT or +# turns the entity on without picking a specific preset first. +_DEFAULT_HEAT_OPERATION_MODE = OperationMode.AUTOMATIC + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800ClimateEntityDescription( + OumanEh800EntityDescription, ClimateEntityDescription +): + """Climate description identifying the endpoints that back one heating circuit.""" + + operation_mode_endpoint: EnumControlOumanEndpoint + current_temperature_endpoint: NumberOumanEndpoint + target_temperature_endpoint: IntControlOumanEndpoint + valve_position_endpoint: NumberOumanEndpoint + + +CLIMATE_DESCRIPTIONS: tuple[OumanEh800ClimateEntityDescription, ...] = ( + OumanEh800ClimateEntityDescription( + device=OumanDevice.L1, + key="climate", + translation_key="heating_circuit", + operation_mode_endpoint=L1BaseEndpoints.OPERATION_MODE, + current_temperature_endpoint=L1RoomSensor.ROOM_TEMPERATURE, + target_temperature_endpoint=L1RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER, + valve_position_endpoint=L1BaseEndpoints.VALVE_POSITION, + ), + OumanEh800ClimateEntityDescription( + device=OumanDevice.L2, + key="climate", + translation_key="heating_circuit", + operation_mode_endpoint=L2BaseEndpoints.OPERATION_MODE, + current_temperature_endpoint=L2RoomSensor.ROOM_TEMPERATURE, + target_temperature_endpoint=L2RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER, + valve_position_endpoint=L2BaseEndpoints.VALVE_POSITION, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 climate entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800ClimateEntity(coordinator, description) + for description in CLIMATE_DESCRIPTIONS + if description.target_temperature_endpoint in coordinator.data + ) + + +class OumanEh800ClimateEntity(OumanEh800Entity, ClimateEntity): + """Ouman EH-800 per-circuit room-temperature climate entity.""" + + entity_description: OumanEh800ClimateEntityDescription + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1 + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = list(_PRESET_TO_OPERATION_MODE) + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: OumanEh800Coordinator, + description: OumanEh800ClimateEntityDescription, + ) -> None: + """Initialize the climate entity.""" + super().__init__( + coordinator, description.target_temperature_endpoint, description + ) + target_endpoint = description.target_temperature_endpoint + self._attr_min_temp = float(target_endpoint.min_val) + self._attr_max_temp = float(target_endpoint.max_val) + + @property + def _operation_mode(self) -> OperationMode: + value = self.coordinator.data[self.entity_description.operation_mode_endpoint] + assert isinstance(value, OperationMode) + return value + + @property + def hvac_mode(self) -> HVACMode: + """Return HEAT only when the climate setpoint is controlling the circuit.""" + if self._operation_mode in _HEAT_OPERATION_MODES: + return HVACMode.HEAT + return HVACMode.OFF + + @property + def hvac_action(self) -> HVACAction: + """Return HEATING when the mixing valve is open, IDLE when closed, OFF otherwise.""" + if self.hvac_mode is HVACMode.OFF: + return HVACAction.OFF + valve_position = self.coordinator.data[ + self.entity_description.valve_position_endpoint + ] + assert isinstance(valve_position, float) + return HVACAction.HEATING if valve_position > 0 else HVACAction.IDLE + + @property + def preset_mode(self) -> str | None: + """Return the current heating sub-mode, or None when shut down.""" + mode = self._operation_mode + return mode.name.lower() if mode in _HEAT_OPERATION_MODES else None + + @property + def current_temperature(self) -> float: + """Return the current room temperature.""" + value = self.coordinator.data[ + self.entity_description.current_temperature_endpoint + ] + assert isinstance(value, float) + return value + + @property + def target_temperature(self) -> float: + """Return the user-set room temperature setpoint.""" + value = self.coordinator.data[ + self.entity_description.target_temperature_endpoint + ] + assert isinstance(value, float) + return value + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new room temperature setpoint and optionally the HVAC mode.""" + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(hvac_mode) + await self.coordinator.async_set_endpoint_value( + self.entity_description.target_temperature_endpoint, + int(kwargs[ATTR_TEMPERATURE]), + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Switch between heating (default sub-mode) and shutdown.""" + new_mode = ( + OperationMode.SHUTDOWN + if hvac_mode is HVACMode.OFF + else _DEFAULT_HEAT_OPERATION_MODE + ) + await self.coordinator.async_set_endpoint_value( + self.entity_description.operation_mode_endpoint, new_mode + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Switch the heating sub-mode.""" + await self.coordinator.async_set_endpoint_value( + self.entity_description.operation_mode_endpoint, + _PRESET_TO_OPERATION_MODE[preset_mode], + ) diff --git a/homeassistant/components/ouman_eh_800/config_flow.py b/homeassistant/components/ouman_eh_800/config_flow.py new file mode 100644 index 00000000000..b4301cd4516 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for the Ouman EH-800 integration.""" + +import logging +from typing import Any + +from ouman_eh_800_api import ( + OumanClientAuthenticationError, + OumanClientCommunicationError, + OumanEh800Client, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +def _normalize_url(url: str) -> str: + """Reduce URL to scheme://host[:port], discarding any path, query, or fragment.""" + return str(URL(url.strip()).origin()) + + +class OumanEh800ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ouman EH-800.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + user_input[CONF_URL] = _normalize_url(user_input[CONF_URL]) + except ValueError: + errors[CONF_URL] = "invalid_url" + else: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + client = OumanEh800Client( + session=async_get_clientsession(self.hass), + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + address=user_input[CONF_URL], + ) + try: + await client.login() + except OumanClientCommunicationError: + errors["base"] = "cannot_connect" + except OumanClientAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Ouman EH-800", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ouman_eh_800/const.py b/homeassistant/components/ouman_eh_800/const.py new file mode 100644 index 00000000000..04bac3843e2 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/const.py @@ -0,0 +1,15 @@ +"""Constants for the Ouman EH-800 integration.""" + +from enum import StrEnum + +DOMAIN = "ouman_eh_800" + +DEFAULT_SCAN_INTERVAL_SECONDS = 60 + + +class OumanDevice(StrEnum): + """Logical device that an entity belongs to.""" + + MAIN = "main" + L1 = "l1" + L2 = "l2" diff --git a/homeassistant/components/ouman_eh_800/coordinator.py b/homeassistant/components/ouman_eh_800/coordinator.py new file mode 100644 index 00000000000..fac5d1701e5 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/coordinator.py @@ -0,0 +1,137 @@ +"""Data update coordinator for the Ouman EH-800 integration.""" + +from datetime import timedelta +import logging + +from ouman_eh_800_api import ( + ControllableEndpoint, + L1BaseEndpoints, + L2BaseEndpoints, + OumanClientAuthenticationError, + OumanClientCommunicationError, + OumanEh800Client, + OumanEndpoint, + OumanRegistrySet, + OumanValues, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN, OumanDevice + +_LOGGER = logging.getLogger(__name__) + +type OumanEh800ConfigEntry = ConfigEntry[OumanEh800Coordinator] + + +class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValues]]): + """Ouman EH-800 data update coordinator.""" + + _registry_set: OumanRegistrySet + config_entry: OumanEh800ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OumanEh800ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Ouman EH-800", + config_entry=config_entry, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS), + always_update=False, + ) + self.client: OumanEh800Client = OumanEh800Client( + session=async_get_clientsession(hass), + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + address=config_entry.data[CONF_URL], + ) + + entry_id = config_entry.entry_id + main_device_identifier = (DOMAIN, entry_id) + self.device_info: dict[OumanDevice, DeviceInfo] = { + OumanDevice.MAIN: DeviceInfo( + identifiers={main_device_identifier}, + manufacturer="Ouman", + model="EH-800", + configuration_url=config_entry.data[CONF_URL], + ), + OumanDevice.L1: DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L1}")}, + translation_key="heating_circuit", + translation_placeholders={"circuit_number": "1"}, + via_device=main_device_identifier, + ), + OumanDevice.L2: DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L2}")}, + translation_key="heating_circuit", + translation_placeholders={"circuit_number": "2"}, + via_device=main_device_identifier, + ), + } + + async def _async_setup(self) -> None: + try: + # Even though not required to fetch values, perform login once + # at the start to verify that the credentials are valid. + await self.client.login() + self._registry_set = await self.client.get_active_registries() + except OumanClientAuthenticationError as err: + raise ConfigEntryError("Invalid credentials") from err + except OumanClientCommunicationError as err: + raise ConfigEntryNotReady("Error communicating with API") from err + + async def _async_update_data(self) -> dict[OumanEndpoint, OumanValues]: + """Fetch registry values from the device.""" + try: + return await self.client.get_values(self._registry_set) + except OumanClientCommunicationError as err: + raise UpdateFailed("Error communicating with API") from err + + async def async_set_endpoint_value( + self, endpoint: ControllableEndpoint, value: OumanValues | int + ) -> None: + """Set a value on the device and refresh.""" + try: + result = await self.client.set_endpoint_value(endpoint, value) + except OumanClientAuthenticationError as err: + raise HomeAssistantError("Authentication failed") from err + except OumanClientCommunicationError as err: + raise HomeAssistantError("Error communicating with API") from err + + self.async_set_updated_data({**self.data, endpoint: result}) + # Separate refresh on all endpoints to catch cascading changes. + await self.async_request_refresh() + + def sync_circuit_device_names(self) -> None: + """Set the device-reported circuit names for the L1/L2 sub-device names. + + Should be called after the data update so that platforms register + L1/L2 devices with the resolved names. + """ + for device, endpoint, circuit_number in ( + (OumanDevice.L1, L1BaseEndpoints.CIRCUIT_NAME, "1"), + (OumanDevice.L2, L2BaseEndpoints.CIRCUIT_NAME, "2"), + ): + if circuit_name := self.data.get(endpoint): + assert isinstance(circuit_name, str) + device_info = self.device_info[device] + device_info["translation_key"] = "heating_circuit_with_name" + device_info["translation_placeholders"] = { + "circuit_number": circuit_number, + "circuit_name": circuit_name, + } diff --git a/homeassistant/components/ouman_eh_800/entity.py b/homeassistant/components/ouman_eh_800/entity.py new file mode 100644 index 00000000000..d99666a4049 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/entity.py @@ -0,0 +1,42 @@ +"""Base entity for Ouman EH-800.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import OumanEndpoint + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import OumanDevice +from .coordinator import OumanEh800Coordinator + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800EntityDescription(EntityDescription): + """Common Ouman EH-800 entity description fields.""" + + device: OumanDevice + + +class OumanEh800Entity(CoordinatorEntity[OumanEh800Coordinator]): + """Base entity for Ouman EH-800.""" + + _attr_has_entity_name = True + entity_description: OumanEh800EntityDescription + + def __init__( + self, + coordinator: OumanEh800Coordinator, + endpoint: OumanEndpoint, + description: OumanEh800EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._endpoint = endpoint + self.entity_description = description + + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{description.device}_{description.key}" + ) + self._attr_device_info = coordinator.device_info[description.device] diff --git a/homeassistant/components/ouman_eh_800/icons.json b/homeassistant/components/ouman_eh_800/icons.json new file mode 100644 index 00000000000..42e24ef5e3c --- /dev/null +++ b/homeassistant/components/ouman_eh_800/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "valve_position": { + "default": "mdi:pipe-valve" + } + } + } +} diff --git a/homeassistant/components/ouman_eh_800/manifest.json b/homeassistant/components/ouman_eh_800/manifest.json new file mode 100644 index 00000000000..7c97d6452de --- /dev/null +++ b/homeassistant/components/ouman_eh_800/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ouman_eh_800", + "name": "Ouman EH-800", + "codeowners": ["@Markus98"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ouman_eh_800", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["ouman-eh-800-api==0.5.0"] +} diff --git a/homeassistant/components/ouman_eh_800/number.py b/homeassistant/components/ouman_eh_800/number.py new file mode 100644 index 00000000000..0ff24577790 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/number.py @@ -0,0 +1,260 @@ +"""Number platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import ( + FloatControlOumanEndpoint, + IntControlOumanEndpoint, + L1BaseEndpoints, + L1ConstantTempMode, + L1FivePointCurve, + L1NoRoomSensor, + L1RoomSensor, + L1ThreePointCurve, + L2BaseEndpoints, + L2FivePointCurve, + L2NoRoomSensor, + L2RoomSensor, + L2ThreePointCurve, + SystemEndpoints, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OumanDevice +from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator +from .entity import OumanEh800Entity, OumanEh800EntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800NumberEntityDescription( + OumanEh800EntityDescription, NumberEntityDescription +): + """Number description with main/L1/L2 device assignment.""" + + +def _temperature_number( + *, + device: OumanDevice, + key: str, + device_class: NumberDeviceClass = NumberDeviceClass.TEMPERATURE, + entity_category: EntityCategory | None = EntityCategory.CONFIG, + enabled_by_default: bool = True, +) -> OumanEh800NumberEntityDescription: + return OumanEh800NumberEntityDescription( + device=device, + key=key, + translation_key=key, + device_class=device_class, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + mode=NumberMode.BOX, + entity_category=entity_category, + entity_registry_enabled_default=enabled_by_default, + ) + + +NUMBER_DESCRIPTIONS: dict[ + IntControlOumanEndpoint | FloatControlOumanEndpoint, + OumanEh800NumberEntityDescription, +] = { + SystemEndpoints.TREND_SAMPLE_INTERVAL: OumanEh800NumberEntityDescription( + device=OumanDevice.MAIN, + key="trend_sampling_interval", + translation_key="trend_sampling_interval", + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + # L1 base water-out temperature limits. + L1BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number( + device=OumanDevice.L1, key="water_out_minimum_temperature" + ), + L1BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number( + device=OumanDevice.L1, key="water_out_maximum_temperature" + ), + # L1 heating curve. Three-point and five-point variants share keys + # where their meaning overlaps. + L1ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_minus_20_temperature" + ), + L1ThreePointCurve.CURVE_0_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_0_temperature" + ), + L1ThreePointCurve.CURVE_20_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_20_temperature" + ), + L1FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_minus_20_temperature" + ), + L1FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_minus_10_temperature" + ), + L1FivePointCurve.CURVE_0_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_0_temperature" + ), + L1FivePointCurve.CURVE_10_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_10_temperature" + ), + L1FivePointCurve.CURVE_20_TEMP: _temperature_number( + device=OumanDevice.L1, key="curve_20_temperature" + ), + # L1 no-room-sensor and room-sensor variants share keys for the offsets + # that conceptually mean the same thing on both axes. + L1NoRoomSensor.TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L1, + key="temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L1, + key="big_temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number( + device=OumanDevice.L1, + key="room_temperature_fine_tuning", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1RoomSensor.TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L1, + key="temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L1, + key="big_temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number( + device=OumanDevice.L1, + key="room_temperature_fine_tuning", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L1ConstantTempMode.CONSTANT_TEMP_SETPOINT: _temperature_number( + device=OumanDevice.L1, + key="constant_temp_setpoint", + entity_category=None, + ), + # L2 mirrors L1. + L2BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number( + device=OumanDevice.L2, key="water_out_minimum_temperature" + ), + L2BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number( + device=OumanDevice.L2, key="water_out_maximum_temperature" + ), + L2ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_minus_20_temperature" + ), + L2ThreePointCurve.CURVE_0_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_0_temperature" + ), + L2ThreePointCurve.CURVE_20_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_20_temperature" + ), + L2FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_minus_20_temperature" + ), + L2FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_minus_10_temperature" + ), + L2FivePointCurve.CURVE_0_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_0_temperature" + ), + L2FivePointCurve.CURVE_10_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_10_temperature" + ), + L2FivePointCurve.CURVE_20_TEMP: _temperature_number( + device=OumanDevice.L2, key="curve_20_temperature" + ), + L2NoRoomSensor.TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L2, + key="temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L2NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L2, + key="big_temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L2NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number( + device=OumanDevice.L2, + key="room_temperature_fine_tuning", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L2RoomSensor.TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L2, + key="temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L2RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number( + device=OumanDevice.L2, + key="big_temperature_drop", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), + L2RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number( + device=OumanDevice.L2, + key="room_temperature_fine_tuning", + device_class=NumberDeviceClass.TEMPERATURE_DELTA, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 number entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800NumberEntity(coordinator, endpoint, description) + for endpoint in coordinator.data + if isinstance(endpoint, IntControlOumanEndpoint | FloatControlOumanEndpoint) + and (description := NUMBER_DESCRIPTIONS.get(endpoint)) is not None + ) + + +class OumanEh800NumberEntity(OumanEh800Entity, NumberEntity): + """Ouman EH-800 number entity.""" + + entity_description: OumanEh800NumberEntityDescription + _endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint + + def __init__( + self, + coordinator: OumanEh800Coordinator, + endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint, + description: OumanEh800NumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator, endpoint, description) + self._attr_native_min_value = float(endpoint.min_val) + self._attr_native_max_value = float(endpoint.max_val) + self._attr_native_step = ( + 1 if isinstance(endpoint, IntControlOumanEndpoint) else 0.1 + ) + + @property + def native_value(self) -> float: + """Return the current value.""" + value = self.coordinator.data[self._endpoint] + assert isinstance(value, float) + return value + + async def async_set_native_value(self, value: float) -> None: + """Set a new value on the device.""" + final_value: int | float = ( + int(value) if isinstance(self._endpoint, IntControlOumanEndpoint) else value + ) + await self.coordinator.async_set_endpoint_value(self._endpoint, final_value) diff --git a/homeassistant/components/ouman_eh_800/quality_scale.yaml b/homeassistant/components/ouman_eh_800/quality_scale.yaml new file mode 100644 index 00000000000..0d36183f69e --- /dev/null +++ b/homeassistant/components/ouman_eh_800/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not use events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration is local polling only, no discovery. + discovery: + status: exempt + comment: Integration is local polling only, no discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration supports a single device per config entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Integration supports a single device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ouman_eh_800/select.py b/homeassistant/components/ouman_eh_800/select.py new file mode 100644 index 00000000000..719c423b5a4 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/select.py @@ -0,0 +1,120 @@ +"""Select platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import ( + ControlEnum, + EnumControlOumanEndpoint, + L1BaseEndpoints, + L2BaseEndpoints, + RelayL1ValvePosition, + RelayPumpSummerStop, + RelayTempDifference, + RelayTemperature, + RelayTimeProgram, + SystemEndpoints, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OumanDevice +from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator +from .entity import OumanEh800Entity, OumanEh800EntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800SelectEntityDescription( + OumanEh800EntityDescription, SelectEntityDescription +): + """Select description with main/L1/L2 device assignment.""" + + +def _select_entity( + *, + device: OumanDevice, + key: str, +) -> OumanEh800SelectEntityDescription: + return OumanEh800SelectEntityDescription( + device=device, + key=key, + translation_key=key, + ) + + +SELECT_DESCRIPTIONS: dict[ + EnumControlOumanEndpoint, OumanEh800SelectEntityDescription +] = { + SystemEndpoints.HOME_AWAY_MODE: _select_entity( + device=OumanDevice.MAIN, key="home_away_mode" + ), + L1BaseEndpoints.OPERATION_MODE: _select_entity( + device=OumanDevice.L1, key="operation_mode" + ), + L2BaseEndpoints.OPERATION_MODE: _select_entity( + device=OumanDevice.L2, key="operation_mode" + ), + RelayPumpSummerStop.CONTROL: _select_entity( + device=OumanDevice.MAIN, key="relay_pump_summer_stop_control" + ), + RelayTemperature.CONTROL: _select_entity( + device=OumanDevice.MAIN, key="relay_control" + ), + RelayTempDifference.CONTROL: _select_entity( + device=OumanDevice.MAIN, key="relay_control" + ), + RelayL1ValvePosition.CONTROL: _select_entity( + device=OumanDevice.MAIN, key="relay_control" + ), + RelayTimeProgram.CONTROL: _select_entity( + device=OumanDevice.MAIN, key="relay_control" + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 select entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800SelectEntity(coordinator, endpoint, description) + for endpoint in coordinator.data + if isinstance(endpoint, EnumControlOumanEndpoint) + and (description := SELECT_DESCRIPTIONS.get(endpoint)) is not None + ) + + +class OumanEh800SelectEntity(OumanEh800Entity, SelectEntity): + """Ouman EH-800 select entity.""" + + entity_description: OumanEh800SelectEntityDescription + _endpoint: EnumControlOumanEndpoint + + def __init__( + self, + coordinator: OumanEh800Coordinator, + endpoint: EnumControlOumanEndpoint, + description: OumanEh800SelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator, endpoint, description) + self._attr_options = [member.name.lower() for member in endpoint.enum_type] + + @property + def current_option(self) -> str: + """Return the currently selected option.""" + value = self.coordinator.data[self._endpoint] + assert isinstance(value, ControlEnum) + return value.name.lower() + + async def async_select_option(self, option: str) -> None: + """Change the selected option on the device.""" + await self.coordinator.async_set_endpoint_value( + self._endpoint, self._endpoint.enum_type[option.upper()] + ) diff --git a/homeassistant/components/ouman_eh_800/sensor.py b/homeassistant/components/ouman_eh_800/sensor.py new file mode 100644 index 00000000000..6753aaae926 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/sensor.py @@ -0,0 +1,186 @@ +"""Sensor platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import ( + L1BaseEndpoints, + L1RoomSensor, + L2BaseEndpoints, + L2RoomSensor, + OumanEndpoint, + SystemEndpoints, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OumanDevice +from .coordinator import OumanEh800ConfigEntry +from .entity import OumanEh800Entity, OumanEh800EntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800SensorDescription(OumanEh800EntityDescription, SensorEntityDescription): + """Sensor description with main/L1/L2 device assignment.""" + + +def _temperature_sensor( + *, + device: OumanDevice, + key: str, + device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE, + entity_category: EntityCategory | None = None, + enabled_by_default: bool = True, +) -> OumanEh800SensorDescription: + return OumanEh800SensorDescription( + device=device, + key=key, + translation_key=key, + device_class=device_class, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + entity_category=entity_category, + entity_registry_enabled_default=enabled_by_default, + ) + + +def _percentage_sensor( + *, + device: OumanDevice, + key: str, +) -> OumanEh800SensorDescription: + return OumanEh800SensorDescription( + device=device, + key=key, + translation_key=key, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + ) + + +SENSOR_DESCRIPTIONS: dict[OumanEndpoint, OumanEh800SensorDescription] = { + SystemEndpoints.OUTSIDE_TEMPERATURE: _temperature_sensor( + device=OumanDevice.MAIN, key="outside_temperature" + ), + L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L1, key="supply_water_temperature" + ), + L1BaseEndpoints.VALVE_POSITION: _percentage_sensor( + device=OumanDevice.L1, key="valve_position" + ), + L1BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: _temperature_sensor( + device=OumanDevice.L1, + key="supply_water_temperature_setpoint", + entity_category=EntityCategory.DIAGNOSTIC, + ), + L1BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L1, + key="curve_supply_water_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L1BaseEndpoints.FINE_ADJUSTMENT_EFFECT: _temperature_sensor( + device=OumanDevice.L1, + key="fine_adjustment_effect", + device_class=SensorDeviceClass.TEMPERATURE_DELTA, + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L1RoomSensor.ROOM_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L1, key="room_temperature" + ), + L1RoomSensor.ROOM_TEMPERATURE_SETPOINT: _temperature_sensor( + device=OumanDevice.L1, + key="room_temperature_setpoint", + entity_category=EntityCategory.DIAGNOSTIC, + ), + L1RoomSensor.DELAYED_ROOM_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L1, + key="delayed_room_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L1RoomSensor.ROOM_SENSOR_POTENTIOMETER: _temperature_sensor( + device=OumanDevice.L1, + key="room_sensor_potentiometer", + device_class=SensorDeviceClass.TEMPERATURE_DELTA, + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L2, key="supply_water_temperature" + ), + L2BaseEndpoints.VALVE_POSITION: _percentage_sensor( + device=OumanDevice.L2, key="valve_position" + ), + L2BaseEndpoints.SUPPLY_WATER_TEMPERATURE_SETPOINT: _temperature_sensor( + device=OumanDevice.L2, + key="supply_water_temperature_setpoint", + entity_category=EntityCategory.DIAGNOSTIC, + ), + L2BaseEndpoints.CURVE_SUPPLY_WATER_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L2, + key="curve_supply_water_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L2BaseEndpoints.DELAYED_OUTDOOR_TEMPERATURE_EFFECT: _temperature_sensor( + device=OumanDevice.L2, + key="delayed_outdoor_temperature_effect", + device_class=SensorDeviceClass.TEMPERATURE_DELTA, + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), + L2RoomSensor.ROOM_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L2, key="room_temperature" + ), + L2RoomSensor.ROOM_TEMPERATURE_SETPOINT: _temperature_sensor( + device=OumanDevice.L2, + key="room_temperature_setpoint", + entity_category=EntityCategory.DIAGNOSTIC, + ), + L2RoomSensor.DELAYED_ROOM_TEMPERATURE: _temperature_sensor( + device=OumanDevice.L2, + key="delayed_room_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + enabled_by_default=False, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 sensors based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800SensorEntity(coordinator, endpoint, description) + for endpoint in coordinator.data + if (description := SENSOR_DESCRIPTIONS.get(endpoint)) is not None + ) + + +class OumanEh800SensorEntity(OumanEh800Entity, SensorEntity): + """Ouman EH-800 sensor entity.""" + + entity_description: OumanEh800SensorDescription + + @property + def native_value(self) -> float | str: + """Return the current sensor value.""" + value = self.coordinator.data[self._endpoint] + assert isinstance(value, float | str) + return value diff --git a/homeassistant/components/ouman_eh_800/strings.json b/homeassistant/components/ouman_eh_800/strings.json new file mode 100644 index 00000000000..a6865d3eab8 --- /dev/null +++ b/homeassistant/components/ouman_eh_800/strings.json @@ -0,0 +1,127 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "Invalid URL", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Password for the Ouman EH-800 web interface", + "url": "The URL of the Ouman EH-800 web interface", + "username": "Username for the Ouman EH-800 web interface" + } + } + } + }, + "device": { + "heating_circuit": { "name": "Heating circuit {circuit_number}" }, + "heating_circuit_with_name": { + "name": "Heating circuit {circuit_number} {circuit_name}" + } + }, + "entity": { + "climate": { + "heating_circuit": { + "state_attributes": { + "preset_mode": { + "state": { + "automatic": "[%key:common::state::auto%]", + "big_temperature_drop": "[%key:component::ouman_eh_800::entity::number::big_temperature_drop::name%]", + "temperature_drop": "[%key:component::ouman_eh_800::entity::number::temperature_drop::name%]" + } + } + } + } + }, + "number": { + "big_temperature_drop": { "name": "Big temperature drop" }, + "constant_temp_setpoint": { "name": "Constant temperature setpoint" }, + "curve_0_temperature": { "name": "Curve 0°C temperature" }, + "curve_10_temperature": { "name": "Curve 10°C temperature" }, + "curve_20_temperature": { "name": "Curve 20°C temperature" }, + "curve_minus_10_temperature": { "name": "Curve -10°C temperature" }, + "curve_minus_20_temperature": { "name": "Curve -20°C temperature" }, + "room_temperature_fine_tuning": { + "name": "Room temperature fine tuning" + }, + "temperature_drop": { "name": "Temperature drop" }, + "trend_sampling_interval": { "name": "Trend sampling interval" }, + "water_out_maximum_temperature": { + "name": "Water out maximum temperature" + }, + "water_out_minimum_temperature": { + "name": "Water out minimum temperature" + } + }, + "select": { + "home_away_mode": { + "name": "Home/Away mode", + "state": { + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", + "off": "[%key:common::state::off%]" + } + }, + "operation_mode": { + "name": "Operation mode", + "state": { + "automatic": "[%key:common::state::auto%]", + "big_temperature_drop": "[%key:component::ouman_eh_800::entity::number::big_temperature_drop::name%]", + "manual_valve_control": "Manual valve control", + "normal_temperature": "Nominal temperature", + "shutdown": "[%key:common::state::standby%]", + "temperature_drop": "[%key:component::ouman_eh_800::entity::number::temperature_drop::name%]" + } + }, + "relay_control": { + "name": "Relay control", + "state": { + "auto": "[%key:common::state::auto%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "relay_pump_summer_stop_control": { + "name": "Pump summer stop", + "state": { + "auto": "[%key:common::state::auto%]", + "run": "Run", + "stop": "[%key:common::action::stop%]" + } + } + }, + "sensor": { + "curve_supply_water_temperature": { + "name": "Curve supply water temperature" + }, + "delayed_outdoor_temperature_effect": { + "name": "Delayed outdoor temperature effect" + }, + "delayed_room_temperature": { "name": "Delayed room temperature" }, + "fine_adjustment_effect": { "name": "Fine adjustment effect" }, + "outside_temperature": { "name": "Outside temperature" }, + "room_sensor_potentiometer": { "name": "Room sensor potentiometer" }, + "room_temperature": { "name": "Room temperature" }, + "room_temperature_setpoint": { "name": "Room temperature setpoint" }, + "supply_water_temperature": { "name": "Supply water temperature" }, + "supply_water_temperature_setpoint": { + "name": "Supply water temperature setpoint" + }, + "valve_position": { "name": "Valve position" } + }, + "valve": { + "valve_position_setpoint": { "name": "Valve position setpoint" } + } + } +} diff --git a/homeassistant/components/ouman_eh_800/valve.py b/homeassistant/components/ouman_eh_800/valve.py new file mode 100644 index 00000000000..c5209d18b3b --- /dev/null +++ b/homeassistant/components/ouman_eh_800/valve.py @@ -0,0 +1,83 @@ +"""Valve platform for the Ouman EH-800 integration.""" + +from dataclasses import dataclass + +from ouman_eh_800_api import IntControlOumanEndpoint, L1BaseEndpoints, L2BaseEndpoints + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import OumanDevice +from .coordinator import OumanEh800ConfigEntry +from .entity import OumanEh800Entity, OumanEh800EntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OumanEh800ValveEntityDescription( + OumanEh800EntityDescription, ValveEntityDescription +): + """Valve description with main/L1/L2 device assignment.""" + + +VALVE_DESCRIPTIONS: dict[IntControlOumanEndpoint, OumanEh800ValveEntityDescription] = { + L1BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription( + device=OumanDevice.L1, + key="valve_position_setpoint", + translation_key="valve_position_setpoint", + device_class=ValveDeviceClass.WATER, + ), + L2BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription( + device=OumanDevice.L2, + key="valve_position_setpoint", + translation_key="valve_position_setpoint", + device_class=ValveDeviceClass.WATER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OumanEh800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ouman EH-800 valve entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OumanEh800ValveEntity(coordinator, endpoint, description) + for endpoint in coordinator.data + if isinstance(endpoint, IntControlOumanEndpoint) + and (description := VALVE_DESCRIPTIONS.get(endpoint)) is not None + ) + + +class OumanEh800ValveEntity(OumanEh800Entity, ValveEntity): + """Ouman EH-800 valve entity.""" + + entity_description: OumanEh800ValveEntityDescription + _endpoint: IntControlOumanEndpoint + + _attr_reports_position = True + _attr_supported_features = ( + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + ) + + @property + def current_valve_position(self) -> int: + """Return the current valve position 0-100.""" + value = self.coordinator.data[self._endpoint] + assert isinstance(value, float) + return int(value) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to the given position.""" + await self.coordinator.async_set_endpoint_value(self._endpoint, position) diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index 8f604257685..7311077ab74 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -1,7 +1,5 @@ """The OurGroceries integration.""" -from __future__ import annotations - from aiohttp import ClientError from ourgroceries import OurGroceries from ourgroceries.exceptions import InvalidLoginException diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py index 893970800b4..f3b184a8d0e 100644 --- a/homeassistant/components/ourgroceries/config_flow.py +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -1,7 +1,5 @@ """Config flow for OurGroceries integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index be9149e0162..df9e0e1da3d 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -1,7 +1,5 @@ """The OurGroceries coordinator.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 9fefed2792c..42b0a13537f 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,7 +1,5 @@ """The Overkiz (by Somfy) integration.""" -from __future__ import annotations - from collections import defaultdict from dataclasses import dataclass @@ -30,8 +28,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_API_TYPE, @@ -44,6 +47,9 @@ from .const import ( UPDATE_INTERVAL_LOCAL, ) from .coordinator import OverkizDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @dataclass @@ -58,6 +64,12 @@ class HomeAssistantOverkizData: type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Overkiz component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) -> bool: """Set up Overkiz from a config entry.""" client: OverkizClient | None = None @@ -81,8 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) server=SUPPORTED_SERVERS[entry.data[CONF_HUB]], ) - await _async_migrate_entries(hass, entry) - try: await client.login() setup = await client.get_setup() @@ -119,7 +129,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) if coordinator.is_stateless: LOGGER.debug( - "All devices have an assumed state. Update interval has been reduced to: %s", + "All devices have an assumed state." + " Update interval has been reduced to: %s", UPDATE_INTERVAL_ALL_ASSUMED_STATE, ) coordinator.set_update_interval(UPDATE_INTERVAL_ALL_ASSUMED_STATE) @@ -183,20 +194,37 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_migrate_entries( - hass: HomeAssistant, config_entry: OverkizDataConfigEntry +async def async_migrate_entry( + hass: HomeAssistant, entry: OverkizDataConfigEntry ) -> bool: - """Migrate old entries to new unique IDs.""" + """Migrate old entry.""" + if entry.version > 1: + return False + + if entry.version == 1 and entry.minor_version < 2: + await _async_migrate_strenum_unique_ids(hass, entry) + hass.config_entries.async_update_entry(entry, minor_version=2) + + return True + + +async def _async_migrate_strenum_unique_ids( + hass: HomeAssistant, config_entry: OverkizDataConfigEntry +) -> None: + """Migrate entities to the StrEnum-style unique IDs.""" entity_registry = er.async_get(hass) @callback def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: - # Python 3.11 treats (str, Enum) and StrEnum in a different way - # Since pyOverkiz switched to StrEnum, we need to rewrite the unique ids once to the new style + # Python 3.11 treats (str, Enum) and StrEnum + # differently. Since pyOverkiz switched to StrEnum, we + # need to rewrite the unique ids once to the new style. # - # io://xxxx-xxxx-xxxx/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL -> io://xxxx-xxxx-xxxx/3541212-core:DiscreteRSSILevelState - # internal://xxxx-xxxx-xxxx/alarm/0-UIWidget.TSKALARM_CONTROLLER -> internal://xxxx-xxxx-xxxx/alarm/0-TSKAlarmController - # io://xxxx-xxxx-xxxx/xxxxxxx-UIClass.ON_OFF -> io://xxxx-xxxx-xxxx/xxxxxxx-OnOff + # OverkizState.CORE_DISCRETE_RSSI_LEVEL + # -> core:DiscreteRSSILevelState + # UIWidget.TSKALARM_CONTROLLER + # -> TSKAlarmController + # UIClass.ON_OFF -> OnOff if (key := entry.unique_id.split("-")[-1]).startswith( ("OverkizState", "UIWidget", "UIClass") ): @@ -223,7 +251,8 @@ async def _async_migrate_entries( entry.domain, entry.platform, new_unique_id ): LOGGER.debug( - "Cannot migrate to unique_id '%s', already exists for '%s'. Entity will be removed", + "Cannot migrate to unique_id '%s', already" + " exists for '%s'. Entity will be removed", new_unique_id, existing_entity_id, ) @@ -239,8 +268,6 @@ async def _async_migrate_entries( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) - return True - def create_local_client( hass: HomeAssistant, host: str, token: str, verify_ssl: bool @@ -262,7 +289,8 @@ def create_cloud_client( hass: HomeAssistant, username: str, password: str, server: OverkizServer ) -> OverkizClient: """Create Overkiz cloud client.""" - # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies + # To allow users with multiple accounts/hubs, we create a + # new session so they have separate cookies session = async_create_clientsession(hass) return OverkizClient( diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 1a5490dd329..bdc3412197f 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Overkiz alarm control panel.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -75,8 +73,12 @@ def _state_tsk_alarm_controller( MAP_CORE_ACTIVE_ZONES: dict[str, AlarmControlPanelState] = { OverkizCommandParam.A: AlarmControlPanelState.ARMED_HOME, - f"{OverkizCommandParam.A},{OverkizCommandParam.B}": AlarmControlPanelState.ARMED_NIGHT, - f"{OverkizCommandParam.A},{OverkizCommandParam.B},{OverkizCommandParam.C}": AlarmControlPanelState.ARMED_AWAY, + f"{OverkizCommandParam.A},{OverkizCommandParam.B}": ( + AlarmControlPanelState.ARMED_NIGHT + ), + ( + f"{OverkizCommandParam.A},{OverkizCommandParam.B},{OverkizCommandParam.C}" + ): AlarmControlPanelState.ARMED_AWAY, } @@ -86,8 +88,10 @@ def _state_stateful_alarm_controller( """Return the state of the device.""" if state := cast(str, select_state(OverkizState.CORE_ACTIVE_ZONES)): # The Stateful Alarm Controller has 3 zones with the following options: - # (A, B, C, A,B, B,C, A,C, A,B,C). Since it is not possible to map this to AlarmControlPanel entity, - # only the most important zones are mapped, other zones can only be disarmed. + # (A, B, C, A,B, B,C, A,C, A,B,C). Since it is not + # possible to map this to AlarmControlPanel entity, only + # the most important zones are mapped, other zones can + # only be disarmed. if state in MAP_CORE_ACTIVE_ZONES: return MAP_CORE_ACTIVE_ZONES[state] diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 884a58092cb..ed2ac5eb405 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Overkiz binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -98,7 +96,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ # DomesticHotWaterProduction/WaterHeatingSystem OverkizBinarySensorDescription( key=OverkizState.IO_OPERATING_MODE_CAPABILITIES, - name="Energy Demand Status", + name="Energy demand status", device_class=BinarySensorDeviceClass.HEAT, value_fn=lambda state: ( cast(dict, state).get(OverkizCommandParam.ENERGY_DEMAND_STATUS) == 1 diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index f4e051ef9ca..024d5513250 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -1,7 +1,5 @@ """Support for Overkiz (virtual) buttons.""" -from __future__ import annotations - from dataclasses import dataclass from pyoverkiz.enums import OverkizCommand, OverkizCommandParam @@ -37,7 +35,8 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ ), # Identify OverkizButtonDescription( - key=OverkizCommand.IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API. + # startIdentify and identify are reversed... Swap this when fixed in API. + key=OverkizCommand.IDENTIFY, name="Start identify", icon="mdi:human-greeting-variant", entity_category=EntityCategory.DIAGNOSTIC, @@ -51,7 +50,8 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ entity_registry_enabled_default=False, ), OverkizButtonDescription( - key=OverkizCommand.START_IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API. + # startIdentify and identify are reversed... Swap this when fixed in API. + key=OverkizCommand.START_IDENTIFY, name="Identify", icon="mdi:human-greeting-variant", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 058c3aefdb7..26bd333dd84 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -1,7 +1,5 @@ """Climate entities for the Overkiz (by Somfy) integration.""" -from __future__ import annotations - from enum import StrEnum, unique from pyoverkiz.enums import Protocol @@ -48,7 +46,9 @@ class Controllable(StrEnum): WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, - UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, + UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: ( + AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint + ), UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, @@ -61,16 +61,22 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent, } -# For Atlantic APC, some devices are standalone and control themselves, some others needs to be -# managed by a ZoneControl device. Widget name is the same in the two cases. +# For Atlantic APC, some devices are standalone and control +# themselves, some others needs to be managed by a ZoneControl +# device. Widget name is the same in the two cases. WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: { - Controllable.IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, - Controllable.IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE: AtlanticPassAPCZoneControlZone, + Controllable.IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: ( + AtlanticPassAPCHeatingZone + ), + Controllable.IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE: ( + AtlanticPassAPCZoneControlZone + ), } } -# Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes +# Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI +# and OVP) that are separated in 2 classes WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py index 4a05a94b635..e319b4df506 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py @@ -1,7 +1,5 @@ """Support for Atlantic Electrical Heater.""" -from __future__ import annotations - from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index fb449f4bbd3..7330973b693 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -1,7 +1,5 @@ """Support for Atlantic Electrical Heater (With Adjustable Temperature Setpoint).""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -59,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.STANDBY: HVACMode.OFF, # main command OverkizCommandParam.AUTO: HVACMode.AUTO, OverkizCommandParam.EXTERNAL: HVACMode.AUTO, + OverkizCommandParam.PROG: HVACMode.AUTO, OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command } @@ -76,7 +75,10 @@ TEMPERATURE_SENSOR_DEVICE_INDEX = 2 class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( OverkizEntity, ClimateEntity ): - """Representation of Atlantic Electrical Heater (With Adjustable Temperature Setpoint).""" + """Representation of Atlantic Electrical Heater. + + With Adjustable Temperature Setpoint. + """ _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index e0cfebc2449..5c8767dc717 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -1,7 +1,5 @@ """Support for Atlantic Electrical Towel Dryer.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -24,7 +22,8 @@ PRESET_PROG = "prog" OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu - OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog (schedule, user program) - mapped as preset + # prog (schedule, user program) - mapped as preset + OverkizCommandParam.INTERNAL: HVACMode.AUTO, OverkizCommandParam.AUTO: HVACMode.AUTO, # auto (intelligent, user behavior) OverkizCommandParam.STANDBY: HVACMode.OFF, # off } @@ -148,9 +147,11 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - # If the preset mode is set to prog, we need to set the operating mode to internal + # If the preset mode is set to prog, we need to set + # the operating mode to internal if preset_mode == PRESET_PROG: - # If currently in a temporary preset (drying or boost), turn it off before turn on prog + # If currently in a temporary preset (drying or + # boost), turn it off before turn on prog if self.preset_mode in (PRESET_DRYING, PRESET_BOOST): await self.executor.async_execute_command( OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, @@ -162,7 +163,8 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): OverkizCommandParam.INTERNAL, ) - # If the preset mode is set from prog to none, we need to set the operating mode to external + # If the preset mode is set from prog to none, we need + # to set the operating mode to external # This will set the towel dryer to auto (intelligent mode) elif preset_mode == PRESET_NONE and self.preset_mode == PRESET_PROG: await self.executor.async_execute_command( diff --git a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py index bb84fa76f22..3657f3a56ba 100644 --- a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py @@ -1,7 +1,5 @@ """Support for AtlanticHeatRecoveryVentilation.""" -from __future__ import annotations - from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py index 800516e4bda..a3330842069 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py @@ -1,7 +1,5 @@ """Support for Atlantic Pass APC Heat Pump Main Component.""" -from __future__ import annotations - from asyncio import sleep from typing import cast @@ -29,10 +27,13 @@ HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity): """Representation of Atlantic Pass APC Heat Pump Main Component. - This component can only turn off the heating pump and select the working mode: heating or cooling. - To set new temperatures, they must be selected individually per Zones (ie: AtlanticPassAPCHeatingAndCoolingZone). - Once the Device is switched on into heating or cooling mode, the Heat Pump will be activated and will use - the default temperature configuration for each available zone. + This component can only turn off the heating pump and select + the working mode: heating or cooling. To set new + temperatures, they must be selected individually per Zones + (ie: AtlanticPassAPCHeatingAndCoolingZone). Once the Device + is switched on into heating or cooling mode, the Heat Pump + will be activated and will use the default temperature + configuration for each available zone. """ _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] @@ -60,5 +61,6 @@ class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity): HVAC_MODES_TO_OVERKIZ[hvac_mode], ) - # Wait for 2 seconds to ensure the HVAC mode change is properly applied and system stabilizes. + # Wait for 2 seconds to ensure the HVAC mode change is + # properly applied and system stabilizes. await sleep(2) diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py index 3df31fb44fc..a97bf80a184 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py @@ -1,7 +1,5 @@ """Support for Atlantic Pass APC Heating Control.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py index eff1d5fa130..72532fb405e 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py @@ -1,7 +1,5 @@ """Support for Atlantic Pass APC Heating Control.""" -from __future__ import annotations - from asyncio import sleep from typing import Any, cast @@ -77,7 +75,8 @@ OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE: dict[ } -# Those device depends on a main probe that choose the operating mode (heating, cooling, ...). +# Those device depends on a main probe that choose the +# operating mode (heating, cooling, ...). class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): """Representation of Atlantic Pass APC Heating And Cooling Zone Control.""" @@ -108,9 +107,11 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): # Those are available and tested presets on Shogun. self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] - # Those APC Heating and Cooling probes depends on the zone control device (main probe). - # Only the base device (#1) can be used to get/set some states. - # Like to retrieve and set the current operating mode (heating, cooling, drying, off). + # Those APC Heating and Cooling probes depends on the + # zone control device (main probe). Only the base device + # (#1) can be used to get/set some states. Like to + # retrieve and set the current operating mode + # (heating, cooling, drying, off). self.zone_control_executor: OverkizExecutor | None = None @@ -183,7 +184,8 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation.""" - # When ZoneControl action is heating/cooling but Zone is stopped, means the zone is idle. + # When ZoneControl action is heating/cooling but Zone is + # stopped, means the zone is idle. if ( hvac_action := self.zone_control_hvac_action ) in HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE and cast( @@ -216,7 +218,8 @@ class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): self.executor.select_state(OverkizState.CORE_HEATING_ON_OFF), ) in (OverkizCommandParam.OFF, None) - # Device is Stopped, it means the air flux is flowing but its venting door is closed. + # Device is Stopped, it means the air flux is flowing + # but its venting door is closed. if ( (device_hvac_mode == HVACMode.COOL and cooling_is_off) or (device_hvac_mode == HVACMode.HEAT and heating_is_off) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py index 41da90f1ce8..83cf17268c7 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -1,7 +1,5 @@ """Support for HitachiAirToAirHeatPump.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -214,7 +212,14 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): def _control_backfill( self, value: str | None, state_name: str, fallback_value: str ) -> str: - """Overkiz doesn't accept commands with undefined parameters. This function is guaranteed to return a `str` which is the provided `value` if set, or the current device state if set, or the provided `fallback_value` otherwise.""" + """Return a parameter value accepted in a command. + + Overkiz doesn't accept commands with undefined + parameters. This function is guaranteed to return a + `str` which is the provided `value` if set, or the + current device state if set, or the provided + `fallback_value` otherwise. + """ if value: return value state = self.device.states[state_name] @@ -231,7 +236,11 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): swing_mode: str | None = None, leave_home: str | None = None, ) -> None: - """Execute globalControl command with all parameters. There is no option to only set a single parameter, without passing all other values.""" + """Execute globalControl command with all parameters. + + There is no option to only set a single parameter, + without passing all other values. + """ main_operation = self._control_backfill( main_operation, MAIN_OPERATION_STATE, OverkizCommandParam.ON @@ -247,14 +256,16 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): hvac_mode, MODE_CHANGE_STATE, OverkizCommandParam.AUTO, - ).lower() # Overkiz can return states that have uppercase characters which are not accepted back as commands - if ( - hvac_mode.replace(" ", "") - in [ # Overkiz can return states like 'auto cooling' or 'autoHeating' that are not valid commands and need to be converted to 'auto' - OverkizCommandParam.AUTOCOOLING, - OverkizCommandParam.AUTOHEATING, - ] - ): + ).lower() + # Overkiz can return states that have uppercase + # characters which are not accepted back as commands. + if hvac_mode.replace(" ", "") in [ + # Overkiz can return states like 'auto cooling' or + # 'autoHeating' that are not valid commands and + # need to be converted to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ]: hvac_mode = OverkizCommandParam.AUTO swing_mode = self._control_backfill( diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py index f60cbbeca2b..caac9c20b1f 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py @@ -1,7 +1,5 @@ """Support for HitachiAirToAirHeatPump.""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -67,11 +65,11 @@ SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} OVERKIZ_TO_FAN_MODES: dict[str, str] = { OverkizCommandParam.AUTO: FAN_AUTO, - OverkizCommandParam.HIGH: FAN_HIGH, # fallback, state can be exposed as HIGH, new state = hi + OverkizCommandParam.HIGH: FAN_HIGH, # fallback, new = hi OverkizCommandParam.HI: FAN_HIGH, OverkizCommandParam.LOW: FAN_LOW, OverkizCommandParam.LO: FAN_LOW, - OverkizCommandParam.MEDIUM: FAN_MEDIUM, # fallback, state can be exposed as MEDIUM, new state = med + OverkizCommandParam.MEDIUM: FAN_MEDIUM, # fallback, new = med OverkizCommandParam.MED: FAN_MEDIUM, OverkizCommandParam.SILENT: OverkizCommandParam.SILENT, } @@ -321,19 +319,23 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): OverkizCommandParam.STOP, ) - # AUTO_MANU parameter is not controlled by HA and is turned "off" when the device is on Holiday mode + # AUTO_MANU parameter is not controlled by HA and is + # turned "off" when the device is on Holiday mode auto_manu_mode = self._control_backfill( None, OverkizState.CORE_AUTO_MANU_MODE, OverkizCommandParam.MANU ) if self.preset_mode == PRESET_HOLIDAY_MODE: auto_manu_mode = OverkizCommandParam.OFF - # In all the hvac modes except AUTO, the temperature command parameter is the target temperature + # In all the hvac modes except AUTO, the temperature + # command parameter is the target temperature temperature_command = None target_temperature = target_temperature or self.target_temperature if hvac_mode == OverkizCommandParam.AUTO: - # In hvac mode AUTO, the temperature command parameter is a temperature_change - # which is the delta between a pivot temperature (25) and the target temperature + # In hvac mode AUTO, the temperature command + # parameter is a temperature_change which is the + # delta between a pivot temperature (25) and the + # target temperature temperature_change = 0 if target_temperature: diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index c5465128bba..0731b72fb40 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -1,7 +1,5 @@ """Support for HitachiAirToWaterHeatingZone.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index 381ec4d83ba..553357ddad0 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -1,7 +1,5 @@ """Support for Somfy Heating Temperature Interface.""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -61,13 +59,19 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): """Representation of Somfy Heating Temperature Interface. The thermostat has 3 ways of working: - - Auto: Switch to eco/comfort temperature on a schedule (day/hour of the day) - - Manual comfort: The thermostat use the temperature of the comfort setting (19°C degree by default) - - Manual eco: The thermostat use the temperature of the eco setting (17°C by default) - - Freeze protection: The thermostat use the temperature of the freeze protection (7°C by default) + - Auto: Switch to eco/comfort temperature on a + schedule (day/hour of the day) + - Manual comfort: The thermostat use the temperature + of the comfort setting (19 degrees by default) + - Manual eco: The thermostat use the temperature + of the eco setting (17 degrees by default) + - Freeze protection: The thermostat use the + temperature of the freeze protection (7 degrees + by default) - There's also the possibility to change the working mode, this can be used to change from a heated - floor to a cooling floor in the summer. + There's also the possibility to change the working + mode, this can be used to change from a heated floor + to a cooling floor in the summer. """ _attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/overkiz/climate/somfy_thermostat.py b/homeassistant/components/overkiz/climate/somfy_thermostat.py index d2aa1658302..975028cec8c 100644 --- a/homeassistant/components/overkiz/climate/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate/somfy_thermostat.py @@ -1,7 +1,5 @@ """Support for Somfy Smart Thermostat.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py index 54c00b33167..5befbacf41b 100644 --- a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py @@ -1,7 +1,5 @@ """Support for ValveHeatingTemperatureInterface.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -74,11 +72,13 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) @property - def hvac_action(self) -> HVACAction: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation.""" - return OVERKIZ_TO_HVAC_ACTION[ - cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) - ] + if ( + state := self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE) + ) is None: + return None + return OVERKIZ_TO_HVAC_ACTION[cast(str, state)] @property def target_temperature(self) -> float: @@ -123,8 +123,9 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - # If we want to switch to manual mode via a preset, we need to pass in a temperature - # Manual mode will be on automatically if an user sets a temperature + # If we want to switch to manual mode via a preset, + # we need to pass in a temperature. Manual mode will + # be on automatically if a user sets a temperature if preset_mode == PRESET_MANUAL: if current_temperature := self.current_temperature: await self.executor.async_execute_command( diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index b04e9436c4d..ddd584a6ec1 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Overkiz integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast @@ -42,6 +40,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Overkiz (by Somfy).""" VERSION = 1 + MINOR_VERSION = 2 _verify_ssl: bool = True _api_type: APIType = APIType.CLOUD @@ -153,8 +152,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): except TooManyRequestsException: errors["base"] = "too_many_requests" except (BadCredentialsException, NotAuthenticatedException) as exception: - # If authentication with CozyTouch auth server is valid, but token is invalid - # for Overkiz API server, the hardware is not supported. + # If authentication with CozyTouch auth server is + # valid, but token is invalid for Overkiz API + # server, the hardware is not supported. if user_input[CONF_HUB] in { Server.ATLANTIC_COZYTOUCH, Server.SAUTER_COZYTOUCH, diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 99b7d48dcca..c7a1900989f 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -1,7 +1,5 @@ """Constants for the Overkiz (by Somfy) integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final @@ -90,36 +88,38 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIClass.SWINGING_SHUTTER: Platform.COVER, UIClass.VENETIAN_BLIND: Platform.COVER, UIClass.WINDOW: Platform.COVER, - UIWidget.ALARM_PANEL_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) - UIWidget.ATLANTIC_ELECTRICAL_HEATER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) - UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.DISCRETE_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) - UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) - UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) - UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) - UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) - UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) - UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) - UIWidget.RTD_INDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) - UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) - UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) - UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (siren) - UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, # widgetName, uiClass is Alarm (not supported) - UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) - UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) - UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) - UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ALARM_PANEL_CONTROLLER: Platform.ALARM_CONTROL_PANEL, + UIWidget.ATLANTIC_ELECTRICAL_HEATER: Platform.CLIMATE, + UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: ( + Platform.CLIMATE + ), + UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: Platform.CLIMATE, + UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, + UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, + UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: Platform.CLIMATE, + UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, + UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, + UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, + UIWidget.DISCRETE_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, + UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, + UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, + UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, + UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, + UIWidget.HITACHI_DHW: Platform.WATER_HEATER, + UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, + UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, + UIWidget.RTD_INDOOR_SIREN: Platform.SWITCH, + UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, + UIWidget.RTS_GENERIC: Platform.COVER, + UIWidget.SIREN_STATUS: None, + UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, + UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, + UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, + UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, + UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, } # Map Overkiz camelCase to Home Assistant snake_case for translation @@ -138,20 +138,32 @@ OVERKIZ_STATE_TO_TRANSLATION: dict[str, str] = { OVERKIZ_UNIT_TO_HA: dict[str, str] = { MeasuredValueType.ABSOLUTE_VALUE: "", MeasuredValueType.ANGLE_IN_DEGREES: DEGREE, - MeasuredValueType.ANGULAR_SPEED_IN_DEGREES_PER_SECOND: f"{DEGREE}/{UnitOfTime.SECONDS}", + MeasuredValueType.ANGULAR_SPEED_IN_DEGREES_PER_SECOND: ( + f"{DEGREE}/{UnitOfTime.SECONDS}" + ), MeasuredValueType.ELECTRICAL_ENERGY_IN_KWH: UnitOfEnergy.KILO_WATT_HOUR, MeasuredValueType.ELECTRICAL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, MeasuredValueType.ELECTRICAL_POWER_IN_KW: UnitOfPower.KILO_WATT, MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT, MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE, - MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE, + MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: ( + UnitOfElectricCurrent.MILLIAMPERE + ), MeasuredValueType.ENERGY_IN_CAL: UnitOfEnergy.CALORIE, MeasuredValueType.ENERGY_IN_KCAL: UnitOfEnergy.KILO_CALORIE, - MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}", - MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: ( + f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}" + ), + MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + ), + MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: ( + f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}" + ), MeasuredValueType.FOSSIL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, - MeasuredValueType.GRADIENT_IN_PERCENTAGE_PER_SECOND: f"{PERCENTAGE}/{UnitOfTime.SECONDS}", + MeasuredValueType.GRADIENT_IN_PERCENTAGE_PER_SECOND: ( + f"{PERCENTAGE}/{UnitOfTime.SECONDS}" + ), MeasuredValueType.LENGTH_IN_METER: UnitOfLength.METERS, MeasuredValueType.LINEAR_SPEED_IN_METER_PER_SECOND: UnitOfSpeed.METERS_PER_SECOND, MeasuredValueType.LUMINANCE_IN_LUX: LIGHT_LUX, diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 17b967fc0b9..a0d5e6f875b 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -1,7 +1,5 @@ """Helpers to help coordinate updates.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from datetime import timedelta import logging @@ -15,6 +13,7 @@ from pyoverkiz.exceptions import ( InvalidEventListenerIdException, MaintenanceException, NotAuthenticatedException, + ServiceUnavailableException, TooManyConcurrentRequestsException, TooManyRequestsException, ) @@ -87,6 +86,8 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): raise UpdateFailed("Too many requests, try again later.") from exception except MaintenanceException as exception: raise UpdateFailed("Server is down for maintenance.") from exception + except ServiceUnavailableException as exception: + raise UpdateFailed("Server is unavailable.") from exception except InvalidEventListenerIdException as exception: raise UpdateFailed(exception) from exception except (TimeoutError, ClientConnectorError) as exception: @@ -146,7 +147,7 @@ async def on_device_available( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device available event.""" - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: coordinator.devices[event.device_url].available = True @@ -156,7 +157,7 @@ async def on_device_unavailable_disabled( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device unavailable / disabled event.""" - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: coordinator.devices[event.device_url].available = False @@ -176,7 +177,7 @@ async def on_device_state_changed( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device state changed event.""" - if not event.device_url: + if not event.device_url or event.device_url not in coordinator.devices: return for state in event.device_states: @@ -200,7 +201,7 @@ async def on_device_removed( ): registry.async_remove_device(registered_device.id) - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: del coordinator.devices[event.device_url] diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py new file mode 100644 index 00000000000..3fb405d04ba --- /dev/null +++ b/homeassistant/components/overkiz/cover.py @@ -0,0 +1,896 @@ +"""Support for Overkiz covers - shutters etc.""" + +from dataclasses import dataclass +from typing import Any + +from pyoverkiz.enums import ( + OverkizCommand, + OverkizCommandParam, + OverkizState, + UIClass, + UIWidget, +) +from pyoverkiz.types import StateType as OverkizStateType + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OverkizDataConfigEntry +from .const import DOMAIN, LOGGER +from .coordinator import OverkizDataUpdateCoordinator +from .entity import OverkizDescriptiveEntity + +# Special position values reported by some Overkiz devices +_POSITION_MY = 108 # "My position" preset +_POSITION_UNKNOWN = 124 # "Unknown position" preset + + +@dataclass(frozen=True, kw_only=True) +class OverkizCoverDescription(CoverEntityDescription): + """Class to describe an Overkiz cover.""" + + open_command: OverkizCommand | None = None + close_command: OverkizCommand | None = None + stop_command: OverkizCommand | None = None + current_position_state: OverkizState | None = None + invert_position: bool = True + set_position_command: OverkizCommand | None = None + is_closed_state: OverkizState | None = None + current_tilt_position_state: OverkizState | None = None + invert_tilt_position: bool = True + set_tilt_position_command: OverkizCommand | None = None + open_tilt_command: OverkizCommand | None = None + open_tilt_command_args: tuple[OverkizStateType, ...] = () + close_tilt_command: OverkizCommand | None = None + close_tilt_command_args: tuple[OverkizStateType, ...] = () + stop_tilt_command: OverkizCommand | None = None + + +COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ + ## + ## Overrides via UIWidget + ## + # Needs override to support position (and remove support for + # tilt position which is not supported by this device) + # uiClass is Pergola + OverkizCoverDescription( + key=UIWidget.PERGOLA_HORIZONTAL_AWNING, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + stop_command=OverkizCommand.STOP, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + ), + OverkizCoverDescription( + key=UIWidget.PERGOLA_HORIZONTAL_AWNING_UNO, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + stop_command=OverkizCommand.STOP, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + ), + # Needs override to support lower/upper position control + # uiClass is RollerShutter + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_DUAL_ROLLER_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_UPPER_CLOSURE, + set_position_command=OverkizCommand.SET_UPPER_CLOSURE, + open_command=OverkizCommand.UPPER_OPEN, + close_command=OverkizCommand.UPPER_CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_UPPER_OPEN_CLOSED, + # Lower position used as tilt (no separate tilt state) + current_tilt_position_state=OverkizState.CORE_LOWER_CLOSURE, + set_tilt_position_command=OverkizCommand.SET_LOWER_CLOSURE, + open_tilt_command=OverkizCommand.LOWER_OPEN, + close_tilt_command=OverkizCommand.LOWER_CLOSE, + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to add support for very specific tilt commands + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.TILT_ONLY_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + # Position commands fully open/close the tilt + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + # Tilt commands move the tilt with a few degrees + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(1, 0), + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(1, 0), + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to support very specific tilt commands + # (rts:ExteriorVenetianBlindRTSComponent) + # uiClass is ExteriorVenetianBlind + OverkizCoverDescription( + key=UIWidget.UP_DOWN_EXTERIOR_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to support very specific tilt commands + # (rts:VenetianBlindRTSComponent) + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.UP_DOWN_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to support very specific tilt commands (rts:SheerBlindRTSComponent) + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.UP_DOWN_SHEER_SCREEN, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override since BioclimaticPergola uses core:SlatsOpenClosedState + # and core:SlateOrientationState (tilt-only, no position) + # uiClass is Pergola + OverkizCoverDescription( + key=UIWidget.BIOCLIMATIC_PERGOLA, + device_class=CoverDeviceClass.AWNING, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.OPEN_SLATS, + close_tilt_command=OverkizCommand.CLOSE_SLATS, + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override since PositionableGarageDoor reports + # core:OpenClosedUnknownState instead of core:OpenClosedState + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN, + ), + # Needs override since DiscretePositionableGarageDoor reports + # core:OpenClosedUnknownState instead of core:OpenClosedState + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.DISCRETE_POSITIONABLE_GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN, + ), + # Needs override since PositionableGarageDoorWithPartialPosition reports + # core:OpenClosedPartialState instead of core:OpenClosedState + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_GARAGE_DOOR_WITH_PARTIAL_POSITION, + device_class=CoverDeviceClass.GARAGE, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PARTIAL, + ), + # Needs override since DiscreteGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.DISCRETE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since OpenCloseGate4T only supports the cycle command + # (rts:GateOpenerRTS4TComponent) + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_GATE_4T, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since UpDownGarageDoor4T only supports the cycle command + # (rts:GarageDoor4TRTSComponent) + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.UP_DOWN_GARAGE_DOOR_4T, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since OpenCloseSlidingGarageDoor4T only supports the cycle command + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GARAGE_DOOR_4T, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since OpenCloseSlidingGate4T only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GATE_4T, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicGarageDoor only supports the cycle command + # (io:CyclicGarageOpenerIOComponent) + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.CYCLIC_GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicSlidingGateOpener only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.CYCLIC_SLIDING_GATE_OPENER, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicSwingingGateOpener only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.CYCLIC_SWINGING_GATE_OPENER, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since SlidingDiscreteGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.SLIDING_DISCRETE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since OpenCloseGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since OpenCloseSlidingGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since PositionableGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + stop_command=OverkizCommand.STOP, + ), + # Needs override to support this Generic device (rts:GenericRTSComponent) + # uiClass is Generic (not mapped to cover as this is a Generic device class) + OverkizCoverDescription( + key=UIWidget.RTS_GENERIC, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + ), + ## + ## Default cover behavior (via UIClass) + ## + OverkizCoverDescription( + key=UIClass.ADJUSTABLE_SLATS_ROLLER_SHUTTER, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.AWNING, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.CURTAIN, + device_class=CoverDeviceClass.CURTAIN, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.EXTERIOR_SCREEN, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.EXTERIOR_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.TILT_DOWN, + close_tilt_command=OverkizCommand.TILT_UP, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.GATE, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.PERGOLA, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.ROLLER_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SCREEN, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SWINGING_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.TILT_UP, + close_tilt_command=OverkizCommand.TILT_DOWN, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.WINDOW, + device_class=CoverDeviceClass.WINDOW, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), +] + +SUPPORTED_DEVICES = {description.key: description for description in COVER_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverkizDataConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Overkiz covers from a config entry.""" + data = entry.runtime_data + + entities: list[OverkizCover] = [] + + for device in data.platforms[Platform.COVER]: + if description := ( + SUPPORTED_DEVICES.get(device.widget) + or SUPPORTED_DEVICES.get(device.ui_class) + ): + entities.append( + OverkizCover(device.device_url, data.coordinator, description) + ) + + # Cover platform does not support configuring the speed of the cover + # For covers where the speed can be configured, we create a separate entity + if ( + OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED + in device.definition.commands + ): + entities.append( + OverkizLowSpeedCover( + device.device_url, data.coordinator, description + ) + ) + + async_add_entities(entities) + + +class OverkizCover(OverkizDescriptiveEntity, CoverEntity): + """Representation of an Overkiz Cover.""" + + entity_description: OverkizCoverDescription + + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizCoverDescription, + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator, description) + + # Use device url as unique ID for backwards compatibility + self._attr_unique_id = self.device.device_url + + # Overkiz does support covers where only tilt commands are supported + # and HA sets by default open/close as supported feature which conflicts + supported_features = CoverEntityFeature(0) + + if self.entity_description.open_command and self.executor.has_command( + self.entity_description.open_command + ): + supported_features |= CoverEntityFeature.OPEN + + if self.entity_description.stop_command and self.executor.has_command( + self.entity_description.stop_command + ): + supported_features |= CoverEntityFeature.STOP + + if self.entity_description.close_command and self.executor.has_command( + self.entity_description.close_command + ): + supported_features |= CoverEntityFeature.CLOSE + + if self.entity_description.open_tilt_command and self.executor.has_command( + self.entity_description.open_tilt_command + ): + supported_features |= CoverEntityFeature.OPEN_TILT + + if self.entity_description.stop_tilt_command and self.executor.has_command( + self.entity_description.stop_tilt_command + ): + supported_features |= CoverEntityFeature.STOP_TILT + + if self.entity_description.close_tilt_command and self.executor.has_command( + self.entity_description.close_tilt_command + ): + supported_features |= CoverEntityFeature.CLOSE_TILT + + if ( + self.entity_description.set_tilt_position_command + and self.executor.has_command( + self.entity_description.set_tilt_position_command + ) + ): + supported_features |= CoverEntityFeature.SET_TILT_POSITION + + if self.entity_description.set_position_command and self.executor.has_command( + self.entity_description.set_position_command + ): + supported_features |= CoverEntityFeature.SET_POSITION + + self._attr_supported_features = supported_features + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if is_closed_state := self.entity_description.is_closed_state: + if state := self.device.states.get(is_closed_state): + if state.value == OverkizCommandParam.UNKNOWN: + return None + return state.value == OverkizCommandParam.CLOSED + + if (position := self.current_cover_position) is not None: + return position == 0 + + if (tilt_position := self.current_cover_tilt_position) is not None: + return tilt_position == 0 + + return None + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + state_name = self.entity_description.current_position_state + + if not state_name or not (state := self.device.states[state_name]): + return None + + position = state.value_as_int + + # Fallback for "My position" preset + if position == _POSITION_MY: + LOGGER.debug( + "Overkiz cover position is invalid (%s). Device: %s, State: %s", + _POSITION_MY, + self.device.device_url, + state_name, + ) + + if fallback_state := self.device.states[ + OverkizState.CORE_MEMORIZED_1_POSITION + ]: + position = fallback_state.value_as_int + else: + return None + + # Fallback for "Unknown position" preset + if position == _POSITION_UNKNOWN: + LOGGER.debug( + "Overkiz cover position is invalid (%s). Device: %s, State: %s", + _POSITION_UNKNOWN, + self.device.device_url, + state_name, + ) + + if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]: + position = fallback_state.value_as_int + else: + return None + + if position is None: + return None + + # Invert position if needed (some devices report 0 as open and 100 as closed) + if self.entity_description.invert_position: + position = 100 - position + + return position + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + if self.entity_description.invert_position: + position = 100 - position + + if command := self.entity_description.set_position_command: + await self.executor.async_execute_command(command, position) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if command := self.entity_description.open_command: + await self.executor.async_execute_command(command) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + if command := self.entity_description.close_command: + await self.executor.async_execute_command(command) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + if command := self.entity_description.stop_command: + await self.executor.async_execute_command(command) + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + state_name = self.entity_description.current_tilt_position_state + + if state_name and (state := self.device.states[state_name]): + position = state.value_as_int + if position is None: + return None + + if self.entity_description.invert_tilt_position: + position = 100 - position + + return position + + return None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + position = kwargs[ATTR_TILT_POSITION] + + if self.entity_description.invert_tilt_position: + position = 100 - position + + if command := self.entity_description.set_tilt_position_command: + await self.executor.async_execute_command(command, position) + + async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None: + """Move cover and tilt to a specific position. + + Exposed as the `overkiz.set_cover_position_and_tilt` + service action. Uses the setClosureAndOrientation + command to move slats and closure in a single + instruction. Calling set_cover_position and + set_cover_tilt_position sequentially will cause the + motor to stop between commands on some devices (e.g. + Somfy DynamicExteriorVenetianBlind). + """ + if not self.executor.has_command(OverkizCommand.SET_CLOSURE_AND_ORIENTATION): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_set_position_and_tilt", + ) + + position = kwargs[ATTR_POSITION] + tilt_position = kwargs[ATTR_TILT_POSITION] + + if self.entity_description.invert_position: + position = 100 - position + if self.entity_description.invert_tilt_position: + tilt_position = 100 - tilt_position + + await self.executor.async_execute_command( + OverkizCommand.SET_CLOSURE_AND_ORIENTATION, + position, + tilt_position, + ) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + if command := self.entity_description.open_tilt_command: + await self.executor.async_execute_command( + command, *self.entity_description.open_tilt_command_args + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + if command := self.entity_description.close_tilt_command: + await self.executor.async_execute_command( + command, *self.entity_description.close_tilt_command_args + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + if command := self.entity_description.stop_tilt_command: + await self.executor.async_execute_command(command) + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening or not.""" + # Check if any open() commands are currently running for this device + if (command := self.entity_description.open_command) and self.is_running( + command + ): + return True + + # Check if any open_tilt() commands are currently running for this device + if (command := self.entity_description.open_tilt_command) and self.is_running( + command + ): + return True + + if self.moving_offset is None: + return None + + # Check if the cover is moving in a direction consistent with opening + if self.entity_description.invert_position: + return self.moving_offset > 0 + return self.moving_offset < 0 + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + # Check if any close() commands are currently running for this device + if (command := self.entity_description.close_command) and self.is_running( + command + ): + return True + + # Check if any close_tilt() commands are currently running for this device + if (command := self.entity_description.close_tilt_command) and self.is_running( + command + ): + return True + + if self.moving_offset is None: + return None + + # Check if the cover is moving in a direction consistent with closing + if self.entity_description.invert_position: + return self.moving_offset < 0 + return self.moving_offset > 0 + + def is_running(self, command: OverkizCommand) -> bool: + """Return if the given commands are currently running.""" + return any( + execution.get("device_url") == self.device.device_url + and execution.get("command_name") == command + for execution in self.coordinator.executions.values() + ) + + @property + def moving_offset(self) -> int | None: + """Return the offset between targeted and current position.""" + moving_state = self.device.states.get(OverkizState.CORE_MOVING) + if moving_state is None or moving_state.value_as_bool is not True: + return None + + current_closure = self.device.states.get( + self.entity_description.current_position_state or OverkizState.CORE_CLOSURE + ) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not current_closure or not target_closure: + return None + + current_value = current_closure.value_as_int + target_value = target_closure.value_as_int + + if current_value is None or target_value is None: + return None + + if current_value in (_POSITION_MY, _POSITION_UNKNOWN): + return None + + return current_value - target_value + + +class OverkizLowSpeedCover(OverkizCover): + """Representation of an Overkiz Low Speed cover.""" + + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizCoverDescription, + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator, description) + + self._attr_name = "Low speed" + self._attr_unique_id = f"{self._attr_unique_id}_low_speed" + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self._async_set_cover_position_low_speed(kwargs[ATTR_POSITION]) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._async_set_cover_position_low_speed(100) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._async_set_cover_position_low_speed(0) + + async def _async_set_cover_position_low_speed(self, position: int) -> None: + """Move the cover to a specific position with a low speed.""" + await self.executor.async_execute_command( + OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED, + 100 - position, + OverkizCommandParam.LOWSPEED, + ) diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py deleted file mode 100644 index dd3216f9c10..00000000000 --- a/homeassistant/components/overkiz/cover/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Support for Overkiz covers - shutters etc.""" - -from pyoverkiz.enums import OverkizCommand, UIClass - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .. import OverkizDataConfigEntry -from .awning import Awning -from .generic_cover import OverkizGenericCover -from .vertical_cover import LowSpeedCover, VerticalCover - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OverkizDataConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the Overkiz covers from a config entry.""" - data = entry.runtime_data - - entities: list[OverkizGenericCover] = [ - Awning(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if device.ui_class == UIClass.AWNING - ] - - entities += [ - VerticalCover(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if device.ui_class != UIClass.AWNING - ] - - entities += [ - LowSpeedCover(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED in device.definition.commands - ] - - async_add_entities(entities) diff --git a/homeassistant/components/overkiz/cover/awning.py b/homeassistant/components/overkiz/cover/awning.py deleted file mode 100644 index 4b6e5b176a7..00000000000 --- a/homeassistant/components/overkiz/cover/awning.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Support for Overkiz awnings.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import OverkizCommand, OverkizState - -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverDeviceClass, - CoverEntityFeature, -) - -from .generic_cover import ( - COMMANDS_CLOSE, - COMMANDS_OPEN, - COMMANDS_STOP, - OverkizGenericCover, -) - - -class Awning(OverkizGenericCover): - """Representation of an Overkiz awning.""" - - _attr_device_class = CoverDeviceClass.AWNING - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = super().supported_features - - if self.executor.has_command(OverkizCommand.SET_DEPLOYMENT): - supported_features |= CoverEntityFeature.SET_POSITION - - if self.executor.has_command(OverkizCommand.DEPLOY): - supported_features |= CoverEntityFeature.OPEN - - if self.executor.has_command(*COMMANDS_STOP): - supported_features |= CoverEntityFeature.STOP - - if self.executor.has_command(OverkizCommand.UNDEPLOY): - supported_features |= CoverEntityFeature.CLOSE - - return supported_features - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - current_position = self.executor.select_state(OverkizState.CORE_DEPLOYMENT) - if current_position is not None: - return cast(int, current_position) - - return None - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - await self.executor.async_execute_command( - OverkizCommand.SET_DEPLOYMENT, kwargs[ATTR_POSITION] - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - await self.executor.async_execute_command(OverkizCommand.DEPLOY) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - await self.executor.async_execute_command(OverkizCommand.UNDEPLOY) - - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not.""" - if self.is_running(COMMANDS_OPEN): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) < cast(int, target_closure.value) - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not.""" - if self.is_running(COMMANDS_CLOSE): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) > cast(int, target_closure.value) diff --git a/homeassistant/components/overkiz/cover/generic_cover.py b/homeassistant/components/overkiz/cover/generic_cover.py deleted file mode 100644 index df13072524d..00000000000 --- a/homeassistant/components/overkiz/cover/generic_cover.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Base class for Overkiz covers, shutters, awnings, etc.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState - -from homeassistant.components.cover import ( - ATTR_TILT_POSITION, - CoverEntity, - CoverEntityFeature, -) - -from ..entity import OverkizEntity - -ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" - -COMMANDS_STOP: list[OverkizCommand] = [ - OverkizCommand.STOP, - OverkizCommand.MY, -] -COMMANDS_STOP_TILT: list[OverkizCommand] = [ - OverkizCommand.STOP, - OverkizCommand.MY, -] -COMMANDS_OPEN: list[OverkizCommand] = [ - OverkizCommand.OPEN, - OverkizCommand.UP, -] -COMMANDS_OPEN_TILT: list[OverkizCommand] = [ - OverkizCommand.OPEN_SLATS, - OverkizCommand.TILT_DOWN, -] -COMMANDS_CLOSE: list[OverkizCommand] = [ - OverkizCommand.CLOSE, - OverkizCommand.DOWN, -] -COMMANDS_CLOSE_TILT: list[OverkizCommand] = [ - OverkizCommand.CLOSE_SLATS, - OverkizCommand.TILT_UP, -] - -COMMANDS_SET_TILT_POSITION: list[OverkizCommand] = [OverkizCommand.SET_ORIENTATION] - - -class OverkizGenericCover(OverkizEntity, CoverEntity): - """Representation of an Overkiz Cover.""" - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - position = self.executor.select_state( - OverkizState.CORE_SLATS_ORIENTATION, OverkizState.CORE_SLATE_ORIENTATION - ) - if position is not None: - return 100 - cast(int, position) - - return None - - async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover tilt to a specific position.""" - if command := self.executor.select_command(*COMMANDS_SET_TILT_POSITION): - await self.executor.async_execute_command( - command, - 100 - kwargs[ATTR_TILT_POSITION], - ) - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - - state = self.executor.select_state( - OverkizState.CORE_OPEN_CLOSED, - OverkizState.CORE_SLATS_OPEN_CLOSED, - OverkizState.CORE_OPEN_CLOSED_PARTIAL, - OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, - OverkizState.CORE_OPEN_CLOSED_UNKNOWN, - OverkizState.MYFOX_SHUTTER_STATUS, - ) - if state is not None: - return state == OverkizCommandParam.CLOSED - - # Keep this condition after the previous one. Some device like the pedestrian gate, always return 50 as position. - if self.current_cover_position is not None: - return self.current_cover_position == 0 - - if self.current_cover_tilt_position is not None: - return self.current_cover_tilt_position == 0 - - return None - - async def async_open_cover_tilt(self, **kwargs: Any) -> None: - """Open the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_OPEN_TILT): - await self.executor.async_execute_command(command) - - async def async_close_cover_tilt(self, **kwargs: Any) -> None: - """Close the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_CLOSE_TILT): - await self.executor.async_execute_command(command) - - async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the cover.""" - if command := self.executor.select_command(*COMMANDS_STOP): - await self.executor.async_execute_command(command) - - async def async_stop_cover_tilt(self, **kwargs: Any) -> None: - """Stop the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_STOP_TILT): - await self.executor.async_execute_command(command) - - def is_running(self, commands: list[OverkizCommand]) -> bool: - """Return if the given commands are currently running.""" - return any( - execution.get("device_url") == self.device.device_url - and execution.get("command_name") in commands - for execution in self.coordinator.executions.values() - ) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature(0) - - if self.executor.has_command(*COMMANDS_OPEN_TILT): - supported_features |= CoverEntityFeature.OPEN_TILT - - if self.executor.has_command(*COMMANDS_STOP_TILT): - supported_features |= CoverEntityFeature.STOP_TILT - - if self.executor.has_command(*COMMANDS_CLOSE_TILT): - supported_features |= CoverEntityFeature.CLOSE_TILT - - if self.executor.has_command(*COMMANDS_SET_TILT_POSITION): - supported_features |= CoverEntityFeature.SET_TILT_POSITION - - return supported_features diff --git a/homeassistant/components/overkiz/cover/vertical_cover.py b/homeassistant/components/overkiz/cover/vertical_cover.py deleted file mode 100644 index 48ac2c838c5..00000000000 --- a/homeassistant/components/overkiz/cover/vertical_cover.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Support for Overkiz Vertical Covers.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import ( - OverkizCommand, - OverkizCommandParam, - OverkizState, - UIClass, - UIWidget, -) - -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverDeviceClass, - CoverEntityFeature, -) - -from ..coordinator import OverkizDataUpdateCoordinator -from .generic_cover import ( - COMMANDS_CLOSE_TILT, - COMMANDS_OPEN_TILT, - COMMANDS_STOP, - OverkizGenericCover, -) - -COMMANDS_OPEN = [OverkizCommand.OPEN, OverkizCommand.UP, OverkizCommand.CYCLE] -COMMANDS_CLOSE = [OverkizCommand.CLOSE, OverkizCommand.DOWN, OverkizCommand.CYCLE] - -OVERKIZ_DEVICE_TO_DEVICE_CLASS = { - UIClass.CURTAIN: CoverDeviceClass.CURTAIN, - UIClass.EXTERIOR_SCREEN: CoverDeviceClass.BLIND, - UIClass.EXTERIOR_VENETIAN_BLIND: CoverDeviceClass.BLIND, - UIClass.GARAGE_DOOR: CoverDeviceClass.GARAGE, - UIClass.GATE: CoverDeviceClass.GATE, - UIWidget.MY_FOX_SECURITY_CAMERA: CoverDeviceClass.SHUTTER, - UIClass.PERGOLA: CoverDeviceClass.AWNING, - UIClass.ROLLER_SHUTTER: CoverDeviceClass.SHUTTER, - UIClass.SWINGING_SHUTTER: CoverDeviceClass.SHUTTER, - UIClass.WINDOW: CoverDeviceClass.WINDOW, -} - - -class VerticalCover(OverkizGenericCover): - """Representation of an Overkiz vertical cover.""" - - def __init__( - self, device_url: str, coordinator: OverkizDataUpdateCoordinator - ) -> None: - """Initialize vertical cover.""" - super().__init__(device_url, coordinator) - self._attr_device_class = ( - OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) - or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) - or CoverDeviceClass.BLIND - ) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = super().supported_features - - if self.executor.has_command(OverkizCommand.SET_CLOSURE): - supported_features |= CoverEntityFeature.SET_POSITION - - if self.executor.has_command(*COMMANDS_OPEN): - supported_features |= CoverEntityFeature.OPEN - - if self.executor.has_command(*COMMANDS_STOP): - supported_features |= CoverEntityFeature.STOP - - if self.executor.has_command(*COMMANDS_CLOSE): - supported_features |= CoverEntityFeature.CLOSE - - return supported_features - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - position = self.executor.select_state( - OverkizState.CORE_CLOSURE, - OverkizState.CORE_CLOSURE_OR_ROCKER_POSITION, - OverkizState.CORE_PEDESTRIAN_POSITION, - ) - - if position is None: - return None - - return 100 - cast(int, position) - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - position = 100 - kwargs[ATTR_POSITION] - await self.executor.async_execute_command(OverkizCommand.SET_CLOSURE, position) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - if command := self.executor.select_command(*COMMANDS_OPEN): - await self.executor.async_execute_command(command) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - if command := self.executor.select_command(*COMMANDS_CLOSE): - await self.executor.async_execute_command(command) - - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not.""" - if self.is_running(COMMANDS_OPEN + COMMANDS_OPEN_TILT): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) > cast(int, target_closure.value) - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not.""" - if self.is_running(COMMANDS_CLOSE + COMMANDS_CLOSE_TILT): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) < cast(int, target_closure.value) - - -class LowSpeedCover(VerticalCover): - """Representation of an Overkiz Low Speed cover.""" - - def __init__( - self, - device_url: str, - coordinator: OverkizDataUpdateCoordinator, - ) -> None: - """Initialize the device.""" - super().__init__(device_url, coordinator) - self._attr_name = "Low speed" - self._attr_unique_id = f"{self._attr_unique_id}_low_speed" - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - await self.async_set_cover_position_low_speed(**kwargs) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - await self.async_set_cover_position_low_speed(**{ATTR_POSITION: 100}) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - await self.async_set_cover_position_low_speed(**{ATTR_POSITION: 0}) - - async def async_set_cover_position_low_speed(self, **kwargs: Any) -> None: - """Move the cover to a specific position with a low speed.""" - position = 100 - kwargs.get(ATTR_POSITION, 0) - - await self.executor.async_execute_command( - OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED, - position, - OverkizCommandParam.LOWSPEED, - ) diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 45c5030a7c7..630c9b82c03 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Overkiz.""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import APIType diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 1e78af867ab..69339625be2 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -1,10 +1,8 @@ """Parent class for every Overkiz device.""" -from __future__ import annotations - from typing import cast -from pyoverkiz.enums import OverkizAttribute, OverkizState +from pyoverkiz.enums import APIType, OverkizAttribute, OverkizCommandParam, OverkizState from pyoverkiz.models import Device from homeassistant.helpers.device_registry import DeviceInfo @@ -46,7 +44,21 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): @property def available(self) -> bool: """Return True if entity is available.""" - return self.device.available and super().available + if self.device.available: + return super().available + + # Workaround: local API may incorrectly report + # available=False (Somfy-TaHoma-Developer-Mode#217) + if self.coordinator.client.api_type != APIType.LOCAL: + return False + + if status_state := self.device.states.get(OverkizState.CORE_STATUS): + return ( + status_state.value == OverkizCommandParam.AVAILABLE + and super().available + ) + + return False @property def is_sub_device(self) -> bool: diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 220c6fe7cb2..58eb95613d8 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -1,7 +1,5 @@ """Class for helpers and communication with the OverKiz API.""" -from __future__ import annotations - from typing import Any, cast from urllib.parse import urlparse @@ -22,6 +20,8 @@ COMMANDS_WITHOUT_DELAY = [ OverkizCommand.ON, OverkizCommand.ON_WITH_TIMER, OverkizCommand.TEST, + OverkizCommand.TILT_POSITIVE, + OverkizCommand.TILT_NEGATIVE, ] @@ -86,8 +86,9 @@ class OverkizExecutor: ) -> None: """Execute device command in async context. - :param refresh_afterwards: Whether to refresh the device state after the command is executed. - If several commands are executed, it will be refreshed only once. + :param refresh_afterwards: Whether to refresh the device + state after the command is executed. If several + commands are executed, it will be refreshed only once. """ parameters = [arg for arg in args if arg is not None] # Set the execution duration to 0 seconds for RTS devices on supported commands @@ -108,7 +109,8 @@ class OverkizExecutor: except BaseOverkizException as exception: raise HomeAssistantError(exception) from exception - # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here + # ExecutionRegisteredEvent doesn't contain the + # device_url, thus we need to register it here self.coordinator.executions[exec_id] = { "device_url": self.device.device_url, "command_name": command_name, @@ -121,8 +123,9 @@ class OverkizExecutor: ) -> bool: """Cancel running execution by command.""" - # Cancel a running execution - # Retrieve executions initiated via Home Assistant from Data Update Coordinator queue + # Cancel a running execution. Retrieve executions + # initiated via Home Assistant from Data Update + # Coordinator queue exec_id = next( ( exec_id diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json index 6e5db404e17..579155b64f8 100644 --- a/homeassistant/components/overkiz/icons.json +++ b/homeassistant/components/overkiz/icons.json @@ -42,5 +42,10 @@ } } } + }, + "services": { + "set_cover_position_and_tilt": { + "service": "mdi:window-shutter-cog" + } } } diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py index acd63140196..981cb41742f 100644 --- a/homeassistant/components/overkiz/light.py +++ b/homeassistant/components/overkiz/light.py @@ -1,7 +1,5 @@ """Support for Overkiz lights.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py index 16ec32b0667..6d28a834613 100644 --- a/homeassistant/components/overkiz/lock.py +++ b/homeassistant/components/overkiz/lock.py @@ -1,7 +1,5 @@ """Support for Overkiz locks.""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 8905e53603a..a5baca90716 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.20.0"], + "requirements": ["pyoverkiz==1.20.4"], "zeroconf": [ { "name": "gateway*", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 70028f138b7..59ac56def63 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -1,7 +1,5 @@ """Support for Overkiz (virtual) numbers.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py index bd362b4b372..06234cbc4b4 100644 --- a/homeassistant/components/overkiz/scene.py +++ b/homeassistant/components/overkiz/scene.py @@ -1,7 +1,5 @@ """Support for Overkiz scenes.""" -from __future__ import annotations - from typing import Any from pyoverkiz.client import OverkizClient diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index d93b71b540f..81eb3f9a503 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -1,7 +1,5 @@ """Support for Overkiz select.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 0636d69a3eb..fe1b9f54f03 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -1,7 +1,5 @@ """Support for Overkiz sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -10,6 +8,7 @@ from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -606,10 +605,25 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and ( unit_value := unit.value_as_str ): - return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + if self._is_unit_valid_for_device_class(ha_unit): + return ha_unit return default_unit + def _is_unit_valid_for_device_class(self, unit: str) -> bool: + """Check if a unit is valid for this sensor's device class. + + The device-level core:MeasuredValueType attribute describes the primary + sensor (e.g. luminance/temperature), but must not override the unit of + unrelated sensors on the same device (e.g. RSSI). + """ + if not (device_class := self.entity_description.device_class): + return True + if (valid_units := DEVICE_CLASS_UNITS.get(device_class)) is None: + return True + return unit in valid_units + class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): """Representation of an Overkiz HomeKit Setup Code.""" diff --git a/homeassistant/components/overkiz/services.py b/homeassistant/components/overkiz/services.py new file mode 100644 index 00000000000..f32fa307e59 --- /dev/null +++ b/homeassistant/components/overkiz/services.py @@ -0,0 +1,42 @@ +"""Services for the Overkiz integration.""" + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN + +SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt" + +POSITION_MIN = 0 +POSITION_MAX = 100 + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Overkiz integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + entity_domain=COVER_DOMAIN, + schema={ + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + }, + func="async_set_cover_position_and_tilt", + required_features=[ + CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION + ], + ) diff --git a/homeassistant/components/overkiz/services.yaml b/homeassistant/components/overkiz/services.yaml new file mode 100644 index 00000000000..f51b602d962 --- /dev/null +++ b/homeassistant/components/overkiz/services.yaml @@ -0,0 +1,23 @@ +set_cover_position_and_tilt: + target: + entity: + integration: overkiz + domain: cover + supported_features: + - - cover.CoverEntityFeature.SET_POSITION + - cover.CoverEntityFeature.SET_TILT_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + tilt_position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 7e55067e80b..9da79f90073 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -179,5 +179,26 @@ } } } + }, + "exceptions": { + "unsupported_set_position_and_tilt": { + "message": "This device does not support setting position and tilt simultaneously." + } + }, + "services": { + "set_cover_position_and_tilt": { + "description": "Moves the cover and tilt to the target position simultaneously, preventing the motor from stopping between movements.", + "fields": { + "position": { + "description": "Target vertical position. 0 means closed, 100 means fully open.", + "name": "Position" + }, + "tilt_position": { + "description": "Target tilt position. 0 means closed, 100 means fully open.", + "name": "Tilt position" + } + }, + "name": "Set cover position and tilt" + } } } diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index 9260f9800a1..e40389a7889 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -1,7 +1,5 @@ """Support for Overkiz switches.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index af7bc1cd1cc..742db8f47e3 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -1,7 +1,5 @@ """Support for Overkiz water heater devices.""" -from __future__ import annotations - from pyoverkiz.enums.ui import UIWidget from homeassistant.const import Platform @@ -54,7 +52,13 @@ WIDGET_TO_WATER_HEATER_ENTITY = { } CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { - "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, - "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, - "io:AtlanticDomesticHotWaterProductionV2_CETHI_V4_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, + "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": ( + AtlanticDomesticHotWaterProductionMBLComponent + ), + "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": ( + AtlanticDomesticHotWaterProductionV2IOComponent + ), + "io:AtlanticDomesticHotWaterProductionV2_CETHI_V4_IOComponent": ( + AtlanticDomesticHotWaterProductionV2IOComponent + ), } diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py index 8ba2c1678c2..e4e34867dd3 100644 --- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py @@ -154,19 +154,33 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE async def async_turn_away_mode_on(self) -> None: """Turn away mode on. - This requires the start date and the end date to be also set, and those dates have to match the device datetime. - The API accepts setting dates in the format of the core:DateTimeState state for the DHW - {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024} - The dict is then passed as an actual device date, the away mode start date, and then as an end date, - but with the year incremented by 1, so the away mode is getting turned on for the next year. - The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant, - but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch - based on datetime.now() and datetime.timedelta into the future. - If you execute `setAbsenceStartDate`, `setAbsenceEndDate` and `setAbsenceMode`, - the API answers with "too many requests", as there's a polling update after each command execution, - and the device becomes unavailable until the API is available again. - With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command, - the API is not choking and the transition is smooth without the unavailability state. + This requires the start date and the end date to be + also set, and those dates have to match the device + datetime. The API accepts setting dates in the format + of the core:DateTimeState state for the DHW: + {"day": 11, "hour": 21, "minute": 12, "month": 7, + "second": 53, "weekday": 3, "year": 2024}. + The dict is then passed as an actual device date, the + away mode start date, and then as an end date, but with + the year incremented by 1, so the away mode is getting + turned on for the next year. + + The weekday number seems to have no effect so the + calculation of the future date's weekday number is + redundant, but possible via homeassistant dt_util to + form both start and end dates dictionaries from + scratch based on datetime.now() and + datetime.timedelta into the future. + + If you execute `setAbsenceStartDate`, + `setAbsenceEndDate` and `setAbsenceMode`, the API + answers with "too many requests", as there's a polling + update after each command execution, and the device + becomes unavailable until the API is available again. + With `refresh_afterwards=False` on the first commands, + and `refresh_afterwards=True` only the last command, + the API is not choking and the transition is smooth + without the unavailability state. """ now = dt_util.now() now_date = { diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py index 7e7db07f847..86a7ca47293 100644 --- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py @@ -207,8 +207,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater elif operation_mode == STATE_HEAT_PUMP: refresh_target_temp = False if self.is_state_performance: - # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP - # changes the target temperature and requires a target temperature refresh + # Switching from STATE_PERFORMANCE to + # STATE_HEAT_PUMP changes the target temperature + # and requires a target temperature refresh refresh_target_temp = True if self.is_boost_mode_on: @@ -280,7 +281,8 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater refresh_target_temp = False if self.is_state_performance: - # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh + # Switching from STATE_PERFORMANCE to BOOST requires + # a target temperature refresh refresh_target_temp = True await self.executor.async_execute_command( diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py index f5a9e3d4a7e..ab5f1d0b060 100644 --- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py +++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py @@ -1,7 +1,5 @@ """Support for DomesticHotWaterProduction.""" -from __future__ import annotations - from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py index 988c66afdb0..f49ba3955ad 100644 --- a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py +++ b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py @@ -1,7 +1,5 @@ """Support for Hitachi DHW.""" -from __future__ import annotations - from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 66cec82fb61..87299859bb8 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -1,7 +1,5 @@ """The Overseerr integration.""" -from __future__ import annotations - import json from typing import cast diff --git a/homeassistant/components/overseerr/diagnostics.py b/homeassistant/components/overseerr/diagnostics.py index d45e1441e23..aa8b2333baf 100644 --- a/homeassistant/components/overseerr/diagnostics.py +++ b/homeassistant/components/overseerr/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Overseerr.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py new file mode 100644 index 00000000000..54f22506078 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py @@ -0,0 +1,86 @@ +"""The OVHcloud AI Endpoints integration.""" + +from openai import ( + AsyncOpenAI, + AuthenticationError, + BadRequestError, + OpenAIError, + PermissionDeniedError, +) +from openai.types.chat import ChatCompletionUserMessageParam + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import BASE_URL + +PLATFORMS = [Platform.CONVERSATION] + +type OVHcloudAIEndpointsConfigEntry = ConfigEntry[AsyncOpenAI] + + +def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI: + """Create the AsyncOpenAI client used by this integration.""" + return AsyncOpenAI( + base_url=BASE_URL, + api_key=api_key, + http_client=get_async_client(hass), + ) + + +async def _validate_api_key(client: AsyncOpenAI) -> None: + """Validate the API key against the chat completions endpoint. + + We send a chat completion request with an unknown ``extra_body`` field + to prevent valid usage and billing. + A valid key triggers a 400 (BadRequestError), which we treat as success. + An invalid key triggers a 401 (AuthenticationError),which propagates + along with any other exception. + """ + try: + await client.with_options(timeout=10.0).chat.completions.create( + model="llama@latest", + messages=[ChatCompletionUserMessageParam(role="user", content="ping")], + extra_body={"foo": "bar"}, + ) + except BadRequestError: + return + + +async def async_setup_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> bool: + """Set up OVHcloud AI Endpoints from a config entry.""" + client = _create_client(hass, entry.data[CONF_API_KEY]) + + try: + await _validate_api_key(client) + except (AuthenticationError, PermissionDeniedError) as err: + raise ConfigEntryAuthFailed(err) from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> None: + """Reload the entry when its data or subentries change.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> bool: + """Unload OVHcloud AI Endpoints.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py new file mode 100644 index 00000000000..cb32d004411 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -0,0 +1,204 @@ +"""Config flow for the OVHcloud AI Endpoints integration.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import callback +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from . import _create_client, _validate_api_key +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS + +_LOGGER = logging.getLogger(__name__) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OVHcloud AI Endpoints.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = _create_client(self.hass, user_input[CONF_API_KEY]) + try: + await _validate_api_key(client) + except AuthenticationError, PermissionDeniedError: + errors["base"] = "invalid_auth" + except OpenAIError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OVHcloud AI Endpoints", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + if user_input is not None: + client = _create_client(self.hass, user_input[CONF_API_KEY]) + try: + await _validate_api_key(client) + except AuthenticationError, PermissionDeniedError: + errors["base"] = "invalid_auth" + except OpenAIError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + +class ConversationFlowHandler(ConfigSubentryFlow): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + self.options: dict[str, Any] = {} + + async def _get_models(self) -> None: + """Fetch models from OVHcloud AI Endpoints.""" + client: AsyncOpenAI = self._get_entry().runtime_data + self.models = [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + + try: + await self._get_models() + except OpenAIError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [ + SelectOptionDict(value=model_id, label=model_id) for model_id in self.models + ] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/const.py b/homeassistant/components/ovhcloud_ai_endpoints/const.py new file mode 100644 index 00000000000..e2a2f5b4d7e --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/const.py @@ -0,0 +1,16 @@ +"""Constants for the OVHcloud AI Endpoints integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "ovhcloud_ai_endpoints" +LOGGER = logging.getLogger(__package__) + +BASE_URL = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/ovhcloud_ai_endpoints/conversation.py b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py new file mode 100644 index 00000000000..6872921632b --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py @@ -0,0 +1,76 @@ +"""Conversation support for OVHcloud AI Endpoints.""" + +from typing import Literal + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OVHcloudAIEndpointsConfigEntry +from .const import DOMAIN +from .entity import OVHcloudAIEndpointsEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OVHcloudAIEndpointsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [OVHcloudAIEndpointsConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OVHcloudAIEndpointsConversationEntity( + OVHcloudAIEndpointsEntity, conversation.ConversationEntity +): + """OVHcloud AI Endpoints conversation agent.""" + + _attr_name = None + + def __init__( + self, + entry: OVHcloudAIEndpointsConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/diagnostics.py b/homeassistant/components/ovhcloud_ai_endpoints/diagnostics.py new file mode 100644 index 00000000000..b91eef4d5dc --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for OVHcloud AI Endpoints.""" + +from typing import TYPE_CHECKING, Any + +from openai import __title__, __version__ + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY, CONF_PROMPT +from homeassistant.helpers import entity_registry as er + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from . import OVHcloudAIEndpointsConfigEntry + + +TO_REDACT = {CONF_API_KEY, CONF_PROMPT} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "client": f"{__title__}=={__version__}", + "title": entry.title, + "entry_id": entry.entry_id, + "entry_version": f"{entry.version}.{entry.minor_version}", + "state": entry.state.value, + "data": async_redact_data(entry.data, TO_REDACT), + "options": async_redact_data(entry.options, TO_REDACT), + "subentries": { + subentry.subentry_id: { + "title": subentry.title, + "subentry_type": subentry.subentry_type, + "data": async_redact_data(subentry.data, TO_REDACT), + } + for subentry in entry.subentries.values() + }, + "entities": { + entity_entry.entity_id: entity_entry.extended_dict + for entity_entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + }, + } diff --git a/homeassistant/components/ovhcloud_ai_endpoints/entity.py b/homeassistant/components/ovhcloud_ai_endpoints/entity.py new file mode 100644 index 00000000000..06a094fb758 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -0,0 +1,228 @@ +"""Base entity for OVHcloud AI Endpoints.""" + +from collections.abc import AsyncGenerator, Callable +import json +import re +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +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 homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.json import json_dumps + +from . import OVHcloudAIEndpointsConfigEntry +from .const import DOMAIN, LOGGER + +MAX_TOOL_ITERATIONS = 10 + +_THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +def _split_thinking(content: str | None) -> tuple[str | None, str | None]: + """Return (cleaned_content, thinking_content) extracted from ```` tags.""" + if not content: + return content, None + thinking_parts = [m.group(1).strip() for m in _THINK_PATTERN.finditer(content)] + if not thinking_parts: + return content, None + cleaned = _THINK_PATTERN.sub("", content).strip() or None + thinking = "\n\n".join(part for part in thinking_parts if part) or None + return cleaned, thinking + + +def _extract_thinking( + message: ChatCompletionMessage, +) -> tuple[str | None, str | None]: + """Return (cleaned_content, thinking_content) for an assistant message. + + Priority order: + 1. ``message.reasoning`` (OpenRouter, and vLLM >= 0.16.0 with a + ``reasoning_parser`` configured, following OpenAI's recommendation + for gpt-oss). + 2. ``message.reasoning_content`` (DeepSeek API, and vLLM < 0.16.0 + with a ``reasoning_parser`` configured). + 3. Inline ```` markup in ``message.content`` (any + reasoning model on vLLM without a ``reasoning_parser`` set). + """ + extras = message.model_extra or {} + for key in ("reasoning", "reasoning_content"): + value = extras.get(key) + if isinstance(value, str) and value.strip(): + return message.content, value.strip() + return _split_thinking(message.content) + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OVHcloud AI Endpoints message to a ChatLog format.""" + cleaned_content, thinking_content = _extract_thinking(message) + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": cleaned_content, + } + if thinking_content: + data["thinking_content"] = thinking_content + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class OVHcloudAIEndpointsEntity(Entity): + """Base entity for OVHcloud AI Endpoints.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: OVHcloudAIEndpointsConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None: + """Generate an answer for the chat log.""" + model_args: dict[str, Any] = { + "model": self.model, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json new file mode 100644 index 00000000000..f2393ec1ade --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "ovhcloud_ai_endpoints", + "name": "OVHcloud AI Endpoints", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@Crocmagnon"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["openai==2.21.0"] +} diff --git a/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml new file mode 100644 index 00000000000..cef29e0aee5 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: configuration is per-subentry; documented via subentry strings + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates stateless entities + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity per subentry + entity-translations: + status: exempt + comment: conversation entity name comes from subentry title + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, deleted with the subentry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ovhcloud_ai_endpoints/strings.json b/homeassistant/components/ovhcloud_ai_endpoints/strings.json new file mode 100644 index 00000000000..ad9c6638a99 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::ovhcloud_ai_endpoints::config::step::user::data_description::api_key%]" + }, + "description": "The OVHcloud AI Endpoints API key is no longer valid. Please enter a new one." + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OVHcloud AI Endpoints API key" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before configuring.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index b496f7ca92f..3b28655f620 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -1,7 +1,5 @@ """Support for OVO Energy.""" -from __future__ import annotations - import logging import aiohttp diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py index 53fc4f8eff6..1ceb04fdb5f 100644 --- a/homeassistant/components/ovo_energy/config_flow.py +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the OVO Energy integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/ovo_energy/coordinator.py b/homeassistant/components/ovo_energy/coordinator.py index 7b41de0b338..1c1ac2374f1 100644 --- a/homeassistant/components/ovo_energy/coordinator.py +++ b/homeassistant/components/ovo_energy/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the OVO Energy integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/ovo_energy/entity.py b/homeassistant/components/ovo_energy/entity.py index d3efc151b59..3e787303111 100644 --- a/homeassistant/components/ovo_energy/entity.py +++ b/homeassistant/components/ovo_energy/entity.py @@ -1,7 +1,5 @@ """Support for OVO Energy.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 32e7e5743f0..ad895438b40 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,7 +1,5 @@ """Support for OVO Energy sensors.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from datetime import datetime, timedelta diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 623e5e17b66..d1e0b6e6cb0 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -1,4 +1,5 @@ """Support for OwnTracks.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import defaultdict import json diff --git a/homeassistant/components/owntracks/const.py b/homeassistant/components/owntracks/const.py index c7caa201ca3..49864e5cce2 100644 --- a/homeassistant/components/owntracks/const.py +++ b/homeassistant/components/owntracks/const.py @@ -1,3 +1,12 @@ """Constants for OwnTracks.""" -DOMAIN = "owntracks" +from typing import Final + +DOMAIN: Final = "owntracks" + +ATTR_ADDRESS: Final = "address" +ATTR_BATTERY_STATUS: Final = "battery_status" +ATTR_COURSE: Final = "course" +ATTR_TID: Final = "tid" +ATTR_UPDATE_TIMESTAMP: Final = "update_timestamp" +ATTR_VELOCITY: Final = "velocity" diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 22762cb390d..0cd1a10c1ac 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -1,4 +1,5 @@ """Device tracker platform that adds support for OwnTracks over MQTT.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any @@ -20,8 +21,26 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util -from . import DOMAIN +from .const import ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, + DOMAIN, +) + +_RESTORED_OWNTRACKS_ATTRIBUTES: tuple[str, ...] = ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, +) async def async_setup_entry( @@ -140,12 +159,19 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): return attr = state.attributes + attributes = { + key: attr[key] for key in _RESTORED_OWNTRACKS_ATTRIBUTES if key in attr + } + if isinstance(update_timestamp := attributes.get(ATTR_UPDATE_TIMESTAMP), str): + attributes[ATTR_UPDATE_TIMESTAMP] = dt_util.parse_datetime(update_timestamp) + self._data = { "host_name": state.name, "gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), "gps_accuracy": attr.get(ATTR_GPS_ACCURACY), "battery": attr.get(ATTR_BATTERY_LEVEL), "source_type": attr.get(ATTR_SOURCE_TYPE), + "attributes": attributes, } @callback diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 93d079b783d..b59ec84749d 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -9,8 +9,16 @@ from nacl.secret import SecretBox from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import SourceType from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME -from homeassistant.util import decorator, slugify +from homeassistant.util import decorator, dt as dt_util, slugify +from .const import ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, +) from .helper import supports_encryption _LOGGER = logging.getLogger(__name__) @@ -71,20 +79,24 @@ def _parse_see_args(message, subscribe_topic): if "batt" in message: kwargs["battery"] = message["batt"] if "vel" in message: - kwargs["attributes"]["velocity"] = message["vel"] + kwargs["attributes"][ATTR_VELOCITY] = message["vel"] if "tid" in message: - kwargs["attributes"]["tid"] = message["tid"] + kwargs["attributes"][ATTR_TID] = message["tid"] if "addr" in message: - kwargs["attributes"]["address"] = message["addr"] + kwargs["attributes"][ATTR_ADDRESS] = message["addr"] if "cog" in message: - kwargs["attributes"]["course"] = message["cog"] + kwargs["attributes"][ATTR_COURSE] = message["cog"] if "bs" in message: - kwargs["attributes"]["battery_status"] = message["bs"] + kwargs["attributes"][ATTR_BATTERY_STATUS] = message["bs"] if "t" in message: if message["t"] in ("c", "u"): kwargs["source_type"] = SourceType.GPS if message["t"] == "b": kwargs["source_type"] = SourceType.BLUETOOTH_LE + if "tst" in message: + kwargs["attributes"][ATTR_UPDATE_TIMESTAMP] = dt_util.utc_from_timestamp( + message["tst"] + ) return dev_id, kwargs diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index e12c092453c..a0f7e33e170 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -1,7 +1,5 @@ """The P1 Monitor integration.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py index d562943698a..b529fedf024 100644 --- a/homeassistant/components/p1_monitor/config_flow.py +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -1,7 +1,5 @@ """Config flow for P1 Monitor integration.""" -from __future__ import annotations - from typing import Any from p1monitor import P1Monitor, P1MonitorError diff --git a/homeassistant/components/p1_monitor/const.py b/homeassistant/components/p1_monitor/const.py index 297a06a9629..c20d6f45559 100644 --- a/homeassistant/components/p1_monitor/const.py +++ b/homeassistant/components/p1_monitor/const.py @@ -1,7 +1,5 @@ """Constants for the P1 Monitor integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/p1_monitor/coordinator.py b/homeassistant/components/p1_monitor/coordinator.py index e62d10e5811..9eb08a0f281 100644 --- a/homeassistant/components/p1_monitor/coordinator.py +++ b/homeassistant/components/p1_monitor/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the P1 Monitor integration.""" -from __future__ import annotations - from typing import TypedDict from p1monitor import ( diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index ac670486e79..365ea79932b 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for P1 Monitor.""" -from __future__ import annotations - from dataclasses import asdict from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 15a8f510fd7..9be1ff9c3a3 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -1,7 +1,5 @@ """Support for P1 Monitor sensors.""" -from __future__ import annotations - from typing import Literal from homeassistant.components.sensor import ( diff --git a/homeassistant/components/paj_gps/__init__.py b/homeassistant/components/paj_gps/__init__.py new file mode 100644 index 00000000000..e3715eac010 --- /dev/null +++ b/homeassistant/components/paj_gps/__init__.py @@ -0,0 +1,28 @@ +"""Integration for PAJ GPS trackers.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import PajGpsConfigEntry, PajGpsCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER, Platform.SENSOR] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool: + """Set up platform from a ConfigEntry.""" + pajgps_coordinator = PajGpsCoordinator(hass, entry) + await pajgps_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = pajgps_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/paj_gps/config_flow.py b/homeassistant/components/paj_gps/config_flow.py new file mode 100644 index 00000000000..4c798981e7d --- /dev/null +++ b/homeassistant/components/paj_gps/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for PAJ GPS Tracker integration.""" + +import logging +from typing import Any + +from aiohttp import ClientError +from pajgps_api import PajGpsApi +from pajgps_api.models.auth import AuthResponse +from pajgps_api.pajgps_api_error import AuthenticationError, TokenRefreshError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +class PajGPSConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for PAJ GPS Tracker.""" + + async def _validate_credentials( + self, email: str, password: str + ) -> tuple[str | None, AuthResponse | None]: + """Attempt a real login with the given credentials. + + Returns (None, auth) on success, or (error_key, None) on failure. + """ + websession = async_get_clientsession(self.hass) + try: + api = PajGpsApi(email=email, password=password, websession=websession) + auth = await api.login() + except AuthenticationError, TokenRefreshError: + return "invalid_auth", None + except ClientError: + return "cannot_connect", None + except Exception: + _LOGGER.exception("Unexpected error validating PAJ GPS credentials") + return "unknown", None + + return None, auth + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + normalized_email = user_input[CONF_EMAIL].strip().lower() + user_input[CONF_EMAIL] = normalized_email + error, auth = await self._validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if error is None and auth is not None: + await self.async_set_unique_id(str(auth.userID)) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=normalized_email, data=user_input) + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/paj_gps/const.py b/homeassistant/components/paj_gps/const.py new file mode 100644 index 00000000000..00a30d877e8 --- /dev/null +++ b/homeassistant/components/paj_gps/const.py @@ -0,0 +1,4 @@ +"""Constants for the PajGPS integration.""" + +DOMAIN = "paj_gps" +UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/paj_gps/coordinator.py b/homeassistant/components/paj_gps/coordinator.py new file mode 100644 index 00000000000..93b9f7d0b10 --- /dev/null +++ b/homeassistant/components/paj_gps/coordinator.py @@ -0,0 +1,107 @@ +"""DataUpdateCoordinator for the PAJ GPS integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pajgps_api import PajGpsApi +from pajgps_api.models.device import Device +from pajgps_api.models.trackpoint import TrackPoint +from pajgps_api.pajgps_api_error import ( + AuthenticationError, + PajGpsApiError, + TokenRefreshError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type PajGpsConfigEntry = ConfigEntry[PajGpsCoordinator] + + +@dataclass +class PajGpsData: + """Snapshot of all PAJ GPS data for one coordinator tick.""" + + devices: dict[int, Device] + positions: dict[int, TrackPoint] + + +class PajGpsCoordinator(DataUpdateCoordinator[PajGpsData]): + """Coordinator for the PAJ GPS integration.""" + + config_entry: PajGpsConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: PajGpsConfigEntry, + ) -> None: + """Initialize the coordinator from config-entry data.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + config_entry=config_entry, + ) + + self._email: str = config_entry.data[CONF_EMAIL] + self._user_id: int | None = None + self.api = PajGpsApi( + email=self._email, + password=config_entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + @property + def email(self) -> str: + """Return the account email address for this coordinator.""" + return self._email + + @property + def user_id(self) -> int | None: + """Return the user ID obtained from the login response.""" + return self._user_id + + async def _async_setup(self) -> None: + """Perform initial and first data refresh.""" + try: + auth = await self.api.login() + self._user_id = auth.userID + except (AuthenticationError, TokenRefreshError) as exc: + raise ConfigEntryAuthFailed from exc + except Exception as exc: + raise ConfigEntryNotReady from exc + + async def _async_update_data(self) -> PajGpsData: + """Fetch device list and positions.""" + devices: dict[int, Device] = {} + try: + device_list = await self.api.get_devices() + devices = { + device.id: device for device in device_list if device.id is not None + } + except PajGpsApiError as exc: + raise UpdateFailed(f"Failed to fetch device list: {exc}") from exc + + device_ids = list(devices.keys()) + positions: dict[int, TrackPoint] = {} + if device_ids: + try: + track_points = await self.api.get_all_last_positions(device_ids) + except PajGpsApiError as exc: + raise UpdateFailed(f"Failed to fetch positions: {exc}") from exc + positions = { + tp.iddevice: tp for tp in track_points if tp.iddevice is not None + } + + return PajGpsData(devices=devices, positions=positions) diff --git a/homeassistant/components/paj_gps/device_tracker.py b/homeassistant/components/paj_gps/device_tracker.py new file mode 100644 index 00000000000..0a76d61b320 --- /dev/null +++ b/homeassistant/components/paj_gps/device_tracker.py @@ -0,0 +1,72 @@ +"""Platform for GPS device tracker integration. + +Reads position data from PajGpsCoordinator and exposes it as a TrackerEntity. +""" + +import logging + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PajGpsConfigEntry +from .coordinator import PajGpsCoordinator +from .entity import PajGpsEntity + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PajGpsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PAJ GPS tracker entities from a config entry.""" + coordinator = config_entry.runtime_data + + known_device_ids: set[int] = set() + + @callback + def _async_add_new_devices() -> None: + """Add entities for any device IDs not yet tracked.""" + current_ids = set(coordinator.data.devices.keys()) + new_ids = current_ids - known_device_ids + if new_ids: + sorted_new_ids = sorted(new_ids) + async_add_entities( + PajGPSDeviceTracker(coordinator, device_id) + for device_id in sorted_new_ids + ) + known_device_ids.update(sorted_new_ids) + + _async_add_new_devices() + + if not known_device_ids: + _LOGGER.warning("No PAJ GPS devices found to add as trackers") + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + + +class PajGPSDeviceTracker(PajGpsEntity, TrackerEntity): + """Tracker entity that reads position from the coordinator snapshot.""" + + _attr_name = None + _attr_icon = "mdi:map-marker" + + def __init__(self, pajgps_coordinator: PajGpsCoordinator, device_id: int) -> None: + """Initialize the GPS position tracker entity.""" + super().__init__(pajgps_coordinator, device_id) + self._attr_unique_id = f"{pajgps_coordinator.user_id}_{device_id}" + + @property + def latitude(self) -> float | None: + """Return the latitude of the device.""" + tp = self.coordinator.data.positions.get(self._device_id) + return float(tp.lat) if tp and tp.lat is not None else None + + @property + def longitude(self) -> float | None: + """Return the longitude of the device.""" + tp = self.coordinator.data.positions.get(self._device_id) + return float(tp.lng) if tp and tp.lng is not None else None diff --git a/homeassistant/components/paj_gps/entity.py b/homeassistant/components/paj_gps/entity.py new file mode 100644 index 00000000000..a307f875df1 --- /dev/null +++ b/homeassistant/components/paj_gps/entity.py @@ -0,0 +1,39 @@ +"""Base entity class for the PAJ GPS integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import Device, PajGpsCoordinator + + +class PajGpsEntity(CoordinatorEntity[PajGpsCoordinator]): + """Base class for all PAJ GPS entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: PajGpsCoordinator, device_id: int) -> None: + """Initialize the entity and build DeviceInfo.""" + super().__init__(coordinator) + self._device_id = device_id + + model = None + device_models = self.device.device_models + if device_models: + model = device_models[0].model + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.user_id}_{device_id}")}, + name=self.device.name or f"PAJ GPS {device_id}", + manufacturer="PAJ GPS", + model=model, + ) + + @property + def available(self) -> bool: + """Return False when the device has been removed from the account.""" + return super().available and self._device_id in self.coordinator.data.devices + + @property + def device(self) -> Device: + """Return the device from coordinator data.""" + return self.coordinator.data.devices[self._device_id] diff --git a/homeassistant/components/paj_gps/manifest.json b/homeassistant/components/paj_gps/manifest.json new file mode 100644 index 00000000000..872572b8c01 --- /dev/null +++ b/homeassistant/components/paj_gps/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "paj_gps", + "name": "PAJ GPS", + "codeowners": ["@skipperro"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/paj_gps", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pajgps-api==0.4.0"] +} diff --git a/homeassistant/components/paj_gps/quality_scale.yaml b/homeassistant/components/paj_gps/quality_scale.yaml new file mode 100644 index 00000000000..c090f944a59 --- /dev/null +++ b/homeassistant/components/paj_gps/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Add setup/entity lifecycle tests (setup, unload, coordinator refresh, and device_tracker entity creation/availability) before marking test-coverage as done. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not use device discovery. The API returns all devices directly. + discovery: + status: exempt + comment: | + This integration does not use device discovery. The API returns all devices directly. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + There are no entities that would be considered less popular or noisy. All entities are enabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/paj_gps/sensor.py b/homeassistant/components/paj_gps/sensor.py new file mode 100644 index 00000000000..ef3b5f74678 --- /dev/null +++ b/homeassistant/components/paj_gps/sensor.py @@ -0,0 +1,95 @@ +"""Platform for PAJ GPS sensor integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pajgps_api.models.trackpoint import TrackPoint + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfSpeed +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PajGpsConfigEntry +from .coordinator import PajGpsCoordinator +from .entity import PajGpsEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PajGpsSensorEntityDescription(SensorEntityDescription): + """Describes a PAJ GPS sensor entity.""" + + value_fn: Callable[[TrackPoint], int | None] + + +SENSOR_DESCRIPTIONS: tuple[PajGpsSensorEntityDescription, ...] = ( + PajGpsSensorEntityDescription( + key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda tp: tp.speed, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PajGpsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PAJ GPS sensor entities from a config entry.""" + coordinator = config_entry.runtime_data + + known_device_ids: set[int] = set() + + @callback + def _async_add_new_devices() -> None: + """Add entities for any device IDs not yet tracked.""" + current_ids = set(coordinator.data.devices.keys()) + new_ids = current_ids - known_device_ids + if new_ids: + sorted_new_ids = sorted(new_ids) + async_add_entities( + PajGpsSensor(coordinator, device_id, description) + for device_id in sorted_new_ids + for description in SENSOR_DESCRIPTIONS + ) + known_device_ids.update(sorted_new_ids) + + _async_add_new_devices() + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + + +class PajGpsSensor(PajGpsEntity, SensorEntity): + """Sensor entity that reads data from the coordinator snapshot.""" + + entity_description: PajGpsSensorEntityDescription + + def __init__( + self, + coordinator: PajGpsCoordinator, + device_id: int, + description: PajGpsSensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{coordinator.user_id}_{device_id}_{description.key}" + + @property + def native_value(self) -> int | None: + """Return the sensor value from the latest trackpoint.""" + tp = self.coordinator.data.positions.get(self._device_id) + if tp is None: + return None + return self.entity_description.value_fn(tp) diff --git a/homeassistant/components/paj_gps/strings.json b/homeassistant/components/paj_gps/strings.json new file mode 100644 index 00000000000..1909a56310f --- /dev/null +++ b/homeassistant/components/paj_gps/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Email used to log in to Finder Portal (finder-portal.com)", + "password": "Password used to log in to Finder Portal (finder-portal.com)" + }, + "description": "Set credentials for your PAJ GPS account (finder-portal.com).", + "title": "PAJ GPS configuration" + } + } + } +} diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index a698cdcd8b7..56fb0613c31 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -1,7 +1,5 @@ """The Palazzetti integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py index 319a1174542..3db4ca45c9c 100644 --- a/homeassistant/components/palazzetti/button.py +++ b/homeassistant/components/palazzetti/button.py @@ -1,7 +1,5 @@ """Support for Palazzetti buttons.""" -from __future__ import annotations - from pypalazzetti.exceptions import CommunicationError from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py index e386ffc7833..3a2bc3d4679 100644 --- a/homeassistant/components/palazzetti/diagnostics.py +++ b/homeassistant/components/palazzetti/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Palazzetti.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index 63c1ed16f0c..679fcd04148 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -1,7 +1,5 @@ """Number platform for Palazzetti settings.""" -from __future__ import annotations - from pypalazzetti.exceptions import CommunicationError, ValidationError from pypalazzetti.fan import FanType diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index d4ef278705c..4c1fb890709 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -65,7 +65,7 @@ rules: entity-device-class: done entity-disabled-by-default: todo entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index 57d5ca861a2..c90692a4c3f 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -20,7 +20,10 @@ from .entity import PalazzettiEntity @dataclass(frozen=True, kw_only=True) class PropertySensorEntityDescription(SensorEntityDescription): - """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property.""" + """Describes a Palazzetti sensor entity. + + Read from a `PalazzettiClient` property. + """ client_property: str property_map: dict[StateType, str] | None = None diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index 0547e5f1b23..85467467799 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -1,7 +1,5 @@ """Support for Panasonic Blu-ray players.""" -from __future__ import annotations - from datetime import timedelta from panacotta import PanasonicBD diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 1478b02095e..4f010e55cff 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -121,7 +121,11 @@ async def async_unload_entry( class Remote: - """The Remote class. It stores the TV properties and the remote control connection itself.""" + """The Remote class. + + It stores the TV properties and the remote control + connection itself. + """ def __init__( self, diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index b00fee513a6..e4fee2e9ca4 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -93,6 +93,8 @@ class PanasonicVieraConfigFlow(ConfigFlow, domain=DOMAIN): if self._data[CONF_HOST] is not None else "", ): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional( CONF_NAME, default=self._data[CONF_NAME] @@ -168,5 +170,7 @@ class PanasonicVieraConfigFlow(ConfigFlow, domain=DOMAIN): self._data[CONF_PORT] = self._data.get(CONF_PORT, DEFAULT_PORT) self._data[CONF_ON_ACTION] = self._data.get(CONF_ON_ACTION) + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=home-assistant-unique-id-ip-based await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/panasonic_viera/const.py b/homeassistant/components/panasonic_viera/const.py index f76c01e396b..a48657ed56f 100644 --- a/homeassistant/components/panasonic_viera/const.py +++ b/homeassistant/components/panasonic_viera/const.py @@ -15,7 +15,6 @@ ATTR_REMOTE = "remote" ATTR_DEVICE_INFO = "device_info" ATTR_FRIENDLY_NAME = "friendlyName" -ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL_NUMBER = "modelNumber" ATTR_UDN = "UDN" diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index b2c5bdd1a5d..ddb0b8409c9 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -1,7 +1,5 @@ """Media player support for Panasonic Viera TV.""" -from __future__ import annotations - import logging from typing import Any @@ -17,7 +15,7 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -25,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PanasonicVieraConfigEntry from .const import ( ATTR_DEVICE_INFO, - ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, ATTR_UDN, DEFAULT_MANUFACTURER, diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index 59090e46ef7..ea3148824dc 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -1,12 +1,10 @@ """Remote control support for Panasonic Viera TV.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any from homeassistant.components.remote import RemoteEntity -from homeassistant.const import CONF_NAME, STATE_ON +from homeassistant.const import ATTR_MANUFACTURER, CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PanasonicVieraConfigEntry, Remote from .const import ( ATTR_DEVICE_INFO, - ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, ATTR_UDN, DEFAULT_MANUFACTURER, diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index db9c35a7608..17aeffed046 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -1,7 +1,5 @@ """Register a custom front end panel.""" -from __future__ import annotations - import logging import voluptuous as vol @@ -10,7 +8,6 @@ from homeassistant.components import frontend from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -71,7 +68,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass async def async_register_panel( hass: HomeAssistant, # The url to serve the panel @@ -93,7 +89,8 @@ async def async_register_panel( config: ConfigType | None = None, # If your panel should only be shown to admin users require_admin: bool = False, - # If your panel is used to configure an integration, needs the domain of the integration + # If your panel is used to configure an integration, + # needs the domain of the integration config_panel_domain: str | None = None, ) -> None: """Register a new custom panel.""" diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index da990be7173..46156fca3f3 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -42,7 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> try: await status_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as err: - # Catch the error so the integration doesn't fail just because status coordinator fails. + # Catch the error so the integration doesn't fail + # just because status coordinator fails. LOGGER.warning("Could not initialize status coordinator: %s", err) entry.runtime_data = PaperlessData( diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py index 9a8ea05d168..2c400bdd901 100644 --- a/homeassistant/components/paperless_ngx/config_flow.py +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Paperless-ngx integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py index 270fd8063dc..cc20afa26ee 100644 --- a/homeassistant/components/paperless_ngx/coordinator.py +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -1,7 +1,5 @@ """Paperless-ngx Status coordinator.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/paperless_ngx/diagnostics.py b/homeassistant/components/paperless_ngx/diagnostics.py index 0382a448f9e..d26a2938d05 100644 --- a/homeassistant/components/paperless_ngx/diagnostics.py +++ b/homeassistant/components/paperless_ngx/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Paperless-ngx.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/paperless_ngx/entity.py b/homeassistant/components/paperless_ngx/entity.py index 59cd13c5209..9f84f03b75e 100644 --- a/homeassistant/components/paperless_ngx/entity.py +++ b/homeassistant/components/paperless_ngx/entity.py @@ -1,7 +1,5 @@ """Paperless-ngx base entity.""" -from __future__ import annotations - from homeassistant.components.sensor import EntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index 77e8240c3e7..91b9c026de7 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Paperless-ngx.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -132,7 +130,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -140,7 +138,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.database is not None and data.database.status is not None - and data.database.status != StatusType.UNKNOWN + and data.database.status is not StatusType.UNKNOWN ) else None ) @@ -153,7 +151,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -161,7 +159,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.tasks is not None and data.tasks.index_status is not None - and data.tasks.index_status != StatusType.UNKNOWN + and data.tasks.index_status is not StatusType.UNKNOWN ) else None ) @@ -174,7 +172,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -182,7 +180,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.tasks is not None and data.tasks.classifier_status is not None - and data.tasks.classifier_status != StatusType.UNKNOWN + and data.tasks.classifier_status is not StatusType.UNKNOWN ) else None ) @@ -195,7 +193,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -203,7 +201,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.tasks is not None and data.tasks.celery_status is not None - and data.tasks.celery_status != StatusType.UNKNOWN + and data.tasks.celery_status is not StatusType.UNKNOWN ) else None ) @@ -215,7 +213,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -223,7 +221,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.tasks is not None and data.tasks.redis_status is not None - and data.tasks.redis_status != StatusType.UNKNOWN + and data.tasks.redis_status is not StatusType.UNKNOWN ) else None ) @@ -235,7 +233,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[ - item.value.lower() for item in StatusType if item != StatusType.UNKNOWN + item.value.lower() for item in StatusType if item is not StatusType.UNKNOWN ], value_fn=( lambda data: ( @@ -243,7 +241,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( if ( data.tasks is not None and data.tasks.sanity_check_status is not None - and data.tasks.sanity_check_status != StatusType.UNKNOWN + and data.tasks.sanity_check_status is not StatusType.UNKNOWN ) else None ) diff --git a/homeassistant/components/paperless_ngx/update.py b/homeassistant/components/paperless_ngx/update.py index 0b273b6f3c1..af97edda4a7 100644 --- a/homeassistant/components/paperless_ngx/update.py +++ b/homeassistant/components/paperless_ngx/update.py @@ -1,7 +1,5 @@ """Update platform for Paperless-ngx.""" -from __future__ import annotations - from datetime import timedelta from pypaperless.exceptions import PaperlessConnectionError diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index bf1b3ef7e66..105e2b90cc5 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -1,7 +1,5 @@ """Integration for Peblar EV chargers.""" -from __future__ import annotations - import asyncio from aiohttp import CookieJar @@ -50,10 +48,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo system_information = await peblar.system_information() api = await peblar.rest_api(enable=True, access_mode=AccessMode.READ_WRITE) except PeblarConnectionError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady("Could not connect to Peblar charger") from err except PeblarAuthenticationError as err: raise ConfigEntryAuthFailed from err except PeblarError as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady( "Unknown error occurred while connecting to Peblar charger" ) from err diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py index 8834a2ba2a0..85c7b0aa979 100644 --- a/homeassistant/components/peblar/binary_sensor.py +++ b/homeassistant/components/peblar/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Peblar binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py index 8c60c8d84d3..c85be81bd09 100644 --- a/homeassistant/components/peblar/button.py +++ b/homeassistant/components/peblar/button.py @@ -1,7 +1,5 @@ """Support for Peblar button.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index b9b42cd6ca5..02594a09e53 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Peblar integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -165,6 +163,8 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): await peblar.login(password=user_input[CONF_PASSWORD]) except PeblarAuthenticationError: errors[CONF_PASSWORD] = "invalid_auth" + except PeblarConnectionError: + errors["base"] = "cannot_connect" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index 58fcc9b85da..8473c5ec481 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -1,7 +1,5 @@ """Constants for the Peblar integration.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 36708b207c5..14e4bcd1c50 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Peblar EV chargers.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/peblar/diagnostics.py b/homeassistant/components/peblar/diagnostics.py index a8c7423f79a..046be829253 100644 --- a/homeassistant/components/peblar/diagnostics.py +++ b/homeassistant/components/peblar/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Peblar.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant @@ -13,14 +11,17 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: PeblarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data return { - "system_information": entry.runtime_data.system_information.to_dict(), - "user_configuration": entry.runtime_data.user_configuration_coordinator.data.to_dict(), - "ev": entry.runtime_data.data_coordinator.data.ev.to_dict(), - "meter": entry.runtime_data.data_coordinator.data.meter.to_dict(), - "system": entry.runtime_data.data_coordinator.data.system.to_dict(), + "system_information": runtime_data.system_information.to_dict(), + "user_configuration": ( + runtime_data.user_configuration_coordinator.data.to_dict() + ), + "ev": runtime_data.data_coordinator.data.ev.to_dict(), + "meter": runtime_data.data_coordinator.data.meter.to_dict(), + "system": runtime_data.data_coordinator.data.system.to_dict(), "versions": { - "available": entry.runtime_data.version_coordinator.data.available.to_dict(), - "current": entry.runtime_data.version_coordinator.data.current.to_dict(), + "available": (runtime_data.version_coordinator.data.available.to_dict()), + "current": (runtime_data.version_coordinator.data.current.to_dict()), }, } diff --git a/homeassistant/components/peblar/entity.py b/homeassistant/components/peblar/entity.py index ecfd3e8232b..95b516721aa 100644 --- a/homeassistant/components/peblar/entity.py +++ b/homeassistant/components/peblar/entity.py @@ -1,7 +1,5 @@ """Base entity for the Peblar integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import CONF_HOST diff --git a/homeassistant/components/peblar/helpers.py b/homeassistant/components/peblar/helpers.py index cc1eb228803..9f9dc53e352 100644 --- a/homeassistant/components/peblar/helpers.py +++ b/homeassistant/components/peblar/helpers.py @@ -1,7 +1,5 @@ """Helpers for Peblar.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index fdb2e7ad7d8..ce1f281cafe 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.4.0"], + "requirements": ["peblar==0.5.1"], "zeroconf": [{ "name": "pblr-*", "type": "_http._tcp.local." }] } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index bff1bb26db4..6f89f7fd5ec 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -1,7 +1,5 @@ """Support for Peblar numbers.""" -from __future__ import annotations - from homeassistant.components.number import ( NumberDeviceClass, NumberEntityDescription, diff --git a/homeassistant/components/peblar/quality_scale.yaml b/homeassistant/components/peblar/quality_scale.yaml index 91f9bb7af55..a67344cf7b4 100644 --- a/homeassistant/components/peblar/quality_scale.yaml +++ b/homeassistant/components/peblar/quality_scale.yaml @@ -61,10 +61,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: exempt - comment: | - The coordinator needs translation when the update failed. + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py index 17503951ccd..f5c37f3d186 100644 --- a/homeassistant/components/peblar/select.py +++ b/homeassistant/components/peblar/select.py @@ -1,7 +1,5 @@ """Support for Peblar selects.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py index 81476eef9aa..131d0fdacca 100644 --- a/homeassistant/components/peblar/sensor.py +++ b/homeassistant/components/peblar/sensor.py @@ -1,7 +1,5 @@ """Support for Peblar sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index 3c2c6793b30..31d7dd7fc5f 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -1,7 +1,5 @@ """Support for Peblar selects.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 88966916069..3ba368745e5 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -1,7 +1,5 @@ """Support for Peblar updates.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index e36de2d6fa9..6caa4099e6f 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -1,7 +1,5 @@ """The PECO Outage Counter integration.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index 3b80cc81ab1..cf2c018de15 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor for PECO outage counter.""" -from __future__ import annotations - from typing import Final from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index a5e8f4451fd..7feac398840 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,7 +1,5 @@ """Config flow for PECO Outage Counter integration.""" -from __future__ import annotations - import logging from typing import Any @@ -103,6 +101,9 @@ class PecoConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=self.meter_error["phone_number"]) return self.async_create_entry( - title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}", + title=( + f"{self.meter_data[CONF_COUNTY].capitalize()}" + f" - {self.meter_data[CONF_PHONE_NUMBER]}" + ), data=self.meter_data, ) diff --git a/homeassistant/components/peco/coordinator.py b/homeassistant/components/peco/coordinator.py index 9c42cddc5dd..97e894d96f6 100644 --- a/homeassistant/components/peco/coordinator.py +++ b/homeassistant/components/peco/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the PECO Outage Counter integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index b7e0b5e733a..1b7077601f3 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -1,7 +1,5 @@ """Sensor component for PECO outage counter.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index c8388f40704..29b8bd99dde 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -1,7 +1,5 @@ """The PEGELONLINE component.""" -from __future__ import annotations - import logging from aiopegelonline import PegelOnline diff --git a/homeassistant/components/pegel_online/config_flow.py b/homeassistant/components/pegel_online/config_flow.py index 440d1fbddf9..f9ad6bc3fc5 100644 --- a/homeassistant/components/pegel_online/config_flow.py +++ b/homeassistant/components/pegel_online/config_flow.py @@ -1,7 +1,5 @@ """Config flow for PEGELONLINE.""" -from __future__ import annotations - from typing import Any from aiopegelonline import CONNECT_ERRORS, PegelOnline diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py index e3b4a166cb4..bc9307501ca 100644 --- a/homeassistant/components/pegel_online/diagnostics.py +++ b/homeassistant/components/pegel_online/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for pegel_online.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pegel_online/entity.py b/homeassistant/components/pegel_online/entity.py index d69b0e13667..cb1cf71184f 100644 --- a/homeassistant/components/pegel_online/entity.py +++ b/homeassistant/components/pegel_online/entity.py @@ -1,7 +1,5 @@ """The PEGELONLINE base entity.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 30d4edfb041..0bfb31c1f66 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -1,7 +1,5 @@ """PEGELONLINE sensor entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index ef988f41da1..169689e960d 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -1,7 +1,5 @@ """Pencom relay control.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 683dd6aa7ea..ff3127d75a8 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -1,7 +1,5 @@ """The MyPermobil integration.""" -from __future__ import annotations - import logging from mypermobil import MyPermobil, MyPermobilClientException diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py index 2d167d29524..4d85f91d0ee 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 11c89f7e398..73372ab1415 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MyPermobil integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index fc58407a5f9..d9ebda20a42 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -148,7 +146,8 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), PermobilSensorEntityDescription( - # Largest number of adjustemnts in a single 24h period, monotonically increasing, never resets + # Largest number of adjustments in a single 24h period, + # monotonically increasing, never resets value_fn=lambda data: data.records[RECORDS_SEATING[0]], available_fn=lambda data: RECORDS_SEATING[0] in data.records, key="record_adjustments", @@ -157,7 +156,8 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), PermobilSensorEntityDescription( - # Record of largest distance travelled in a day, monotonically increasing, never resets + # Record of largest distance travelled in a day, + # monotonically increasing, never resets value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, key="record_distance", diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 2871f4b575a..352d32dc8bd 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -1,7 +1,5 @@ """Support for displaying persistent notifications.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from datetime import datetime from enum import StrEnum @@ -19,7 +17,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from homeassistant.util.uuid import random_uuid_hex @@ -75,7 +72,6 @@ def async_register_callback( ) -@bind_hass def create( hass: HomeAssistant, message: str, @@ -86,14 +82,12 @@ def create( hass.add_job(async_create, hass, message, title, notification_id) -@bind_hass def dismiss(hass: HomeAssistant, notification_id: str) -> None: """Remove a notification.""" hass.add_job(async_dismiss, hass, notification_id) @callback -@bind_hass def async_create( hass: HomeAssistant, message: str, @@ -127,7 +121,6 @@ def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notific @callback -@bind_hass def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: """Remove a notification.""" notifications = _async_get_or_create_notifications(hass) diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 8e0808f9879..fb9a92b5ca7 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -1,7 +1,5 @@ """Offer persistent_notifications triggered automation rules.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 2fc04785812..c1957502231 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,7 +1,5 @@ """Support for tracking people.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, Self @@ -13,8 +11,10 @@ from homeassistant.components import persistent_notification, websocket_api from homeassistant.components.device_tracker import ( ATTR_IN_ZONES, ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, + TrackingType, ) from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.const import ( @@ -52,7 +52,6 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.loader import bind_hass from .const import DOMAIN @@ -93,7 +92,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass async def async_create_person( hass: HomeAssistant, name: str, @@ -111,7 +109,6 @@ async def async_create_person( ) -@bind_hass async def async_add_user_device_tracker( hass: HomeAssistant, user_id: str, device_tracker_entity_id: str ) -> None: @@ -465,7 +462,7 @@ class Person( """Register device trackers.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._parse_source_state(state, state) + self._parse_source_state(state) if self.hass.is_running: # Update person now if hass is already running. @@ -515,39 +512,32 @@ class Person( @callback def _update_state(self) -> None: """Update the state.""" - latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None + latest_connected = latest_legacy_home = latest_not_home = latest_gps = None for entity_id in self._config[CONF_DEVICE_TRACKERS]: state = self.hass.states.get(entity_id) if not state or state.state in IGNORE_STATES: continue - if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS: + if state.attributes.get( + ATTR_TRACKING_TYPE + ) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES): + latest_connected = _get_latest(latest_connected, state) + elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS: latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: - latest_non_gps_home = _get_latest(latest_non_gps_home, state) + # Legacy scanner without tracking type + latest_legacy_home = _get_latest(latest_legacy_home, state) else: latest_not_home = _get_latest(latest_not_home, state) - if latest_non_gps_home: - latest = latest_non_gps_home - if ( - latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None - and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None - and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) - ): - coordinates = home_zone - else: - coordinates = latest_non_gps_home - elif latest_gps: - latest = latest_gps - coordinates = latest_gps - else: - latest = latest_not_home - coordinates = latest_not_home + # A scanner (e.g. a router or beacon) that reports + # being in a zone is the most reliable presence signal, so it + # takes precedence over everything else. + latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home - if latest and coordinates: - self._parse_source_state(latest, coordinates) + if latest: + self._parse_source_state(latest) else: self._attr_state = None self._source = None @@ -560,17 +550,32 @@ class Person( self.async_write_ha_state() @callback - def _parse_source_state(self, state: State, coordinates: State) -> None: + def _parse_source_state(self, state: State) -> None: """Parse source state and set person attributes. This is a device tracker state or the restored person state. """ self._attr_state = state.state self._source = state.entity_id - self._latitude = coordinates.attributes.get(ATTR_LATITUDE) - self._longitude = coordinates.attributes.get(ATTR_LONGITUDE) - self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY) - self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, []) + self._latitude = state.attributes.get(ATTR_LATITUDE) + self._longitude = state.attributes.get(ATTR_LONGITUDE) + self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) + self._in_zones = state.attributes.get(ATTR_IN_ZONES, []) + + # A legacy scanner (one that doesn't report in_zones) reports "home" + # without coordinates. Use the home zone's coordinates for backwards + # compatibility with legacy zone conditions and triggers. Modern + # trackers report in_zones and keep their own (possibly absent) + # coordinates. + if ( + ATTR_IN_ZONES not in state.attributes + and state.state == STATE_HOME + and self._latitude is None + and self._longitude is None + and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None + ): + self._latitude = home_zone.attributes.get(ATTR_LATITUDE) + self._longitude = home_zone.attributes.get(ATTR_LONGITUDE) @callback def _update_extra_state_attributes(self) -> None: diff --git a/homeassistant/components/person/conditions.yaml b/homeassistant/components/person/conditions.yaml deleted file mode 100644 index 3e5e9c1aa52..00000000000 --- a/homeassistant/components/person/conditions.yaml +++ /dev/null @@ -1,17 +0,0 @@ -.condition_common: &condition_common - target: - entity: - domain: person - fields: - behavior: - required: true - default: any - selector: - select: - translation_key: condition_behavior - options: - - all - - any - -is_home: *condition_common -is_not_home: *condition_common diff --git a/homeassistant/components/person/icons.json b/homeassistant/components/person/icons.json index cd1d80aba38..f645d9c2090 100644 --- a/homeassistant/components/person/icons.json +++ b/homeassistant/components/person/icons.json @@ -1,12 +1,4 @@ { - "conditions": { - "is_home": { - "condition": "mdi:account" - }, - "is_not_home": { - "condition": "mdi:account-arrow-right" - } - }, "entity_component": { "_": { "default": "mdi:account", @@ -19,13 +11,5 @@ "reload": { "service": "mdi:reload" } - }, - "triggers": { - "entered_home": { - "trigger": "mdi:account-arrow-left" - }, - "left_home": { - "trigger": "mdi:account-arrow-right" - } } } diff --git a/homeassistant/components/person/significant_change.py b/homeassistant/components/person/significant_change.py index c6720bcc4ff..dae7417fd72 100644 --- a/homeassistant/components/person/significant_change.py +++ b/homeassistant/components/person/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Person state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index af211e373a7..385bad47cb4 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,28 +1,4 @@ { - "common": { - "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more persons are home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::condition_behavior_name%]" - } - }, - "name": "Person is home" - }, - "is_not_home": { - "description": "Tests if one or more persons are not home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::condition_behavior_name%]" - } - }, - "name": "Person is not home" - } - }, "entity_component": { "_": { "name": "[%key:component::person::title%]", @@ -49,46 +25,11 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "reload": { "description": "Reloads persons from the YAML-configuration.", "name": "Reload persons" } }, - "title": "Person", - "triggers": { - "entered_home": { - "description": "Triggers when one or more persons enter home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::trigger_behavior_name%]" - } - }, - "name": "Entered home" - }, - "left_home": { - "description": "Triggers when one or more persons leave home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::trigger_behavior_name%]" - } - }, - "name": "Left home" - } - } + "title": "Person" } diff --git a/homeassistant/components/person/trigger.py b/homeassistant/components/person/trigger.py deleted file mode 100644 index 0ca46a6cd43..00000000000 --- a/homeassistant/components/person/trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Provides triggers for persons.""" - -from homeassistant.const import STATE_HOME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import ( - Trigger, - make_entity_origin_state_trigger, - make_entity_target_state_trigger, -) - -from .const import DOMAIN - -TRIGGERS: dict[str, type[Trigger]] = { - "entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME), - "left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME), -} - - -async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: - """Return the triggers for persons.""" - return TRIGGERS diff --git a/homeassistant/components/person/triggers.yaml b/homeassistant/components/person/triggers.yaml deleted file mode 100644 index 31208321b54..00000000000 --- a/homeassistant/components/person/triggers.yaml +++ /dev/null @@ -1,18 +0,0 @@ -.trigger_common: &trigger_common - target: - entity: - domain: person - fields: - behavior: - required: true - default: any - selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior - -entered_home: *trigger_common -left_home: *trigger_common diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index a490f476f83..0b0648d3c21 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -1,7 +1,5 @@ """PG LAB Electronics integration.""" -from __future__ import annotations - from pypglab.mqtt import ( Client as PyPGLabMqttClient, Sub_State as PyPGLabSubState, diff --git a/homeassistant/components/pglab/config_flow.py b/homeassistant/components/pglab/config_flow.py index 606de757622..5eef5b4799a 100644 --- a/homeassistant/components/pglab/config_flow.py +++ b/homeassistant/components/pglab/config_flow.py @@ -1,7 +1,5 @@ """Config flow for PG LAB Electronics integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components import mqtt diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py index b703f368eb1..f364ff744fe 100644 --- a/homeassistant/components/pglab/coordinator.py +++ b/homeassistant/components/pglab/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for PG LAB Electronics.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/pglab/cover.py b/homeassistant/components/pglab/cover.py index 8385fd95ffa..a79befc5879 100644 --- a/homeassistant/components/pglab/cover.py +++ b/homeassistant/components/pglab/cover.py @@ -1,7 +1,5 @@ """PG LAB Electronics Cover.""" -from __future__ import annotations - from typing import Any from pypglab.device import Device as PyPGLabDevice diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index c83ea4466fa..8c2a313f9b7 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -1,7 +1,5 @@ """Discovery PG LAB Electronics devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import json @@ -117,7 +115,10 @@ async def create_discover_device_info( @dataclass class PGLabDiscovery: - """Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config.""" + """Discover a PGLab device. + + Uses the MQTT topic format pglab/discovery/[device]/config. + """ def __init__(self) -> None: """Initialize the discovery class.""" @@ -148,7 +149,8 @@ class PGLabDiscovery: "Unexpected discovery payload format, id key not present" ) - # Do a sanity check: the id must match the discovery topic /pglab/discovery/[id]/config + # Do a sanity check: the id must match the discovery + # topic /pglab/discovery/[id]/config topic = msg.topic if not topic.endswith(f"{payload[device_id]}/config"): raise PGLabDiscoveryError("Unexpected discovery topic format") @@ -203,11 +205,14 @@ class PGLabDiscovery: except PGLabDiscoveryError as err: LOGGER.warning("Can't create PGLabDiscovery instance(%s) ", str(err)) - # For some reason it's not possible to create the device with the discovery message, - # be sure that any previous device with the same topic is now destroyed. + # For some reason it's not possible to create the + # device with the discovery message, be sure that + # any previous device with the same topic is now + # destroyed. device_id = get_device_id_from_discovery_topic(msg.topic) - # If there is a valid topic device_id clean everything relative to the device. + # If there is a valid topic device_id clean + # everything relative to the device. if device_id: self.__clean_discovered_device(hass, device_id) @@ -235,15 +240,18 @@ class PGLabDiscovery: if discovery_info.hash == pglab_device.hash: # Best case, there is nothing to do. - # The device is still in the same configuration. Same name, same shutters, same relay etc. + # The device is still in the same configuration. + # Same name, same shutters, same relay etc. return LOGGER.warning( - "Changed internal configuration of device(%s). Rebuilding all entities", + "Changed internal configuration of device(%s)." + " Rebuilding all entities", pglab_device.id, ) - # Something has changed, all previous entities must be destroyed and re-created. + # Something has changed, all previous entities + # must be destroyed and re-created. self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index c0a02f4f835..c659a748c5f 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -1,7 +1,5 @@ """Entity for PG LAB Electronics.""" -from __future__ import annotations - from pypglab.device import Device as PyPGLabDevice from pypglab.entity import Entity as PyPGLabEntity @@ -75,7 +73,10 @@ class PGLabEntity(PGLabBaseEntity): self._entity: PyPGLabEntity = pglab_entity async def async_added_to_hass(self) -> None: - """Subscribe pypglab entity to be updated from mqtt when pypglab entity internal state change.""" + """Subscribe pypglab entity to MQTT updates. + + Triggered when pypglab entity internal state changes. + """ # set the callback to be called when pypglab entity state is changed self._entity.set_on_state_callback(self.state_updated) diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py index ce19ec3a21a..29272dcb589 100644 --- a/homeassistant/components/pglab/sensor.py +++ b/homeassistant/components/pglab/sensor.py @@ -1,7 +1,5 @@ """Sensor for PG LAB Electronics.""" -from __future__ import annotations - from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py index 76b177e84c4..b5e92d8c507 100644 --- a/homeassistant/components/pglab/switch.py +++ b/homeassistant/components/pglab/switch.py @@ -1,7 +1,5 @@ """Switch for PG LAB Electronics.""" -from __future__ import annotations - from typing import Any from pypglab.device import Device as PyPGLabDevice diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 9ff101915b8..ce3905754c3 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -1,7 +1,5 @@ """The Philips TV integration.""" -from __future__ import annotations - import logging from haphilipsjs import PhilipsTV diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 3667d37dc48..7f92e2a3da3 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -1,7 +1,5 @@ """Philips TV binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from haphilipsjs import PhilipsTV @@ -64,7 +62,11 @@ def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: class PhilipsTVBinarySensorEntityRecordingType(PhilipsJsEntity, BinarySensorEntity): - """A Philips TV binary sensor class, which allows multiple entities given by a BinarySensorEntityDescription.""" + """A Philips TV binary sensor class. + + Allows multiple entities given by a + BinarySensorEntityDescription. + """ entity_description: PhilipsTVBinarySensorEntityDescription @@ -87,7 +89,11 @@ class PhilipsTVBinarySensorEntityRecordingType(PhilipsJsEntity, BinarySensorEnti @callback def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator and set is_on true if one specified value is available within given entry of list.""" + """Handle updated data from the coordinator. + + Set is_on true if one specified value is available within + given entry of list. + """ self._attr_is_on = _check_for_recording_entry( self.coordinator.api, "RecordingType", diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 779452b284b..4b1147686ee 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Philips TV integration.""" -from __future__ import annotations - from collections.abc import Mapping import platform from typing import Any diff --git a/homeassistant/components/philips_js/coordinator.py b/homeassistant/components/philips_js/coordinator.py index 9e92efa83c1..519ca2e5d66 100644 --- a/homeassistant/components/philips_js/coordinator.py +++ b/homeassistant/components/philips_js/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Philips TV integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -87,8 +85,10 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): def _notify_wanted(self): """Return if the notify feature should be active. - We only run it when TV is considered fully on. When powerstate is in standby, the TV - will go in low power states and seemingly break the http server in odd ways. + We only run it when TV is considered fully on. + When powerstate is in standby, the TV will go in low + power states and seemingly break the http server in + odd ways. """ return ( self.api.on diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py index 4c2ec9b95db..e5723c08eb7 100644 --- a/homeassistant/components/philips_js/device_trigger.py +++ b/homeassistant/components/philips_js/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for control of device.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/philips_js/diagnostics.py b/homeassistant/components/philips_js/diagnostics.py index 99b27b2c85a..60033918510 100644 --- a/homeassistant/components/philips_js/diagnostics.py +++ b/homeassistant/components/philips_js/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Philips JS.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/philips_js/entity.py b/homeassistant/components/philips_js/entity.py index 8d8090318f9..8aff1b872f7 100644 --- a/homeassistant/components/philips_js/entity.py +++ b/homeassistant/components/philips_js/entity.py @@ -1,7 +1,5 @@ """Base Philips js entity.""" -from __future__ import annotations - from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import PhilipsTVDataUpdateCoordinator diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 112ee0cd2ca..7c49ebf15f0 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -1,7 +1,5 @@ """Component to integrate ambilight for TVs exposing the Joint Space API.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast @@ -96,7 +94,11 @@ class AmbilightEffect: if self.mode == EFFECT_MODE: return f"{EFFECT_MODE}{EFFECT_PARTITION}{self.style}" if self.mode == EFFECT_EXPERT: - return f"{self.style}{EFFECT_PARTITION}{self.algorithm}{EFFECT_PARTITION}{EFFECT_EXPERT}" + return ( + f"{self.style}{EFFECT_PARTITION}" + f"{self.algorithm}{EFFECT_PARTITION}" + f"{EFFECT_EXPERT}" + ) return f"{self.style}{EFFECT_PARTITION}{self.algorithm}" @@ -247,7 +249,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): *_average_pixels(self._tv.ambilight_cached) ) self._attr_hs_color = hsv_h, hsv_s - self._attr_brightness = hsv_v * 255.0 / 100.0 + self._attr_brightness = round(hsv_v * 255.0 / 100.0) else: self._attr_hs_color = None self._attr_brightness = None @@ -291,7 +293,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): "color": { "hue": round(hs_color[0] * 255.0 / 360.0), "saturation": round(hs_color[1] * 255.0 / 100.0), - "brightness": round(brightness), + "brightness": brightness, }, "colorDelta": { "hue": 0, diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index dda1f3cca05..81797e8550f 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -1,7 +1,5 @@ """Media Player component to integrate TVs exposing the Joint Space API.""" -from __future__ import annotations - from typing import Any from haphilipsjs import ConnectionFailure diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index b026b33a857..3203df4c28b 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -1,7 +1,5 @@ """Remote control support for Apple TV.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index 45963432665..7e27525edd1 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -1,7 +1,5 @@ """Philips TV menu switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 0595b01f143..65f21d414a8 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -1,7 +1,5 @@ """The pi_hole component.""" -from __future__ import annotations - import logging from typing import Any, Literal @@ -108,7 +106,10 @@ def api_by_version( version: int, password: str | None = None, ) -> HoleV5 | HoleV6: - """Create a pi-hole API object by API version number. Once V5 is deprecated this function can be removed.""" + """Create a pi-hole API object by API version number. + + Once V5 is deprecated this function can be removed. + """ if password is None: password = entry.get(CONF_API_KEY, "") @@ -133,21 +134,26 @@ def api_by_version( async def determine_api_version( hass: HomeAssistant, entry: dict[str, Any] ) -> Literal[5, 6]: - """Determine the API version of the Pi-hole instance without requiring authentication. + """Determine the API version of the Pi-hole instance. - Neither API v5 or v6 provides an endpoint to check the version without authentication. - Version 6 provides other enddpoints that do not require authentication, so we can use those to determine the version - version 5 returns an empty list in response to unauthenticated requests. - Because we are using endpoints that are not designed for this purpose, we should log liberally to help with debugging. + Neither API v5 or v6 provides an endpoint to check the + version without authentication. Version 6 provides other + endpoints that do not require authentication, so we can + use those to determine the version. Version 5 returns an + empty list in response to unauthenticated requests. + Because we are using endpoints that are not designed for + this purpose, we should log liberally to help with + debugging. """ - holeV6 = api_by_version(hass, entry, 6, password="wrong_password") + hole_v6 = api_by_version(hass, entry, 6, password="wrong_password") try: - await holeV6.authenticate() + await hole_v6.authenticate() except HoleConnectionError as err: _LOGGER.error( - "Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API", - holeV6.base_url, + "Unexpected error connecting to Pi-hole v6 API" + " at %s: %s. Trying version 5 API", + hole_v6.base_url, err, ) # Ideally python-hole would raise a specific exception for authentication failures @@ -155,43 +161,48 @@ async def determine_api_version( if str(ex_v6) == "Authentication failed: Invalid password": _LOGGER.debug( "Success connecting to Pi-hole at %s without auth, API version is : %s", - holeV6.base_url, + hole_v6.base_url, 6, ) return 6 _LOGGER.debug( - "Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6 + "Connection to %s failed: %s, trying API version 5", hole_v6.base_url, ex_v6 ) else: - # It seems that occasionally the auth can succeed unexpectedly when there is a valid session + # It seems that occasionally the auth can succeed + # unexpectedly when there is a valid session _LOGGER.warning( - "Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug", - holeV6.base_url, + "Authenticated with %s through v6 API, but" + " succeeded with an incorrect password." + " This is a known bug", + hole_v6.base_url, ) return 6 - holeV5 = api_by_version(hass, entry, 5, password="wrong_token") + hole_v5 = api_by_version(hass, entry, 5, password="wrong_token") try: - await holeV5.get_data() + await hole_v5.get_data() except HoleConnectionError as err: _LOGGER.error( - "Failed to connect to Pi-hole v5 API at %s: %s", holeV5.base_url, err + "Failed to connect to Pi-hole v5 API at %s: %s", hole_v5.base_url, err ) else: # V5 API returns [] to unauthenticated requests - if not holeV5.data: + if not hole_v5.data: _LOGGER.debug( - "Response '[]' from API without auth, pihole API version 5 probably detected at %s", - holeV5.base_url, + "Response '[]' from API without auth," + " pihole API version 5 probably" + " detected at %s", + hole_v5.base_url, ) return 5 _LOGGER.debug( "Unexpected response from Pi-hole API at %s: %s", - holeV5.base_url, - str(holeV5.data), + hole_v5.base_url, + str(hole_v5.data), ) _LOGGER.debug( "Could not determine pi-hole API version at: %s", - holeV6.base_url, + hole_v6.base_url, ) raise HoleError("Could not determine Pi-hole API version") diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index eee059b035c..739a0644d05 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -1,7 +1,5 @@ """Support for getting status from a Pi-hole system.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index 327ce32847e..230fea14d82 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Pi-hole integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -78,6 +76,8 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_PORT, default=user_input.get(CONF_PORT, 80) ): vol.Coerce(int), + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, @@ -166,13 +166,16 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} else: _LOGGER.debug( - "Success connecting to, but necessarily authenticating with, pihole, API version is: %s", + "Success connecting to, but necessarily" + " authenticating with, pihole," + " API version is: %s", 5, ) # the v5 API returns an empty list to unauthenticated requests. if not isinstance(pi_hole.data, dict): _LOGGER.debug( - "API version %s returned %s, '[]' is expected for unauthenticated requests", + "API version %s returned %s, '[]' is expected" + " for unauthenticated requests", 5, pi_hole.data, ) diff --git a/homeassistant/components/pi_hole/coordinator.py b/homeassistant/components/pi_hole/coordinator.py index 36cf64f345a..b25ffa02e12 100644 --- a/homeassistant/components/pi_hole/coordinator.py +++ b/homeassistant/components/pi_hole/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Pi-hole integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -72,18 +70,27 @@ class PiHoleUpdateCoordinator(DataUpdateCoordinator[None]): and "/admin/api" in hint ): _LOGGER.warning( - "Pi-hole API v6 returned an error that is expected when using v5 endpoints please re-configure your authentication" + "Pi-hole API v6 returned an error that " + "is expected when using v5 endpoints. " + "Please reconfigure your authentication" ) raise ConfigEntryAuthFailed except HoleError as err: if str(err) == "Authentication failed: Invalid password": raise ConfigEntryAuthFailed( - f"Pi-hole {self._name} at host {self._host}, reported an invalid password" + f"Pi-hole {self._name} at host" + f" {self._host}, reported an invalid" + " password" ) from err raise UpdateFailed( - f"Pi-hole {self._name} at host {self._host}, update failed with HoleError: {err}" + f"Pi-hole {self._name} at host" + f" {self._host}, update failed with" + f" HoleError: {err}" ) from err if not isinstance(self._api.data, dict): raise ConfigEntryAuthFailed( - f"Pi-hole {self._name} at host {self._host}, returned an unexpected response: {self._api.data}, assuming authentication failed" + f"Pi-hole {self._name} at host" + f" {self._host}, returned an unexpected" + f" response: {self._api.data}," + " assuming authentication failed" ) diff --git a/homeassistant/components/pi_hole/diagnostics.py b/homeassistant/components/pi_hole/diagnostics.py index 4b7e7d50cab..a5424a6ec5e 100644 --- a/homeassistant/components/pi_hole/diagnostics.py +++ b/homeassistant/components/pi_hole/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the Pi-hole integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/pi_hole/entity.py b/homeassistant/components/pi_hole/entity.py index c1e4b2cc3b5..09c071c3b7d 100644 --- a/homeassistant/components/pi_hole/entity.py +++ b/homeassistant/components/pi_hole/entity.py @@ -1,7 +1,5 @@ """The pi_hole component.""" -from __future__ import annotations - from hole import Hole from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index c77e5f7ed80..3420938a0dc 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -1,7 +1,5 @@ """Support for getting statistical data from a Pi-hole system.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index c643a69fed3..3a5de92ce33 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -1,7 +1,5 @@ """Support for turning on and off Pi-hole system.""" -from __future__ import annotations - import logging from typing import Any @@ -77,6 +75,7 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): try: await self.api.enable() await self.async_update() + # pylint: disable-next=home-assistant-action-swallowed-exception except HoleError as err: _LOGGER.error("Unable to enable Pi-hole: %s", err) @@ -98,5 +97,6 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): try: await self.api.disable(duration_seconds) await self.async_update() + # pylint: disable-next=home-assistant-action-swallowed-exception except HoleError as err: _LOGGER.error("Unable to disable Pi-hole: %s", err) diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 3bf9d3694f1..acc12627b48 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -1,7 +1,5 @@ """Support for update entities of a Pi-hole system.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index a60086173a8..23bbcd856b0 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -1,13 +1,15 @@ """Config flow for Picnic integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any from python_picnic_api2 import PicnicAPI -from python_picnic_api2.session import PicnicAuthError +from python_picnic_api2.session import ( + Picnic2FAError, + Picnic2FARequired, + PicnicAuthError, +) import requests import voluptuous as vol @@ -18,13 +20,19 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import COUNTRY_CODES, DOMAIN +from .const import COUNTRY_CODES, DOMAIN, TWO_FA_CHANNELS _LOGGER = logging.getLogger(__name__) +CONF_2FA_CODE = "two_fa_code" +CONF_2FA_CHANNEL = "two_fa_channel" + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -35,45 +43,23 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) - -class PicnicHub: - """Hub class to test user authentication.""" - - @staticmethod - def authenticate(username, password, country_code) -> tuple[str, dict]: - """Test if we can authenticate with the Picnic API.""" - picnic = PicnicAPI(username, password, country_code) - return picnic.session.auth_token, picnic.get_user() - - -async def validate_input(hass: HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - hub = PicnicHub() - - try: - auth_token, user_data = await hass.async_add_executor_job( - hub.authenticate, - data[CONF_USERNAME], - data[CONF_PASSWORD], - data[CONF_COUNTRY_CODE], - ) - except requests.exceptions.ConnectionError as error: - raise CannotConnect from error - except PicnicAuthError as error: - raise InvalidAuth from error - - # Return the validation result - address = ( - f"{user_data['address']['street']} {user_data['address']['house_number']}" - f"{user_data['address']['house_number_ext']}" - ) - return auth_token, { - "title": address, - "unique_id": user_data["user_id"], +STEP_2FA_CHANNEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_2FA_CHANNEL, default=TWO_FA_CHANNELS[0]): SelectSelector( + SelectSelectorConfig( + options=TWO_FA_CHANNELS, + mode=SelectSelectorMode.LIST, + translation_key="two_fa_channel", + ) + ), } +) + +STEP_2FA_SCHEMA = vol.Schema( + { + vol.Required(CONF_2FA_CODE): str, + } +) class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): @@ -81,6 +67,11 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self._picnic: PicnicAPI | None = None + self._user_input: dict[str, Any] = {} + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -90,7 +81,7 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the authentication step, this is the generic step for both `step_user` and `step_reauth`.""" + """Handle the authentication step.""" if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA @@ -99,43 +90,124 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: - auth_token, info = await validate_input(self.hass, user_input) - except CannotConnect: + await self.hass.async_add_executor_job( + self._start_login, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_COUNTRY_CODE], + ) + except Picnic2FARequired: + self._user_input = user_input + return await self.async_step_2fa_channel() + except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" - except InvalidAuth: + except PicnicAuthError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { - CONF_ACCESS_TOKEN: auth_token, - CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], - } - existing_entry = await self.async_set_unique_id(info["unique_id"]) - - # Abort if we're adding a new config and the unique id is already in use, else create the entry - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Picnic", data=data) - - # In case of re-auth, only continue if an exiting account exists with the same unique id - if existing_entry: - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - # Set the error because the account is different - errors["base"] = "different_account" + return await self._async_finish(user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + def _start_login(self, username: str, password: str, country_code: str) -> None: + self._picnic = PicnicAPI(country_code=country_code) + self._picnic.login(username, password) -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" + async def async_step_2fa_channel( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user pick the 2FA delivery channel.""" + assert self._picnic is not None + if user_input is None: + return self.async_show_form( + step_id="2fa_channel", data_schema=STEP_2FA_CHANNEL_SCHEMA + ) -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + errors = {} + channel = user_input[CONF_2FA_CHANNEL].upper() + try: + await self.hass.async_add_executor_job( + self._picnic.generate_2fa_code, channel + ) + except requests.exceptions.ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Failed to request 2FA code via %s", channel) + errors["base"] = "unknown" + else: + return await self.async_step_2fa() + + return self.async_show_form( + step_id="2fa_channel", + data_schema=STEP_2FA_CHANNEL_SCHEMA, + errors=errors, + ) + + async def async_step_2fa( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the 2FA verification step.""" + assert self._picnic is not None + + if user_input is None: + return self.async_show_form(step_id="2fa", data_schema=STEP_2FA_SCHEMA) + + errors = {} + + try: + await self.hass.async_add_executor_job( + self._picnic.verify_2fa_code, user_input[CONF_2FA_CODE] + ) + except Picnic2FAError: + errors["base"] = "invalid_2fa_code" + except requests.exceptions.ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during 2FA verification") + errors["base"] = "unknown" + else: + return await self._async_finish(self._user_input) + + return self.async_show_form( + step_id="2fa", data_schema=STEP_2FA_SCHEMA, errors=errors + ) + + async def _async_finish( + self, + user_input: dict[str, Any], + ) -> ConfigFlowResult: + """Finalize the config entry after successful authentication.""" + assert self._picnic is not None + + auth_token = self._picnic.session.auth_token + user_data = await self.hass.async_add_executor_job(self._picnic.get_user) + + data = { + CONF_ACCESS_TOKEN: auth_token, + CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], + } + existing_entry = await self.async_set_unique_id(user_data["user_id"]) + + # Abort if we're adding a new config and the unique id + # is already in use, else create the entry + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Picnic", data=data) + + # In case of re-auth, only continue if an exiting + # account exists with the same unique id + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "different_account"}, + ) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 9cde3dea03d..0c7336263d3 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,7 +1,5 @@ """Constants for the Picnic integration.""" -from __future__ import annotations - DOMAIN = "picnic" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -12,6 +10,7 @@ ATTR_AMOUNT = "amount" ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" COUNTRY_CODES = ["NL", "DE", "BE", "FR"] +TWO_FA_CHANNELS = ["sms", "email"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" CART_DATA = "cart_data" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 55827ee1e84..35e87ca2ecf 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -1,7 +1,5 @@ """Coordinator to fetch data from the Picnic API.""" -from __future__ import annotations - import asyncio from contextlib import suppress import copy @@ -67,7 +65,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): return data def fetch_data(self): - """Fetch the data from the Picnic API and return a flat dict with only needed sensor data.""" + """Fetch data from the Picnic API. + + Return a flat dict with only needed sensor data. + """ # Fetch from the API and pre-process the data if not (cart := self.picnic_api_client.get_cart()): raise UpdateFailed("API response doesn't contain expected data.") @@ -117,7 +118,8 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): # Determine the last order and return an empty dict if there is none try: - # Filter on status CURRENT and select the last on the list which is the first one to be delivered + # Filter on status CURRENT and select the last + # on the list which is the first one to be delivered # Make a deepcopy because some references are local next_deliveries = list( filter(lambda d: d["status"] == "CURRENT", deliveries) @@ -127,7 +129,8 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): ) last_order = copy.deepcopy(deliveries[0]) if deliveries else {} except KeyError, TypeError: - # A KeyError or TypeError indicate that the response contains unexpected data + # A KeyError or TypeError indicate that the + # response contains unexpected data return {}, {} # Get the next order's position details if there is an undelivered order @@ -139,7 +142,8 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): next_delivery["delivery_id"] ) - # Determine the ETA, if available, the one from the delivery position API is more precise + # Determine the ETA, if available, the one from the + # delivery position API is more precise # but, it's only available shortly before the actual delivery. next_delivery["eta"] = delivery_position.get( "eta_window", next_delivery.get("eta2", {}) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index a6b1f3ae8c4..6196220c847 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -1,7 +1,5 @@ """Definition of Picnic sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -66,7 +64,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_CART_TOTAL_PRICE, translation_key=SENSOR_CART_TOTAL_PRICE, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=True, data_type="cart_data", value_fn=lambda cart: cart.get("total_price", 0) / 100, ), @@ -74,7 +71,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_SELECTED_SLOT_START, translation_key=SENSOR_SELECTED_SLOT_START, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="slot_data", value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_start"))), ), @@ -82,7 +78,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_SELECTED_SLOT_END, translation_key=SENSOR_SELECTED_SLOT_END, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="slot_data", value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("window_end"))), ), @@ -90,7 +85,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, translation_key=SENSOR_SELECTED_SLOT_MAX_ORDER_TIME, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="slot_data", value_fn=lambda slot: dt_util.parse_datetime(str(slot.get("cut_off_time"))), ), @@ -98,7 +92,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, translation_key=SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=True, data_type="slot_data", value_fn=lambda slot: ( slot["minimum_order_value"] / 100 @@ -134,7 +127,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, translation_key=SENSOR_LAST_ORDER_MAX_ORDER_TIME, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="last_order_data", value_fn=lambda last_order: dt_util.parse_datetime( str(last_order.get("slot", {}).get("cut_off_time")) @@ -144,7 +136,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_LAST_ORDER_DELIVERY_TIME, translation_key=SENSOR_LAST_ORDER_DELIVERY_TIME, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="last_order_data", value_fn=lambda last_order: dt_util.parse_datetime( str(last_order.get("delivery_time", {}).get("start")) @@ -161,7 +152,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_NEXT_DELIVERY_ETA_START, translation_key=SENSOR_NEXT_DELIVERY_ETA_START, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="next_delivery_data", value_fn=lambda next_delivery: dt_util.parse_datetime( str(next_delivery.get("eta", {}).get("start")) @@ -171,7 +161,6 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( key=SENSOR_NEXT_DELIVERY_ETA_END, translation_key=SENSOR_NEXT_DELIVERY_ETA_END, device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, data_type="next_delivery_data", value_fn=lambda next_delivery: dt_util.parse_datetime( str(next_delivery.get("eta", {}).get("end")) diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index bdc33950204..00f509132fe 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -1,7 +1,5 @@ """Services for the Picnic integration.""" -from __future__ import annotations - from typing import cast from python_picnic_api2 import PicnicAPI @@ -56,7 +54,7 @@ async def get_api_client(hass: HomeAssistant, config_entry_id: str) -> PicnicAPI entry: PicnicConfigEntry | None = hass.config_entries.async_get_entry( config_entry_id ) - if entry is None or entry.state != ConfigEntryState.LOADED: + if entry is None or entry.state is not ConfigEntryState.LOADED: raise ValueError(f"Config entry with id {config_entry_id} not found!") return entry.runtime_data.picnic_api_client diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index db56d032b1d..e2cea9b4d4d 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -7,10 +7,25 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "different_account": "Account should be the same as used for setting up the integration", + "invalid_2fa_code": "The verification code is incorrect or has expired.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "2fa": { + "data": { + "two_fa_code": "Verification code" + }, + "description": "A verification code has been sent to you via your selected channel.", + "title": "Two-factor authentication" + }, + "2fa_channel": { + "data": { + "two_fa_channel": "Channel" + }, + "description": "A second factor is required to complete the login. Select the channel through which you want to receive your second factor.", + "title": "Two-factor authentication" + }, "user": { "data": { "country_code": "Country code", @@ -77,6 +92,14 @@ } } }, + "selector": { + "two_fa_channel": { + "options": { + "email": "Email", + "sms": "Text message (SMS)" + } + } + }, "services": { "add_product": { "description": "Adds a product to the cart based on a search string or product ID. The search string and product ID are exclusive.", diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index aee818a8fe6..7f70193fcb8 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -1,7 +1,5 @@ """Definition of Picnic shopping cart.""" -from __future__ import annotations - import logging from typing import cast @@ -69,7 +67,8 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): TodoItem( summary=f"{article['name']} ({article['unit_quantity']})", uid=f"{item['id']}-{article['id']}", - status=TodoItemStatus.NEEDS_ACTION, # We set 'NEEDS_ACTION' so they count as state + # We set 'NEEDS_ACTION' so they count as state + status=TodoItemStatus.NEEDS_ACTION, ) for item in self.coordinator.data["cart_data"]["items"] for article in item["items"] diff --git a/homeassistant/components/picotts/__init__.py b/homeassistant/components/picotts/__init__.py index c8e47e7f22a..03898bbac0b 100644 --- a/homeassistant/components/picotts/__init__.py +++ b/homeassistant/components/picotts/__init__.py @@ -1,7 +1,5 @@ """The Pico TTS integration.""" -from __future__ import annotations - import shutil from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/picotts/config_flow.py b/homeassistant/components/picotts/config_flow.py index eb9684bdcb8..88335d7e005 100644 --- a/homeassistant/components/picotts/config_flow.py +++ b/homeassistant/components/picotts/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Pico TTS integration.""" -from __future__ import annotations - import shutil from typing import Any diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 2e5d7ffe5e8..2cb83de6688 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -1,7 +1,5 @@ """Component to create an interface to a Pilight daemon.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import functools @@ -100,6 +98,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: pilight_client.send_code(message_data) + # pylint: disable-next=home-assistant-action-swallowed-exception except OSError: _LOGGER.error("Pilight send failed for %s", str(message_data)) diff --git a/homeassistant/components/pilight/binary_sensor.py b/homeassistant/components/pilight/binary_sensor.py index 93a631e498e..7ba0a06fe1d 100644 --- a/homeassistant/components/pilight/binary_sensor.py +++ b/homeassistant/components/pilight/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Pilight binary sensors.""" -from __future__ import annotations - import datetime from typing import Any diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index 3a647dad093..4e7e8b66c19 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -1,7 +1,5 @@ """Support for switching devices via Pilight to on and off.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json index da07c4ee645..e2764026dd8 100644 --- a/homeassistant/components/pilight/manifest.json +++ b/homeassistant/components/pilight/manifest.json @@ -2,6 +2,7 @@ "domain": "pilight", "name": "Pilight", "codeowners": [], + "disabled": "Pilight relies on setuptools.pkg_resources, which is no longer available in setuptools 82.0.0 and later.", "documentation": "https://www.home-assistant.io/integrations/pilight", "iot_class": "local_push", "loggers": ["pilight"], diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 60ded6aad87..c1ac3d161d0 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -1,7 +1,5 @@ """Support for Pilight sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index 9b812075e17..b8965831809 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -1,7 +1,5 @@ """Support for switching devices via Pilight to on and off.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 1383e4c035a..1153d496b92 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -1,7 +1,5 @@ """The ping component.""" -from __future__ import annotations - import logging from icmplib import SocketPermissionError, async_ping @@ -71,6 +69,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool entry.runtime_data = coordinator + # Ensure the device exists before forwarding to platforms, so that the + # device tracker (which looks up the device on init) is not racing the + # binary sensor / sensor platforms that create the device via DeviceInfo. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Ping", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 35bf2707694..a2227d12a56 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -1,7 +1,5 @@ """Tracks the latency of a host by sending ICMP echo requests (ping).""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index b496d6ac4b5..0dd33b9863d 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ping (ICMP) integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index afb7de4dce3..2db1edd1f26 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the ping integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 118bddbae74..5c708b83909 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,7 +1,5 @@ """Tracks devices by sending a ICMP echo request (ping).""" -from __future__ import annotations - from datetime import datetime, timedelta from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json index e2f1a3c47b9..4c16b15154e 100644 --- a/homeassistant/components/ping/manifest.json +++ b/homeassistant/components/ping/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["icmplib"], "quality_scale": "internal", - "requirements": ["icmplib==3.0"] + "requirements": ["icmplib==3.0.4"] } diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 8da2e171cef..0eeb5f07a9b 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -1,7 +1,5 @@ """Support for Pioneer Network Receivers.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/pjlink/__init__.py b/homeassistant/components/pjlink/__init__.py index 79a1f8f76fb..b7f51755646 100644 --- a/homeassistant/components/pjlink/__init__.py +++ b/homeassistant/components/pjlink/__init__.py @@ -1,7 +1,5 @@ """The PJLink integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pjlink/config_flow.py b/homeassistant/components/pjlink/config_flow.py index c2cf722e598..975d4c9b699 100644 --- a/homeassistant/components/pjlink/config_flow.py +++ b/homeassistant/components/pjlink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the PJLink integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index dea2f801db9..c4e64ccdf4f 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,7 +1,5 @@ """Support for controlling projector via the PJLink protocol.""" -from __future__ import annotations - from typing import Any from pypjlink import MUTE_AUDIO, Projector diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 490bc094aaa..68f68ad90b7 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -23,7 +23,6 @@ import voluptuous as vol from homeassistant.components import webhook from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, @@ -39,21 +38,15 @@ from .const import ( CONF_DEVICE_NAME, CONF_DEVICE_TYPE, CONF_USE_WEBHOOK, - COORDINATOR, DEFAULT_SCAN_INTERVAL, - DEVICE, - DEVICE_ID, - DEVICE_NAME, - DEVICE_TYPE, DOMAIN, PLATFORMS, - SENSOR_DATA, - UNDO_UPDATE_LISTENER, ) -from .coordinator import PlaatoCoordinator +from .coordinator import PlaatoConfigEntry, PlaatoCoordinator, PlaatoData _LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["webhook"] SENSOR_UPDATE = f"{DOMAIN}_sensor_update" @@ -82,15 +75,15 @@ WEBHOOK_SCHEMA = vol.Schema( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PlaatoConfigEntry) -> bool: """Configure based on config entry.""" - hass.data.setdefault(DOMAIN, {}) - if entry.data[CONF_USE_WEBHOOK]: async_setup_webhook(hass, entry) else: await async_setup_coordinator(hass, entry) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + await hass.config_entries.async_forward_entry_setups( entry, [platform for platform in PLATFORMS if entry.options.get(platform, True)] ) @@ -99,19 +92,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback -def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry): +def async_setup_webhook(hass: HomeAssistant, entry: PlaatoConfigEntry) -> None: """Init webhook based on config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] device_name = entry.data[CONF_DEVICE_NAME] - _set_entry_data(entry, hass) + entry.runtime_data = PlaatoData( + coordinator=None, + device_name=entry.data[CONF_DEVICE_NAME], + device_type=entry.data[CONF_DEVICE_TYPE], + device_id=None, + ) webhook.async_register( hass, DOMAIN, f"{DOMAIN}.{device_name}", webhook_id, handle_webhook ) -async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_coordinator( + hass: HomeAssistant, entry: PlaatoConfigEntry +) -> None: """Init auth token based on config entry.""" auth_token = entry.data[CONF_TOKEN] device_type = entry.data[CONF_DEVICE_TYPE] @@ -126,62 +126,44 @@ async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry): ) await coordinator.async_config_entry_first_refresh() - _set_entry_data(entry, hass, coordinator, auth_token) + entry.runtime_data = PlaatoData( + coordinator=coordinator, + device_name=entry.data[CONF_DEVICE_NAME], + device_type=entry.data[CONF_DEVICE_TYPE], + device_id=auth_token, + ) for platform in PLATFORMS: if entry.options.get(platform, True): coordinator.platforms.append(platform) -def _set_entry_data(entry, hass, coordinator=None, device_id=None): - device = { - DEVICE_NAME: entry.data[CONF_DEVICE_NAME], - DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], - DEVICE_ID: device_id, - } - - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - DEVICE: device, - SENSOR_DATA: None, - UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), - } - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PlaatoConfigEntry) -> bool: """Unload a config entry.""" - use_webhook = entry.data[CONF_USE_WEBHOOK] - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if use_webhook: + if entry.data[CONF_USE_WEBHOOK]: return await async_unload_webhook(hass, entry) return await async_unload_coordinator(hass, entry) -async def async_unload_webhook(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_webhook(hass: HomeAssistant, entry: PlaatoConfigEntry) -> bool: """Unload webhook based entry.""" if entry.data[CONF_WEBHOOK_ID] is not None: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - return await async_unload_platforms(hass, entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_unload_coordinator(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_coordinator( + hass: HomeAssistant, entry: PlaatoConfigEntry +) -> bool: """Unload auth token based entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - return await async_unload_platforms(hass, entry, coordinator.platforms) + coordinator = entry.runtime_data.coordinator + return await hass.config_entries.async_unload_platforms( + entry, coordinator.platforms if coordinator else PLATFORMS + ) -async def async_unload_platforms(hass: HomeAssistant, entry: ConfigEntry, platforms): - """Unload platforms.""" - unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) - - return unloaded - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: PlaatoConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index de574738d8d..1c9411e278e 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -1,24 +1,22 @@ """Support for Plaato Airlock sensors.""" -from __future__ import annotations - from pyplaato.plaato import PlaatoKeg from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN +from .const import CONF_USE_WEBHOOK +from .coordinator import PlaatoConfigEntry, PlaatoCoordinator, PlaatoData from .entity import PlaatoEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlaatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" @@ -26,10 +24,12 @@ async def async_setup_entry( if config_entry.data[CONF_USE_WEBHOOK]: return - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + entry_data = config_entry.runtime_data + coordinator = entry_data.coordinator + assert coordinator is not None async_add_entities( PlaatoBinarySensor( - hass.data[DOMAIN][config_entry.entry_id], + entry_data, sensor_type, coordinator, ) @@ -40,7 +40,12 @@ async def async_setup_entry( class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity): """Representation of a Binary Sensor.""" - def __init__(self, data, sensor_type, coordinator=None) -> None: + def __init__( + self, + data: PlaatoData, + sensor_type: str, + coordinator: PlaatoCoordinator | None = None, + ) -> None: """Initialize plaato binary sensor.""" super().__init__(data, sensor_type, coordinator) if sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index ee345563cd6..ef930b49241 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Plaato.""" -from __future__ import annotations - from typing import Any from pyplaato.plaato import PlaatoDeviceType @@ -61,6 +59,8 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_DEVICE_NAME, default=self._init_info.get(CONF_DEVICE_NAME, None), @@ -210,6 +210,8 @@ class PlaatoOptionsFlowHandler(OptionsFlow): step_id="user", data_schema=vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index 73382765bfe..33ef69b8c45 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -19,13 +19,7 @@ PLACEHOLDER_DEVICE_TYPE = "device_type" PLACEHOLDER_DEVICE_NAME = "device_name" DOCS_URL = "https://www.home-assistant.io/integrations/plaato/" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -SENSOR_DATA = "sensor_data" -COORDINATOR = "coordinator" -DEVICE = "device" -DEVICE_NAME = "device_name" -DEVICE_TYPE = "device_type" -DEVICE_ID = "device_id" -UNDO_UPDATE_LISTENER = "undo_update_listener" + DEFAULT_SCAN_INTERVAL = 5 MIN_UPDATE_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index 74ff8566729..22b64d9a310 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -1,8 +1,10 @@ """Coordinator for Plaato devices.""" +from dataclasses import dataclass, field from datetime import timedelta import logging +from pyplaato.models.device import PlaatoDevice from pyplaato.plaato import Plaato, PlaatoDeviceType from homeassistant.config_entries import ConfigEntry @@ -16,15 +18,29 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class PlaatoCoordinator(DataUpdateCoordinator): +@dataclass +class PlaatoData: + """Runtime data for the Plaato integration.""" + + coordinator: PlaatoCoordinator | None + device_name: str + device_type: str + device_id: str | None + sensor_data: PlaatoDevice | None = field(default=None) + + +type PlaatoConfigEntry = ConfigEntry[PlaatoData] + + +class PlaatoCoordinator(DataUpdateCoordinator[PlaatoDevice]): """Class to manage fetching data from the API.""" - config_entry: ConfigEntry + config_entry: PlaatoConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlaatoConfigEntry, auth_token: str, device_type: PlaatoDeviceType, update_interval: timedelta, @@ -42,7 +58,7 @@ class PlaatoCoordinator(DataUpdateCoordinator): update_interval=update_interval, ) - async def _async_update_data(self): + async def _async_update_data(self) -> PlaatoDevice: """Update data via library.""" return await self.api.get_data( session=aiohttp_client.async_get_clientsession(self.hass), diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 9cc63a38a64..9b008028c6e 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -1,6 +1,6 @@ """PlaatoEntity class.""" -from typing import Any +from typing import Any, cast from pyplaato.models.device import PlaatoDevice @@ -8,16 +8,8 @@ from homeassistant.helpers import entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - DEVICE, - DEVICE_ID, - DEVICE_NAME, - DEVICE_TYPE, - DOMAIN, - EXTRA_STATE_ATTRIBUTES, - SENSOR_DATA, - SENSOR_SIGNAL, -) +from .const import DOMAIN, EXTRA_STATE_ATTRIBUTES, SENSOR_SIGNAL +from .coordinator import PlaatoCoordinator, PlaatoData class PlaatoEntity(entity.Entity): @@ -25,16 +17,24 @@ class PlaatoEntity(entity.Entity): _attr_should_poll = False - def __init__(self, data, sensor_type, coordinator=None): + def __init__( + self, + data: PlaatoData, + sensor_type: str, + coordinator: PlaatoCoordinator | None = None, + ) -> None: """Initialize the sensor.""" self._coordinator = coordinator self._entry_data = data self._sensor_type = sensor_type - self._device_id = data[DEVICE][DEVICE_ID] - self._device_type = data[DEVICE][DEVICE_TYPE] - self._device_name = data[DEVICE][DEVICE_NAME] + assert self._entry_data.device_id is not None + self._device_id = cast(str, data.device_id) + self._device_type = data.device_type + self._device_name = data.device_name self._attr_unique_id = f"{self._device_id}_{self._sensor_type}" - self._attr_name = f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() + self._attr_name = ( + f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}" + ).title() sw_version = None if firmware := self._sensor_data.firmware_version: sw_version = firmware @@ -58,7 +58,7 @@ class PlaatoEntity(entity.Entity): def _sensor_data(self) -> PlaatoDevice: if self._coordinator: return self._coordinator.data - return self._entry_data[SENSOR_DATA] + return self._entry_data.sensor_data @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 7a98c8a1ced..0b99f1c7704 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -1,12 +1,9 @@ """Support for Plaato Airlock sensors.""" -from __future__ import annotations - from pyplaato.models.device import PlaatoDevice from pyplaato.plaato import PlaatoKeg from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -19,15 +16,8 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_TEMP, SENSOR_UPDATE -from .const import ( - CONF_USE_WEBHOOK, - COORDINATOR, - DEVICE, - DEVICE_ID, - DOMAIN, - SENSOR_DATA, - SENSOR_SIGNAL, -) +from .const import CONF_USE_WEBHOOK, SENSOR_SIGNAL +from .coordinator import PlaatoConfigEntry, PlaatoCoordinator, PlaatoData from .entity import PlaatoEntity @@ -42,19 +32,19 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlaatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data @callback def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): """Update/Create the sensors.""" - entry_data[SENSOR_DATA] = sensor_data + entry_data.sensor_data = sensor_data - if device_id != entry_data[DEVICE][DEVICE_ID]: - entry_data[DEVICE][DEVICE_ID] = device_id + if device_id != entry_data.device_id: + entry_data.device_id = device_id async_add_entities( [ PlaatoSensor(entry_data, sensor_type) @@ -68,7 +58,8 @@ async def async_setup_entry( if entry.data[CONF_USE_WEBHOOK]: async_dispatcher_connect(hass, SENSOR_UPDATE, _async_update_from_webhook) else: - coordinator = entry_data[COORDINATOR] + coordinator = entry_data.coordinator + assert coordinator is not None async_add_entities( PlaatoSensor(entry_data, sensor_type, coordinator) for sensor_type in coordinator.data.sensors @@ -78,18 +69,23 @@ async def async_setup_entry( class PlaatoSensor(PlaatoEntity, SensorEntity): """Representation of a Plaato Sensor.""" - def __init__(self, data, sensor_type, coordinator=None) -> None: + def __init__( + self, + data: PlaatoData, + sensor_type: str, + coordinator: PlaatoCoordinator | None = None, + ) -> None: """Initialize plaato sensor.""" super().__init__(data, sensor_type, coordinator) if sensor_type is PlaatoKeg.Pins.TEMPERATURE or sensor_type == ATTR_TEMP: self._attr_device_class = SensorDeviceClass.TEMPERATURE @property - def native_value(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" return self._sensor_data.sensors.get(self._sensor_type) @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self._sensor_data.get_unit_of_measurement(self._sensor_type) diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index 91214ba9ebe..e0ac391f68b 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -1,7 +1,5 @@ """The PlayStation Network integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index 89a752eff0e..9deafe8bdfa 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for PlayStation Network integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index 26333423603..946a5cf9ec1 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -148,7 +148,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): } ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( entry, data_updates={CONF_NPSSO: npsso}, ) diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 7cdb872a7ac..616008d3fce 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the PlayStation Network Integration.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 710760a015c..ce858645daf 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for PlayStation Network.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any @@ -20,7 +18,6 @@ TO_REDACT = { "onlineId", "url", "username", - "onlineId", "accountId", "members", "body", diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index d456cc110a4..5e733a1c9f1 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -1,7 +1,5 @@ """Helper methods for common PlayStation Network integration operations.""" -from __future__ import annotations - from dataclasses import dataclass, field from functools import partial from typing import Any @@ -184,7 +182,7 @@ class PlaystationNetwork: for title in self.trophy_titles if game_title_info["titleName"] == normalize_title(title.title_name or "") - and next(iter(title.title_platform)) == PlatformType.PS_VITA + and next(iter(title.title_platform)) is PlatformType.PS_VITA ), None, ) diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py index 2394c01b132..c01cce65b47 100644 --- a/homeassistant/components/playstation_network/image.py +++ b/homeassistant/components/playstation_network/image.py @@ -1,7 +1,5 @@ """Image platform for PlayStation Network.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index 118d49c1ab6..5f2446a01f6 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -90,5 +90,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.1.0"] + "requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.2.0"] } diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py index 8eabb5a78f6..298e293ff33 100644 --- a/homeassistant/components/playstation_network/notify.py +++ b/homeassistant/components/playstation_network/notify.py @@ -1,7 +1,5 @@ """Notify platform for PlayStation Network.""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING @@ -174,7 +172,7 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity): class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity): - """Representation of a PlayStation Network notify entity for sending direct messages.""" + """PlayStation Network notify entity for direct messages.""" coordinator: PlaystationNetworkFriendlistCoordinator diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index 4e91bf2f1bb..9e29c85213c 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for PlayStation Network integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 5ed34eac6b2..09aa1fd4278 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -1,7 +1,5 @@ """Representation of Plex buttons.""" -from __future__ import annotations - from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory diff --git a/homeassistant/components/plex/cast.py b/homeassistant/components/plex/cast.py index b95e836329a..c1a2ffd4c63 100644 --- a/homeassistant/components/plex/cast.py +++ b/homeassistant/components/plex/cast.py @@ -1,7 +1,5 @@ """Google Cast support for the Plex component.""" -from __future__ import annotations - from pychromecast import Chromecast from pychromecast.controllers.plex import PlexController diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 67abba8a89a..e4d28871c1a 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Plex.""" -from __future__ import annotations - from collections.abc import Mapping from copy import deepcopy import logging @@ -41,7 +39,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, - AUTOMATIC_SETUP_STRING, CONF_IGNORE_NEW_SHARED_USERS, CONF_IGNORE_PLEX_WEB_CLIENTS, CONF_MONITORED_USERS, @@ -52,7 +49,6 @@ from .const import ( DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, - MANUAL_SETUP_STRING, PLEX_SERVER_CONFIG, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, @@ -117,41 +113,22 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None, - errors: dict[str, str] | None = None, ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - if user_input is not None: - return await self._async_step_plex_website_auth() - if self.show_advanced_options: - return await self.async_step_user_advanced(errors=errors) - return self.async_show_form( + return self.async_show_menu( step_id="user", - errors=errors, - description_placeholders={"plex_server_url": "[plex.tv](https://plex.tv)"}, + menu_options=["website_auth", "manual_setup"], ) - async def async_step_user_advanced( + async def async_step_website_auth( self, - user_input: dict[str, str] | None = None, + user_input: dict[str, Any] | None = None, errors: dict[str, str] | None = None, ) -> ConfigFlowResult: - """Handle an advanced mode flow initialized by the user.""" - if user_input is not None: - if user_input.get("setup_method") == MANUAL_SETUP_STRING: - self._manual = True - return await self.async_step_manual_setup() - return await self._async_step_plex_website_auth() - - data_schema = vol.Schema( - { - vol.Required("setup_method", default=AUTOMATIC_SETUP_STRING): vol.In( - [AUTOMATIC_SETUP_STRING, MANUAL_SETUP_STRING] - ) - } - ) - return self.async_show_form( - step_id="user_advanced", data_schema=data_schema, errors=errors - ) + """Handle website authentication.""" + if errors: + return self.async_show_form(step_id="website_auth", errors=errors) + return await self._async_step_plex_website_auth() async def async_step_manual_setup( self, @@ -159,6 +136,7 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None, ) -> ConfigFlowResult: """Begin manual configuration.""" + self._manual = True if user_input is not None and errors is None: user_input.pop(CONF_URL, None) if host := user_input.get(CONF_HOST): @@ -242,7 +220,7 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_manual_setup( user_input=server_config, errors=errors ) - return await self.async_step_user(errors=errors) + return await self.async_step_website_auth(errors=errors) server_id = plex_server.machine_identifier url = plex_server.url_in_use diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index b43a1eca135..bdd0a28e278 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -52,9 +52,6 @@ X_PLEX_PLATFORM = "Home Assistant" X_PLEX_PRODUCT = "Home Assistant" X_PLEX_VERSION = __version__ -AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv" -MANUAL_SETUP_STRING = "Configure Plex server manually" - SERVICE_REFRESH_LIBRARY = "refresh_library" PLEX_URI_SCHEME = "plex://" diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index 3c7ff8180c8..0a1428e1acd 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -1,7 +1,5 @@ """Helper methods for common Plex integration operations.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, TypedDict @@ -28,6 +26,8 @@ class PlexData(TypedDict): def get_plex_data(hass: HomeAssistant) -> PlexData: """Get typed data from hass.data.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data return hass.data[DOMAIN] diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 74beee479f0..8bd1b9f0184 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -1,7 +1,5 @@ """Support to interface with the Plex API.""" -from __future__ import annotations - from yarl import URL from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 0c74714cb4e..97bcc90d242 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -1,7 +1,5 @@ """Support to interface with the Plex API.""" -from __future__ import annotations - from collections.abc import Callable from functools import wraps import logging diff --git a/homeassistant/components/plex/media_search.py b/homeassistant/components/plex/media_search.py index bd785a08907..e330245f0fd 100644 --- a/homeassistant/components/plex/media_search.py +++ b/homeassistant/components/plex/media_search.py @@ -1,7 +1,5 @@ """Helper methods to search for Plex media.""" -from __future__ import annotations - import logging from plexapi.base import PlexObject diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 87af46f198d..5d5b01dc903 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -1,7 +1,5 @@ """Support for Plex media server monitoring.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 462d7577a9b..d0b967fa424 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,7 +1,5 @@ """Shared class to maintain Plex server instances.""" -from __future__ import annotations - from copy import copy import logging import ssl diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index d767953e600..8e378b9519f 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -29,18 +29,20 @@ }, "select_server": { "data": { - "server": "Server" + "server_id": "Server" }, "description": "Multiple servers available, select one:", "title": "Select Plex server" }, "user": { - "description": "Continue to {plex_server_url} to link a Plex server." - }, - "user_advanced": { - "data": { - "setup_method": "Setup method" + "description": "A Plex server can be set up in Home Assistant in two different ways.\n\nYou can link your Plex account by logging in via plex.tv, which will automatically discover and connect your servers, or you can manually enter the server address and token.", + "menu_options": { + "manual_setup": "Enter manually", + "website_auth": "Link Plex account (recommended)" } + }, + "website_auth": { + "description": "Something went wrong connecting to Plex. Please try again." } } }, diff --git a/homeassistant/components/plex/view.py b/homeassistant/components/plex/view.py index c1254a9795a..6adac62d8aa 100644 --- a/homeassistant/components/plex/view.py +++ b/homeassistant/components/plex/view.py @@ -1,7 +1,5 @@ """Implement a view to provide proxied Plex thumbnails to the media browser.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index cc491d31973..c403d27ce39 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -1,7 +1,5 @@ """Plugwise platform for Home Assistant Core.""" -from __future__ import annotations - from typing import Any from homeassistant.const import Platform @@ -47,7 +45,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: """Migrate Plugwise entity entries. - Migrates old unique ID's from old binary_sensors and switches to the new unique ID's. + Migrates old unique ID's from old binary_sensors and + switches to the new unique ID's. """ if entry.domain == Platform.BINARY_SENSOR and entry.unique_id.endswith( "-slave_boiler_state" diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index f2c2fd6ed68..3d80157f6f6 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -1,7 +1,5 @@ """Plugwise Binary Sensor component for Home Assistant.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index c0896b602f0..f9db1c492d0 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -1,7 +1,5 @@ """Plugwise Button component for Home Assistant.""" -from __future__ import annotations - from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index ac33f04215f..6fd2e14d026 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -1,7 +1,5 @@ """Plugwise Climate component for Home Assistant.""" -from __future__ import annotations - from dataclasses import asdict, dataclass from typing import Any @@ -29,6 +27,15 @@ ERROR_NO_SCHEDULE = "set_schedule_first" PARALLEL_UPDATES = 0 +def _check_for_schedule(active: bool, last_active: str | None) -> None: + """Raise a HAError when no thermostat schedule has been set.""" + if not active and last_active is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=ERROR_NO_SCHEDULE, + ) + + @dataclass class PlugwiseClimateExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" @@ -87,22 +94,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _last_active_schedule: str | None = None - _previous_action_mode: str | None = HVACAction.HEATING.value - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added.""" - await super().async_added_to_hass() - - if extra_data := await self.async_get_last_extra_data(): - plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict( - extra_data.as_dict() - ) - self._last_active_schedule = plugwise_extra_data.last_active_schedule - self._previous_action_mode = ( - plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value - ) - def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, @@ -112,18 +103,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): super().__init__(coordinator, device_id) self._attr_unique_id = f"{device_id}-climate" - gateway_id: str = coordinator.api.gateway_id + self._api = coordinator.api + gateway_id: str = self._api.gateway_id self._gateway_data = coordinator.data[gateway_id] + self._last_active_schedule: str | None = None self._location = device_id if (location := self.device.get("location")) is not None: self._location = location + self._previous_action_mode = HVACAction.HEATING.value # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if ( - self.coordinator.api.cooling_present - and coordinator.api.smile.name != "Adam" - ): + if self._api.cooling_present and self._api.smile.name != "Adam": self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -142,10 +133,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): self.device["thermostat"]["resolution"], 0.1 ) - @property - def current_temperature(self) -> float: - """Return the current temperature.""" - return self.device["sensors"]["temperature"] + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if extra_data := await self.async_get_last_extra_data(): + plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict( + extra_data.as_dict() + ) + self._last_active_schedule = plugwise_extra_data.last_active_schedule + self._previous_action_mode = ( + plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value + ) @property def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData: @@ -155,6 +154,11 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): previous_action_mode=self._previous_action_mode, ) + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self.device["sensors"]["temperature"] + @property def target_temperature(self) -> float: """Return the temperature we try to reach. @@ -199,7 +203,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): if self.device.get("available_schedules"): hvac_modes.append(HVACMode.AUTO) - if self.coordinator.api.cooling_present: + if self._api.cooling_present: if "regulation_modes" in self._gateway_data: if "heating" in self._gateway_data["regulation_modes"]: hvac_modes.append(HVACMode.HEAT) @@ -249,79 +253,69 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity): if mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(mode) - await self.coordinator.api.set_temperature(self._location, data) + await self._api.set_temperature(self._location, data) - def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str | None: - """Return the API regulation value for a manual HVAC mode, or None.""" + def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str: + """Return the API regulation value for a manual HVAC mode, or None. + + The function inputs are limited to the HVACModes HEAT and COOL. + """ if hvac_mode == HVACMode.HEAT: - return HVACAction.HEATING.value + mode = HVACAction.HEATING.value if hvac_mode == HVACMode.COOL: - return HVACAction.COOLING.value - return None + mode = HVACAction.COOLING.value + return mode @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule).""" + # Early exit if no mode change if hvac_mode == self.hvac_mode: return - api = self.coordinator.api - current_schedule = self.device.get("select_schedule") - - # OFF: single API call + # Adam only: set to HVACMode.OFF if hvac_mode == HVACMode.OFF: - await api.set_regulation_mode(hvac_mode.value) + await self._api.set_regulation_mode(hvac_mode.value) return - # Manual mode (heat/cool/heat_cool) without a schedule: set regulation only - if ( - current_schedule is None - and hvac_mode != HVACMode.AUTO - and ( - regulation := self._regulation_mode_for_hvac(hvac_mode) - or self._previous_action_mode - ) - ): - await api.set_regulation_mode(regulation) - return + current_schedule = self.device.get("select_schedule") + schedule_is_active = current_schedule not in (None, "off") + desired_schedule = ( + current_schedule if schedule_is_active else self._last_active_schedule + ) + # Adam only: transition from HVACMode.OFF + if self.hvac_mode == HVACMode.OFF: + if hvac_mode == HVACMode.AUTO: + _check_for_schedule(schedule_is_active, self._last_active_schedule) + await self._api.set_schedule_state( + self._location, STATE_ON, desired_schedule + ) + await self._api.set_regulation_mode(self._previous_action_mode) + return - # Manual mode: ensure regulation and turn off schedule when needed - if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL): - regulation = self._regulation_mode_for_hvac(hvac_mode) or ( - self._previous_action_mode - if self.hvac_mode in (HVACMode.HEAT_COOL, HVACMode.OFF) - else None - ) - if regulation: - await api.set_regulation_mode(regulation) - - if ( - self.hvac_mode == HVACMode.OFF and current_schedule not in (None, "off") - ) or (self.hvac_mode == HVACMode.AUTO and current_schedule is not None): - await api.set_schedule_state( + # Transition to manual mode + if schedule_is_active: + await self._api.set_schedule_state( self._location, STATE_OFF, current_schedule ) + self._last_active_schedule = current_schedule + regulation = self._regulation_mode_for_hvac(hvac_mode) + await self._api.set_regulation_mode(regulation) return - # AUTO: restore schedule and regulation - desired_schedule = current_schedule - if desired_schedule and desired_schedule != "off": - self._last_active_schedule = desired_schedule - elif desired_schedule == "off": - desired_schedule = self._last_active_schedule - - if not desired_schedule: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=ERROR_NO_SCHEDULE, + # Common - transition from auto = schedule off + if self.hvac_mode == HVACMode.AUTO: + await self._api.set_schedule_state( + self._location, STATE_OFF, current_schedule ) + self._last_active_schedule = current_schedule + return - if self._previous_action_mode: - if self.hvac_mode == HVACMode.OFF: - await api.set_regulation_mode(self._previous_action_mode) - await api.set_schedule_state(self._location, STATE_ON, desired_schedule) + # Common - transition to auto = schedule on + _check_for_schedule(schedule_is_active, self._last_active_schedule) + await self._api.set_schedule_state(self._location, STATE_ON, desired_schedule) @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - await self.coordinator.api.set_preset(self._location, preset_mode) + await self._api.set_preset(self._location, preset_mode) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index fac9f0b4cdd..84706db4cef 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Plugwise integration.""" -from __future__ import annotations - import logging from typing import Any, Self diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 9b9e426651b..e679081e374 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -1,7 +1,5 @@ """Constants for Plugwise component.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final, Literal diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index b0a28c5f616..46481e9f1f1 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -137,8 +137,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData """Add new Plugwise devices, remove non-existing devices.""" set_of_data = set(data) # Check for new or removed devices, - # 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty) - # this is required for the proper initialization of all the present platform entities. + # 'new_devices' contains all devices present in 'data' + # at init ('self._current_devices' is empty) this is + # required for the proper initialization of all the + # present platform entities. self.new_devices = set_of_data - self._current_devices for device_id in self.new_devices: self._firmware_list.setdefault(device_id, data[device_id].get("firmware")) diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index e97405f6279..7298ebe4059 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Plugwise.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index afecac11ec4..f994e9db106 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -1,7 +1,5 @@ """Generic Plugwise Entity Class.""" -from __future__ import annotations - from plugwise import GwEntityData from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index b17edb50835..775fa049de6 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.11.3"], + "requirements": ["plugwise==1.11.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 1dbb0506748..e68d5ad2b6d 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -1,7 +1,5 @@ """Number platform for Plugwise integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.number import ( diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index c83c71ee9bc..9220a0f1e8c 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -1,7 +1,5 @@ """Plugwise Select component for Home Assistant.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.select import SelectEntity, SelectEntityDescription diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index aa417d2eeeb..5853c8c33f2 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -1,7 +1,5 @@ """Plugwise Sensor component for Home Assistant.""" -from __future__ import annotations - from dataclasses import dataclass from plugwise.constants import SensorType diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 8179fb546b4..5e63099db75 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -1,7 +1,5 @@ """Plugwise Switch component for HomeAssistant.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py index 4a0b849d939..6d5b218a892 100644 --- a/homeassistant/components/plum_lightpad/config_flow.py +++ b/homeassistant/components/plum_lightpad/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Plum Lightpad.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigFlow from . import DOMAIN diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index bbe75ae544c..e06abdd8575 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -1,7 +1,5 @@ """Support for Pocket Casts.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 5902b77076d..04db79c5f3a 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Minut Point.""" -from __future__ import annotations - import logging from pypoint import PointSession diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 8113899b505..ec003ac8652 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Minut Point binary sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 40b59b17575..60f453d11c4 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -1,7 +1,5 @@ """Support for Minut Point sensors.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py index 12d55ed544f..b9d83c52fd5 100644 --- a/homeassistant/components/pooldose/__init__.py +++ b/homeassistant/components/pooldose/__init__.py @@ -1,7 +1,5 @@ """The Seko Pooldose integration.""" -from __future__ import annotations - import logging from typing import Any @@ -67,16 +65,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> client_status = await client.connect() except TimeoutError as err: raise ConfigEntryNotReady( - f"Timeout connecting to PoolDose device: {err}" + translation_domain=entry.domain, + translation_key="connect_timeout", ) from err except (ConnectionError, OSError) as err: raise ConfigEntryNotReady( - f"Failed to connect to PoolDose device: {err}" + translation_domain=entry.domain, + translation_key="connect_failed", ) from err if client_status != RequestStatus.SUCCESS: raise ConfigEntryNotReady( - f"Failed to create PoolDose client while initialization: {client_status}" + translation_domain=entry.domain, + translation_key="client_init_failed", + translation_placeholders={"status": str(client_status.value)}, ) # Create coordinator and perform first refresh diff --git a/homeassistant/components/pooldose/binary_sensor.py b/homeassistant/components/pooldose/binary_sensor.py index efd2ed70fa4..2375c86a6d3 100644 --- a/homeassistant/components/pooldose/binary_sensor.py +++ b/homeassistant/components/pooldose/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for the Seko PoolDose integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py index d15de677960..05dd087857f 100644 --- a/homeassistant/components/pooldose/config_flow.py +++ b/homeassistant/components/pooldose/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Seko PoolDose integration.""" -from __future__ import annotations - import logging from typing import Any @@ -45,17 +43,17 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): """Validate the host and return (serial_number, api_versions, errors).""" client = PooldoseClient(host, websession=async_get_clientsession(self.hass)) client_status = await client.connect() - if client_status == RequestStatus.HOST_UNREACHABLE: + if client_status is RequestStatus.HOST_UNREACHABLE: return None, None, {"base": "cannot_connect"} - if client_status == RequestStatus.PARAMS_FETCH_FAILED: + if client_status is RequestStatus.PARAMS_FETCH_FAILED: return None, None, {"base": "params_fetch_failed"} - if client_status != RequestStatus.SUCCESS: + if client_status is not RequestStatus.SUCCESS: return None, None, {"base": "cannot_connect"} api_status, api_versions = client.check_apiversion_supported() - if api_status == RequestStatus.NO_DATA: + if api_status is RequestStatus.NO_DATA: return None, None, {"base": "api_not_set"} - if api_status == RequestStatus.API_VERSION_UNSUPPORTED: + if api_status is RequestStatus.API_VERSION_UNSUPPORTED: return None, api_versions, {"base": "api_not_supported"} device_info = client.device_info @@ -122,8 +120,10 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=SCHEMA_DEVICE, errors=errors, - # Handle API version info for error display; pass version info when available - # or None when api_versions is None to avoid displaying version details + # Handle API version info for error display; + # pass version info when available or None + # when api_versions is None to avoid + # displaying version details description_placeholders={ "api_version_is": api_versions.get("api_version_is") or "", "api_version_should": api_versions.get("api_version_should") @@ -157,7 +157,8 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=SCHEMA_DEVICE, errors=errors, - # Handle API version info for error display identical to other steps + # Handle API version info for error display + # identical to other steps description_placeholders={ "api_version_is": api_versions.get("api_version_is") or "", "api_version_should": api_versions.get("api_version_should") @@ -167,7 +168,8 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN): else None, ) - # Ensure new serial number matches the existing entry unique_id (serial number) + # Ensure new serial number matches the existing + # entry unique_id (serial number) if serial_number != self._get_reconfigure_entry().unique_id: return self.async_abort(reason="wrong_device") diff --git a/homeassistant/components/pooldose/const.py b/homeassistant/components/pooldose/const.py index c0a7949d71b..0cd74f260a3 100644 --- a/homeassistant/components/pooldose/const.py +++ b/homeassistant/components/pooldose/const.py @@ -1,7 +1,5 @@ """Constants for the Seko Pooldose integration.""" -from __future__ import annotations - from homeassistant.const import UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlowRate DOMAIN = "pooldose" diff --git a/homeassistant/components/pooldose/coordinator.py b/homeassistant/components/pooldose/coordinator.py index 660c895f33d..db193689e86 100644 --- a/homeassistant/components/pooldose/coordinator.py +++ b/homeassistant/components/pooldose/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the PoolDose integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -52,18 +50,27 @@ class PooldoseCoordinator(DataUpdateCoordinator[StructuredValuesDict]): status, instant_values = await self.client.instant_values_structured() except TimeoutError as err: raise UpdateFailed( - f"Timeout fetching data from PoolDose device: {err}" + translation_domain=self.config_entry.domain, + translation_key="update_timeout", ) from err except (ConnectionError, OSError) as err: raise UpdateFailed( - f"Failed to connect to PoolDose device while fetching data: {err}" + translation_domain=self.config_entry.domain, + translation_key="update_connect_failed", ) from err if status != RequestStatus.SUCCESS: - raise UpdateFailed(f"API returned status: {status}") + raise UpdateFailed( + translation_domain=self.config_entry.domain, + translation_key="api_status_error", + translation_placeholders={"status": str(status.value)}, + ) if not instant_values: - raise UpdateFailed("No data received from API") + raise UpdateFailed( + translation_domain=self.config_entry.domain, + translation_key="no_data_received", + ) _LOGGER.debug("Instant values structured: %s", instant_values) return instant_values diff --git a/homeassistant/components/pooldose/diagnostics.py b/homeassistant/components/pooldose/diagnostics.py index 35e19d370fd..4fce167acaa 100644 --- a/homeassistant/components/pooldose/diagnostics.py +++ b/homeassistant/components/pooldose/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Pooldose.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/pooldose/entity.py b/homeassistant/components/pooldose/entity.py index 013e28751c3..2bae134c2d6 100644 --- a/homeassistant/components/pooldose/entity.py +++ b/homeassistant/components/pooldose/entity.py @@ -1,7 +1,5 @@ """Base entity for Seko Pooldose integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Literal @@ -34,7 +32,9 @@ def device_info( name=info.get("NAME") or None, serial_number=unique_id, sw_version=( - f"{info.get('FW_VERSION')} (SW v{info.get('SW_VERSION')}, API {api_version})" + f"{info.get('FW_VERSION')}" + f" (SW v{info.get('SW_VERSION')}," + f" API {api_version})" if info.get("FW_VERSION") and info.get("SW_VERSION") and api_version else None ), diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index acc8e90c3d6..74280a780c9 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["python-pooldose==0.9.0"] + "requirements": ["python-pooldose==0.9.1"] } diff --git a/homeassistant/components/pooldose/number.py b/homeassistant/components/pooldose/number.py index 4cc999db6a6..19ab137d58e 100644 --- a/homeassistant/components/pooldose/number.py +++ b/homeassistant/components/pooldose/number.py @@ -1,7 +1,5 @@ """Number entities for the Seko PoolDose integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/pooldose/select.py b/homeassistant/components/pooldose/select.py index db791f77229..789603580b2 100644 --- a/homeassistant/components/pooldose/select.py +++ b/homeassistant/components/pooldose/select.py @@ -1,7 +1,5 @@ """Select entities for the Seko PoolDose integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py index 0c5c0f87427..af2146cb390 100644 --- a/homeassistant/components/pooldose/sensor.py +++ b/homeassistant/components/pooldose/sensor.py @@ -1,7 +1,5 @@ """Sensors for the Seko PoolDose integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index e8e8a5ea416..f313da694e7 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -345,9 +345,30 @@ } }, "exceptions": { + "api_status_error": { + "message": "API returned status code: {status}" + }, "cannot_connect": { "message": "Value cannot be set because the device is not connected" }, + "client_init_failed": { + "message": "Failed to initialize PoolDose client: {status}" + }, + "connect_failed": { + "message": "Failed to connect to PoolDose device" + }, + "connect_timeout": { + "message": "Timeout connecting to PoolDose device" + }, + "no_data_received": { + "message": "No data received from API" + }, + "update_connect_failed": { + "message": "Failed to connect to PoolDose device while fetching data" + }, + "update_timeout": { + "message": "Timeout fetching data from PoolDose device" + }, "write_rejected": { "message": "The device rejected the value for {entity}: {value}" } diff --git a/homeassistant/components/pooldose/switch.py b/homeassistant/components/pooldose/switch.py index 23dcc271aff..c29a4e86145 100644 --- a/homeassistant/components/pooldose/switch.py +++ b/homeassistant/components/pooldose/switch.py @@ -1,7 +1,5 @@ """Switches for the Seko PoolDose integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index b93f017501d..523fda698c0 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -1,7 +1,5 @@ """Support for PoolSense binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py index 557686f9145..64cedc64c10 100644 --- a/homeassistant/components/poolsense/coordinator.py +++ b/homeassistant/components/poolsense/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for poolsense integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index b0ac4404237..6af05b21cef 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the PoolSense sensor.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 6e166ffd7b9..c4d453c8348 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -1,7 +1,5 @@ """The Portainer integration.""" -from __future__ import annotations - import logging from pyportainer import Portainer @@ -22,10 +20,11 @@ import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .const import API_MAX_RETRIES, DOMAIN -from .coordinator import PortainerCoordinator +from .coordinator import PortainerCoordinator, PortainerDockerDiskSpaceCoordinator from .services import async_setup_services _PLATFORMS: list[Platform] = [ @@ -45,19 +44,45 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: """Set up Portainer from a config entry.""" + session = async_create_clientsession( + hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] + ) + client = Portainer( api_url=entry.data[CONF_URL], api_key=entry.data[CONF_API_TOKEN], - session=async_create_clientsession( - hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] - ), - request_timeout=30, + session=session, + request_timeout=10, max_retries=API_MAX_RETRIES, ) coordinator = PortainerCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() + docker_system_df_client = Portainer( + api_url=entry.data[CONF_URL], + api_key=entry.data[CONF_API_TOKEN], + session=session, + request_timeout=120, + max_retries=API_MAX_RETRIES, + ) + + docker_disk_space_coordinator = PortainerDockerDiskSpaceCoordinator( + hass, entry, docker_system_df_client + ) + coordinator.docker_disk_space = docker_disk_space_coordinator + + async def _defer_docker_disk_space_refresh(_: HomeAssistant) -> None: + """Defer the first refresh until Home Assistant has started.""" + hass.async_create_task( + docker_disk_space_coordinator.async_refresh(), + "portainer_docker_disk_space_initial_refresh", + ) + + # On lower-end hardware, the DF endpoint can take long + # Do not block the setup, but defer the first refresh until HA is fully started + entry.async_on_unload(async_at_started(hass, _defer_docker_disk_space_refresh)) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) @@ -94,7 +119,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) entity_registry = er.async_get(hass) devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device in devices: - # This means it's an endpoint. This can be skipped, we're only interested in the containers + # This means it's an endpoint. This can be skipped, + # we're only interested in the containers if device.via_device_id is None: continue @@ -172,7 +198,8 @@ async def async_remove_config_entry_device( coordinator = entry.runtime_data valid_identifiers: set[tuple[str, str]] = set() - # The Portainer integration creates devices for both endpoints and containers. That's why we're doing it double + # The Portainer integration creates devices for both + # endpoints and containers. That's why we're doing it double valid_identifiers.update( (DOMAIN, f"{entry.entry_id}_{endpoint_id}") for endpoint_id in coordinator.data ) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 201b07c1c21..bd0b5047cf3 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Portainer.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 6cf761149be..1f3c86cf9b0 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -1,7 +1,5 @@ """Support for Portainer buttons.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass @@ -14,6 +12,7 @@ from pyportainer.exceptions import ( PortainerConnectionError, PortainerTimeoutError, ) +from pyportainer.models.docker import DockerContainer from homeassistant.components.button import ( ButtonDeviceClass, @@ -41,10 +40,9 @@ PARALLEL_UPDATES = 1 class PortainerButtonDescription(ButtonEntityDescription): """Class to describe a Portainer button entity.""" - # Note to reviewer: I am keeping the third argument a str, in order to keep mypy happy :) press_action: Callable[ [Portainer, int, str], - Coroutine[Any, Any, None], + Coroutine[Any, Any, None | DockerContainer], ] @@ -102,6 +100,19 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), + PortainerButtonDescription( + key="recreate", + translation_key="recreate_container", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.container_recreate( + endpoint_id=endpoint_id, + container_id=container_id, + timeout=timedelta(minutes=10), + pull_image=True, + ) + ), + ), PortainerButtonDescription( key="kill", translation_key="kill_container", @@ -170,7 +181,10 @@ async def async_setup_entry( class PortainerBaseButton(ButtonEntity): - """Common base for Portainer buttons. Basically to ensure the async_press logic isn't duplicated.""" + """Common base for Portainer buttons. + + Ensures the async_press logic isn't duplicated. + """ entity_description: PortainerButtonDescription coordinator: PortainerCoordinator diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index b94f2943a5b..8c9ede61aee 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the portainer integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index f05cf4b53d2..c16232936d6 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -1,7 +1,6 @@ """Data Update Coordinator for Portainer.""" -from __future__ import annotations - +from abc import abstractmethod import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -20,6 +19,8 @@ from pyportainer.models.docker import ( DockerContainer, DockerContainerStats, DockerSystemDF, + DockerVolume, + DockerVolumeUsageData, ) from pyportainer.models.docker_inspect import DockerInfo, DockerVersion from pyportainer.models.portainer import Endpoint @@ -38,6 +39,7 @@ type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] _LOGGER = logging.getLogger(__name__) DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_DF_SCAN_INTERVAL = timedelta(minutes=30) @dataclass @@ -50,8 +52,8 @@ class PortainerCoordinatorData: containers: dict[str, PortainerContainerData] docker_version: DockerVersion docker_info: DockerInfo - docker_system_df: DockerSystemDF stacks: dict[str, PortainerStackData] + volumes: dict[str, PortainerVolumeData] @dataclass(slots=True) @@ -72,10 +74,18 @@ class PortainerStackData: container_count: int = 0 -class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]): - """Data Update Coordinator for Portainer.""" +@dataclass(slots=True) +class PortainerVolumeData: + """Volume data held by the Portainer coordinator.""" + + volume: DockerVolume + + +class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for Portainer.""" config_entry: PortainerConfigEntry + _update_interval: timedelta def __init__( self, @@ -83,19 +93,20 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD config_entry: PortainerConfigEntry, portainer: Portainer, ) -> None: - """Initialize the Portainer Data Update Coordinator.""" + """Initialize.""" super().__init__( hass, _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, + update_interval=self._update_interval, ) self.portainer = portainer self.known_endpoints: set[int] = set() self.known_containers: set[tuple[int, str]] = set() self.known_stacks: set[tuple[int, str]] = set() + self.known_volumes: set[tuple[int, str]] = set() self.new_endpoints_callbacks: list[ Callable[[list[PortainerCoordinatorData]], None] @@ -108,6 +119,9 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD self.new_stacks_callbacks: list[ Callable[[list[tuple[PortainerCoordinatorData, PortainerStackData]]], None] ] = [] + self.new_volumes_callbacks: list[ + Callable[[list[tuple[PortainerCoordinatorData, PortainerVolumeData]]], None] + ] = [] async def _async_setup(self) -> None: """Set up the Portainer Data Update Coordinator.""" @@ -132,7 +146,44 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD translation_placeholders={"error": repr(err)}, ) from err - async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" + + async def _async_update_data(self) -> _DataT: + """Fetch per coordinator specific data.""" + try: + return await self.update_data() + except PortainerAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerTimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + +class PortainerCoordinator( + PortainerBaseCoordinator[dict[int, PortainerCoordinatorData]] +): + """Data Update Coordinator for Portainer.""" + + config_entry: PortainerConfigEntry + docker_disk_space: PortainerDockerDiskSpaceCoordinator | None = None + _update_interval = DEFAULT_SCAN_INTERVAL + + async def update_data(self) -> dict[int, PortainerCoordinatorData]: """Fetch data from Portainer API.""" _LOGGER.debug( "Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL] @@ -164,100 +215,119 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD ) continue - try: - ( - containers, - docker_version, - docker_info, - docker_system_df, - stacks, - ) = await asyncio.gather( - self.portainer.get_containers(endpoint.id), - self.portainer.docker_version(endpoint.id), - self.portainer.docker_info(endpoint.id), - self.portainer.docker_system_df(endpoint.id), - self.portainer.get_stacks(endpoint.id), + ( + containers, + docker_version, + docker_info, + docker_system_df, + volumes, + ) = await asyncio.gather( + self.portainer.get_containers(endpoint.id), + self.portainer.docker_version(endpoint.id), + self.portainer.docker_info(endpoint.id), + self.portainer.docker_system_df(endpoint.id, verbose=True), + self.portainer.get_volumes(endpoint.id), + ) + + stack_requests = [self.portainer.get_stacks(endpoint_id=endpoint.id)] + swarm_id = ( + docker_info.swarm.cluster.get("ID") + if docker_info.swarm + and docker_info.swarm.control_available + and docker_info.swarm.cluster + else None + ) + if swarm_id: + stack_requests.append( + self.portainer.get_stacks( + endpoint_id=endpoint.id, swarm_id=swarm_id + ) ) - prev_endpoint = self.data.get(endpoint.id) if self.data else None - container_map: dict[str, PortainerContainerData] = {} - stack_map: dict[str, PortainerStackData] = { - stack.name: PortainerStackData(stack=stack, container_count=0) - for stack in stacks - } + stacks = [ + stack + for result in await asyncio.gather(*stack_requests) + for stack in result + ] - # Map containers, started and stopped - for container in containers: - container_name = self._get_container_name(container.names[0]) - prev_container = ( - prev_endpoint.containers.get(container_name) - if prev_endpoint - else None + prev_endpoint = self.data.get(endpoint.id) if self.data else None + container_map: dict[str, PortainerContainerData] = {} + stack_map: dict[str, PortainerStackData] = { + stack.name: PortainerStackData(stack=stack, container_count=0) + for stack in stacks + } + + # Map containers, started and stopped + for container in containers: + container_name = self._get_container_name(container.names[0]) + prev_container = ( + prev_endpoint.containers.get(container_name) + if prev_endpoint + else None + ) + + # Check if container belongs to a stack via docker compose label + stack_name: str | None = ( + container.labels.get("com.docker.compose.project") + or container.labels.get("com.docker.stack.namespace") + if container.labels + else None + ) + if stack_name and (stack_data := stack_map.get(stack_name)): + stack_data.container_count += 1 + + container_map[container_name] = PortainerContainerData( + container=container, + stats=None, + stats_pre=prev_container.stats if prev_container else None, + stack=stack_map[stack_name].stack + if stack_name and stack_name in stack_map + else None, + ) + + volume_usage_map = { + item["Name"]: item + for item in (docker_system_df.volume_disk_usage.items or []) + } + volume_map: dict[str, PortainerVolumeData] = {} + for volume in volumes: + if item := volume_usage_map.get(volume.name): + volume.usage_data = DockerVolumeUsageData( + size=item["UsageData"]["Size"], + ref_count=item["UsageData"]["RefCount"], ) + volume_map[volume.name] = PortainerVolumeData(volume=volume) - # Check if container belongs to a stack via docker compose label - stack_name: str | None = ( - container.labels.get("com.docker.compose.project") - or container.labels.get("com.docker.stack.namespace") - if container.labels - else None - ) - if stack_name and (stack_data := stack_map.get(stack_name)): - stack_data.container_count += 1 - - container_map[container_name] = PortainerContainerData( - container=container, - stats=None, - stats_pre=prev_container.stats if prev_container else None, - stack=stack_map[stack_name].stack - if stack_name and stack_name in stack_map - else None, - ) - - # Separately fetch stats for running containers - running_containers = [ - container - for container in containers - if container.state - in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) - ] - if running_containers: - container_stats = dict( - zip( - ( - self._get_container_name(container.names[0]) - for container in running_containers - ), - await asyncio.gather( - *( - self.portainer.container_stats( - endpoint_id=endpoint.id, - container_id=container.id, - ) - for container in running_containers + # Separately fetch stats for active containers + active_containers = [ + container + for container in containers + if container.state + in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) + ] + if active_containers: + container_stats = dict( + zip( + ( + self._get_container_name(container.names[0]) + for container in active_containers + ), + await asyncio.gather( + *( + self.portainer.container_stats( + endpoint_id=endpoint.id, + container_id=container.id, ) - ), - strict=False, - ) + for container in active_containers + ) + ), + strict=False, ) + ) - # Now assign stats to the containers - for container_name, stats in container_stats.items(): - container_map[container_name].stats = stats - except PortainerConnectionError as err: - _LOGGER.exception("Connection error") - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, - ) from err - except PortainerAuthenticationError as err: - _LOGGER.exception("Authentication error") - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, - ) from err + # Now assign stats to the containers + for container_name, stats in container_stats.items(): + container_map[container_name].stats = stats mapped_endpoints[endpoint.id] = PortainerCoordinatorData( id=endpoint.id, @@ -266,7 +336,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD containers=container_map, docker_version=docker_version, docker_info=docker_info, - docker_system_df=docker_system_df, + volumes=volume_map, stacks=stack_map, ) @@ -313,6 +383,28 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD for container_callback in self.new_containers_callbacks: container_callback(new_container_data) + # Volume management + current_volumes = { + (endpoint.id, volume_name) + for endpoint in mapped_endpoints.values() + for volume_name in endpoint.volumes + } + + self.known_volumes &= current_volumes + new_volumes = current_volumes - self.known_volumes + if new_volumes: + _LOGGER.debug("New volumes found: %s", new_volumes) + self.known_volumes.update(new_volumes) + new_volume_data = [ + ( + mapped_endpoints[endpoint_id], + mapped_endpoints[endpoint_id].volumes[name], + ) + for endpoint_id, name in new_volumes + ] + for volume_callback in self.new_volumes_callbacks: + volume_callback(new_volume_data) + # Stack management current_stacks = { (endpoint.id, stack_name) @@ -338,3 +430,22 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD 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]] +): + """Data Update Coordinator for Docker disk space.""" + + config_entry: PortainerConfigEntry + _update_interval = DEFAULT_DF_SCAN_INTERVAL + + async def update_data(self) -> dict[int, DockerSystemDF]: + """Fetch Docker disk space data independently from Portainer API.""" + endpoints = await self.portainer.get_endpoints() + results: dict[int, DockerSystemDF] = {} + for endpoint in endpoints: + if endpoint.status == EndpointStatus.DOWN: + continue + results[endpoint.id] = await self.portainer.docker_system_df(endpoint.id) + return results diff --git a/homeassistant/components/portainer/diagnostics.py b/homeassistant/components/portainer/diagnostics.py index de53dc8033f..035b109ed42 100644 --- a/homeassistant/components/portainer/diagnostics.py +++ b/homeassistant/components/portainer/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics for the Portainer integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index 9fb87248e63..929217433c7 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -9,10 +9,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + DockerVolume, PortainerContainerData, PortainerCoordinator, PortainerCoordinatorData, + PortainerDockerDiskSpaceCoordinator, PortainerStackData, + PortainerVolumeData, ) @@ -22,6 +25,14 @@ class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]): _attr_has_entity_name = True +class PortainerDockerDiskSpaceCoordinatorEntity( + CoordinatorEntity[PortainerDockerDiskSpaceCoordinator] +): + """Base class for Portainer entities using the Docker disk space coordinator.""" + + _attr_has_entity_name = True + + class PortainerEndpointEntity(PortainerCoordinatorEntity): """Base implementation for Portainer endpoint.""" @@ -48,7 +59,10 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity): name=device_info.endpoint.name, entry_type=DeviceEntryType.SERVICE, ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{device_info.id}_{entity_description.key}" + ) @property def available(self) -> bool: @@ -104,7 +118,10 @@ class PortainerContainerEntity(PortainerCoordinatorEntity): translation_key=None if self.device_name else "unknown_container", entry_type=DeviceEntryType.SERVICE, ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self.device_name}_{entity_description.key}" + ) @property def available(self) -> bool: @@ -158,7 +175,10 @@ class PortainerStackEntity(PortainerCoordinatorEntity): f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", ), ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.stack_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self.stack_id}_{entity_description.key}" + ) @property def available(self) -> bool: @@ -173,3 +193,106 @@ class PortainerStackEntity(PortainerCoordinatorEntity): def stack_data(self) -> PortainerStackData: """Return the coordinator data for this stack.""" return self.coordinator.data[self.endpoint_id].stacks[self.device_name] + + +class PortainerDockerSystemDiskSpaceEndpointEntity( + PortainerDockerDiskSpaceCoordinatorEntity +): + """Base class for endpoint entities. + + Backed by the docker system disk space coordinator. + """ + + def __init__( + self, + coordinator: PortainerDockerDiskSpaceCoordinator, + entity_description: EntityDescription, + device_info: PortainerCoordinatorData, + ) -> None: + """Initialize a Portainer docker system disk space endpoint entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self.endpoint_id = device_info.endpoint.id + self._device_info = device_info + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.endpoint_id}") + }, + configuration_url=URL( + f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/dashboard" + ), + manufacturer=DEFAULT_NAME, + model="Endpoint", + name=device_info.endpoint.name, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{device_info.endpoint.id}" + f"_{entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.endpoint_id in self.coordinator.data + ) + + +class PortainerVolumeEntity(PortainerCoordinatorEntity): + """Base implementation for Portainer volume.""" + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: EntityDescription, + device_info: DockerVolume, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize a Portainer volume.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._device_info = device_info + self.volume_name = device_info.name + self.endpoint_id = via_device.endpoint.id + self.endpoint_name = via_device.endpoint.name + + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_volume_{self.volume_name}", + ) + }, + manufacturer=DEFAULT_NAME, + configuration_url=URL( + f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/volumes/{self.volume_name}" + ), + model="Volume", + name=self.volume_name, + via_device=( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + ), + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self.endpoint_id}_volume" + f"_{self.volume_name}_{entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return if the volume is available.""" + return ( + super().available + and self.endpoint_id in self.coordinator.data + and self.volume_name in self.coordinator.data[self.endpoint_id].volumes + ) + + @property + def volume_data(self) -> PortainerVolumeData: + """Return the coordinator data for this volume.""" + return self.coordinator.data[self.endpoint_id].volumes[self.volume_name] diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index db152c8c8ed..6404351b342 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -7,6 +7,9 @@ "pause_container": { "default": "mdi:pause-circle" }, + "recreate_container": { + "default": "mdi:creation" + }, "resume_container": { "default": "mdi:play" }, @@ -92,6 +95,9 @@ }, "volume_disk_usage_total_size": { "default": "mdi:harddisk" + }, + "volume_driver": { + "default": "mdi:docker" } }, "switch": { @@ -112,6 +118,9 @@ "services": { "prune_images": { "service": "mdi:delete-sweep" + }, + "recreate_container": { + "service": "mdi:restart" } } } diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index bd054dc9239..f60fe1e3070 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.37"] + "requirements": ["pyportainer==1.0.38"] } diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 8f5fdd2bdde..c6d5c2f66bb 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -1,11 +1,11 @@ """Sensor platform for Portainer integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +from itertools import chain from pyportainer import StackType +from pyportainer.models.docker import DockerSystemDF from homeassistant.components.sensor import ( EntityCategory, @@ -23,12 +23,15 @@ from .coordinator import ( PortainerConfigEntry, PortainerContainerData, PortainerStackData, + PortainerVolumeData, ) from .entity import ( PortainerContainerEntity, PortainerCoordinatorData, + PortainerDockerSystemDiskSpaceEndpointEntity, PortainerEndpointEntity, PortainerStackEntity, + PortainerVolumeEntity, ) PARALLEL_UPDATES = 0 @@ -55,6 +58,20 @@ class PortainerStackSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[PortainerStackData], StateType] +@dataclass(frozen=True, kw_only=True) +class PortainerDockerSystemDiskSpaceSensorEntityDescription(SensorEntityDescription): + """Class to hold Portainer docker system disk space sensor description.""" + + value_fn: Callable[[DockerSystemDF], StateType] + + +@dataclass(frozen=True, kw_only=True) +class PortainerVolumeSensorEntityDescription(SensorEntityDescription): + """Class to hold Portainer volume sensor description.""" + + value_fn: Callable[[PortainerVolumeData], StateType] + + CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( PortainerContainerSensorEntityDescription( key="image", @@ -139,6 +156,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ), ) + ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = ( PortainerEndpointSensorEntityDescription( key="api_version", @@ -236,50 +254,55 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), - PortainerEndpointSensorEntityDescription( +) + +DOCKER_SYSTEM_DISK_SPACE_SENSORS: tuple[ + PortainerDockerSystemDiskSpaceSensorEntityDescription, ... +] = ( + PortainerDockerSystemDiskSpaceSensorEntityDescription( key="container_disk_usage_reclaimable", translation_key="container_disk_usage_reclaimable", - value_fn=lambda data: data.docker_system_df.container_disk_usage.reclaimable, + value_fn=lambda data: data.container_disk_usage.reclaimable, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.BYTES, suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, entity_category=EntityCategory.DIAGNOSTIC, ), - PortainerEndpointSensorEntityDescription( + PortainerDockerSystemDiskSpaceSensorEntityDescription( key="container_disk_usage_total_size", translation_key="container_disk_usage_total_size", - value_fn=lambda data: data.docker_system_df.container_disk_usage.total_size, + value_fn=lambda data: data.container_disk_usage.total_size, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.BYTES, suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, entity_category=EntityCategory.DIAGNOSTIC, ), - PortainerEndpointSensorEntityDescription( + PortainerDockerSystemDiskSpaceSensorEntityDescription( key="image_disk_usage_reclaimable", translation_key="image_disk_usage_reclaimable", - value_fn=lambda data: data.docker_system_df.image_disk_usage.reclaimable, + value_fn=lambda data: data.image_disk_usage.reclaimable, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.BYTES, suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, entity_category=EntityCategory.DIAGNOSTIC, ), - PortainerEndpointSensorEntityDescription( + PortainerDockerSystemDiskSpaceSensorEntityDescription( key="image_disk_usage_total_size", translation_key="image_disk_usage_total_size", - value_fn=lambda data: data.docker_system_df.image_disk_usage.total_size, + value_fn=lambda data: data.image_disk_usage.total_size, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.BYTES, suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, entity_category=EntityCategory.DIAGNOSTIC, ), - PortainerEndpointSensorEntityDescription( + PortainerDockerSystemDiskSpaceSensorEntityDescription( key="volume_disk_usage_total", translation_key="volume_disk_usage_total_size", - value_fn=lambda data: data.docker_system_df.volume_disk_usage.total_size, + value_fn=lambda data: data.volume_disk_usage.total_size, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.BYTES, @@ -287,7 +310,6 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ) - STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( PortainerStackSensorEntityDescription( key="stack_type", @@ -313,6 +335,25 @@ STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ), ) +VOLUME_SENSORS: tuple[PortainerVolumeSensorEntityDescription, ...] = ( + PortainerVolumeSensorEntityDescription( + key="volume_driver", + translation_key="volume_driver", + value_fn=lambda data: data.volume.driver, + ), + PortainerVolumeSensorEntityDescription( + key="volume_size", + translation_key="volume_size", + value_fn=lambda data: ( + data.volume.usage_data.size if data.volume.usage_data else None + ), + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) async def async_setup_entry( @@ -322,18 +363,28 @@ async def async_setup_entry( ) -> None: """Set up Portainer sensors based on a config entry.""" coordinator = entry.runtime_data + ds_coordinator = coordinator.docker_disk_space + assert ds_coordinator is not None def _async_add_new_endpoints(endpoints: list[PortainerCoordinatorData]) -> None: - """Add new endpoint sensor.""" + """Add new endpoint sensors.""" async_add_entities( - PortainerEndpointSensor( - coordinator, - entity_description, - endpoint, + chain( + ( + PortainerEndpointSensor(coordinator, entity_description, endpoint) + for entity_description in ENDPOINT_SENSORS + for endpoint in endpoints + ), + ( + PortainerDockerSystemDiskSpaceSensor( + ds_coordinator, + entity_description, + endpoint, + ) + for entity_description in DOCKER_SYSTEM_DISK_SPACE_SENSORS + for endpoint in endpoints + ), ) - for entity_description in ENDPOINT_SENSORS - for endpoint in endpoints - if entity_description.value_fn(endpoint) ) def _async_add_new_containers( @@ -366,9 +417,25 @@ async def async_setup_entry( for entity_description in STACK_SENSORS ) + def _async_add_new_volumes( + volumes: list[tuple[PortainerCoordinatorData, PortainerVolumeData]], + ) -> None: + """Add new volume sensors.""" + async_add_entities( + PortainerVolumeSensor( + coordinator, + entity_description, + volume.volume, + endpoint, + ) + for (endpoint, volume) in volumes + for entity_description in VOLUME_SENSORS + ) + coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints) coordinator.new_containers_callbacks.append(_async_add_new_containers) coordinator.new_stacks_callbacks.append(_async_add_new_stacks) + coordinator.new_volumes_callbacks.append(_async_add_new_volumes) _async_add_new_endpoints( [ @@ -391,6 +458,13 @@ async def async_setup_entry( for stack in endpoint.stacks.values() ] ) + _async_add_new_volumes( + [ + (endpoint, volume) + for endpoint in coordinator.data.values() + for volume in endpoint.volumes.values() + ] + ) class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): @@ -416,6 +490,20 @@ class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity): return self.entity_description.value_fn(endpoint_data) +class PortainerDockerSystemDiskSpaceSensor( + PortainerDockerSystemDiskSpaceEndpointEntity, SensorEntity +): + """Representation of a Portainer docker system disk space sensor.""" + + entity_description: PortainerDockerSystemDiskSpaceSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + endpoint_data = self.coordinator.data[self._device_info.endpoint.id] + return self.entity_description.value_fn(endpoint_data) + + class PortainerStackSensor(PortainerStackEntity, SensorEntity): """Representation of a Portainer stack sensor.""" @@ -425,3 +513,14 @@ class PortainerStackSensor(PortainerStackEntity, SensorEntity): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.stack_data) + + +class PortainerVolumeSensor(PortainerVolumeEntity, SensorEntity): + """Representation of a Portainer volume sensor.""" + + entity_description: PortainerVolumeSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.volume_data) diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index ad1e8a82e28..b1dd01675c0 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -20,6 +20,9 @@ from .coordinator import PortainerConfigEntry ATTR_DATE_UNTIL = "until" ATTR_DANGLING = "dangling" +ATTR_TIMEOUT = "timeout" +ATTR_PULL_IMAGE = "pull_image" +ATTR_CONTAINER_DEVICE_ID = "container_device_id" SERVICE_PRUNE_IMAGES = "prune_images" SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema( @@ -32,6 +35,17 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema( }, ) +SERVICE_RECREATE_CONTAINER = "recreate_container" +SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string, + vol.Optional(ATTR_TIMEOUT): vol.All( + cv.time_period, vol.Range(min=timedelta(minutes=1)) + ), + vol.Optional(ATTR_PULL_IMAGE): cv.boolean, + } +) + async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry: """Extract config entry from the service call.""" @@ -59,20 +73,66 @@ async def _get_endpoint_id( device_reg = dr.async_get(call.hass) device_id = call.data[ATTR_DEVICE_ID] device = device_reg.async_get(device_id) - assert device + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + coordinator = config_entry.runtime_data - endpoint_data = None for data in coordinator.data.values(): if ( DOMAIN, f"{config_entry.entry_id}_{data.endpoint.id}", ) in device.identifiers: - endpoint_data = data + return data.endpoint.id + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + +async def _get_container_and_endpoint_ids( + call: ServiceCall, +) -> tuple[PortainerConfigEntry, int, str]: + """Get config entry, endpoint ID and container ID from the container device ID.""" + device_reg = dr.async_get(call.hass) + device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID]) + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + config_entry: PortainerConfigEntry | None = None + for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + if loaded_entry.entry_id in device.config_entries: + config_entry = loaded_entry break - assert endpoint_data - return endpoint_data.endpoint.id + if config_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + coordinator = config_entry.runtime_data + for data in coordinator.data.values(): + for container_name, container_data in data.containers.items(): + if ( + DOMAIN, + f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}", + ) in device.identifiers: + return config_entry, data.endpoint.id, container_data.container.id + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) async def prune_images(call: ServiceCall) -> None: @@ -104,6 +164,40 @@ async def prune_images(call: ServiceCall) -> None: ) from err +async def recreate_container(call: ServiceCall) -> None: + """Recreate a container in Portainer, with more controls.""" + config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids( + call + ) + coordinator = config_entry.runtime_data + timeout: timedelta | None = call.data.get(ATTR_TIMEOUT) + + try: + await coordinator.portainer.container_recreate( + endpoint_id=endpoint_id, + container_id=container_id, + **({"timeout": timeout} if timeout is not None else {}), + pull_image=call.data.get(ATTR_PULL_IMAGE, False), + ) + except PortainerAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth_no_details", + ) from err + except PortainerConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect_no_details", + ) from err + except PortainerTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect_no_details", + ) from err + + await coordinator.async_request_refresh() + + async def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" @@ -113,3 +207,10 @@ async def async_setup_services(hass: HomeAssistant) -> None: prune_images, SERVICE_PRUNE_IMAGES_SCHEMA, ) + + hass.services.async_register( + DOMAIN, + SERVICE_RECREATE_CONTAINER, + recreate_container, + SERVICE_RECREATE_CONTAINER_SCHEMA, + ) diff --git a/homeassistant/components/portainer/services.yaml b/homeassistant/components/portainer/services.yaml index 82be879fbd9..383c02f94fd 100644 --- a/homeassistant/components/portainer/services.yaml +++ b/homeassistant/components/portainer/services.yaml @@ -16,3 +16,20 @@ prune_images: required: false selector: boolean: {} + +recreate_container: + fields: + container_device_id: + required: true + selector: + device: + integration: portainer + model: Container + timeout: + required: false + selector: + duration: + pull_image: + required: false + selector: + boolean: diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d1d2d99839f..d39cddc8c6c 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -72,6 +72,9 @@ "pause_container": { "name": "Pause container" }, + "recreate_container": { + "name": "Recreate container" + }, "restart_container": { "name": "Restart container" }, @@ -174,6 +177,12 @@ }, "volume_disk_usage_total_size": { "name": "Volume disk usage total size" + }, + "volume_driver": { + "name": "Volume driver" + }, + "volume_size": { + "name": "Volume size" } }, "switch": { @@ -226,6 +235,29 @@ } }, "name": "Prune unused images" + }, + "recreate_container": { + "description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.", + "fields": { + "container_device_id": { + "description": "The container to recreate.", + "name": "Container" + }, + "pull_image": { + "description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.", + "name": "Pull image" + }, + "timeout": { + "description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.", + "name": "Timeout" + } + }, + "name": "Recreate container" + } + }, + "system_health": { + "info": { + "can_reach_server": "Reach Portainer server" } } } diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 2b162abe98c..ca9b7209326 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -1,7 +1,5 @@ """Switch platform for Portainer containers.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/portainer/system_health.py b/homeassistant/components/portainer/system_health.py new file mode 100644 index 00000000000..7fda712884b --- /dev/null +++ b/homeassistant/components/portainer/system_health.py @@ -0,0 +1,28 @@ +"""Provide info to system health.""" + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + return { + "can_reach_server": system_health.async_check_can_reach_url( + hass, f"{config_entry.data[CONF_URL].rstrip('/')}/api/system/status" + ), + } diff --git a/homeassistant/components/power/__init__.py b/homeassistant/components/power/__init__.py index 87636a72167..609a3e2b99f 100644 --- a/homeassistant/components/power/__init__.py +++ b/homeassistant/components/power/__init__.py @@ -1,7 +1,5 @@ """Integration for power triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/power/condition.py b/homeassistant/components/power/condition.py index 114417a8d57..b276ef36d70 100644 --- a/homeassistant/components/power/condition.py +++ b/homeassistant/components/power/condition.py @@ -1,7 +1,5 @@ """Provides conditions for power.""" -from __future__ import annotations - from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index 63f2c82b20f..1776a1a46d4 100644 --- a/homeassistant/components/power/conditions.yaml +++ b/homeassistant/components/power/conditions.yaml @@ -2,11 +2,14 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + +.condition_for: &condition_for + required: true + default: 00:00:00 + selector: + duration: .power_units: &power_units - "mW" @@ -32,6 +35,7 @@ is_value: device_class: power fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json index 9be4af702e5..18d724d67cd 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::power::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::power::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Power value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Power", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::power::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::power::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index b43dc072f7a..4630bef905a 100644 --- a/homeassistant/components/power/trigger.py +++ b/homeassistant/components/power/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for power.""" -from __future__ import annotations - from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -22,8 +20,10 @@ TRIGGERS: dict[str, type[Trigger]] = { "changed": make_entity_numerical_state_changed_with_unit_trigger( POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter ), - "crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter + "crossed_threshold": ( + make_entity_numerical_state_crossed_threshold_with_unit_trigger( + POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter + ) ), } diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml index 22dac96db36..6636dae882b 100644 --- a/homeassistant/components/power/triggers.yaml +++ b/homeassistant/components/power/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .power_units: &power_units - "mW" @@ -49,6 +50,7 @@ crossed_threshold: target: *trigger_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 161b8c55e65..254fe45f0ce 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -1,7 +1,5 @@ """The Powerfox integration.""" -from __future__ import annotations - import asyncio from powerfox import ( diff --git a/homeassistant/components/powerfox/config_flow.py b/homeassistant/components/powerfox/config_flow.py index dd17badf881..1bc988e34b1 100644 --- a/homeassistant/components/powerfox/config_flow.py +++ b/homeassistant/components/powerfox/config_flow.py @@ -1,14 +1,16 @@ """Config flow for Powerfox integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -38,26 +40,27 @@ class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) - client = Powerfox( - username=user_input[CONF_EMAIL], - password=user_input[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - try: - await client.all_devices() - except PowerfoxAuthenticationError: - errors["base"] = "invalid_auth" - except PowerfoxConnectionError: - errors["base"] = "cannot_connect" + if error: + errors["base"] = error + elif self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + if reconfigure_entry.data[CONF_EMAIL] != user_input[CONF_EMAIL]: + self._async_abort_entries_match( + {CONF_EMAIL: user_input[CONF_EMAIL]} + ) + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) else: + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) return self.async_create_entry( title=user_input[CONF_EMAIL], - data={ - CONF_EMAIL: user_input[CONF_EMAIL], - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, + data=user_input, ) + return self.async_show_form( step_id="user", errors=errors, @@ -78,22 +81,17 @@ class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - client = Powerfox( - username=reauth_entry.data[CONF_EMAIL], - password=user_input[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + error = await self._async_validate_credentials( + reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD] ) - try: - await client.all_devices() - except PowerfoxAuthenticationError: - errors["base"] = "invalid_auth" - except PowerfoxConnectionError: - errors["base"] = "cannot_connect" + if error: + errors["base"] = error else: return self.async_update_reload_and_abort( reauth_entry, data_updates=user_input, ) + return self.async_show_form( step_id="reauth_confirm", description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, @@ -104,32 +102,22 @@ class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reconfigure Powerfox configuration.""" - errors = {} + """Handle reconfiguration.""" + return await self.async_step_user() - reconfigure_entry = self._get_reconfigure_entry() - if user_input is not None: - client = Powerfox( - username=user_input[CONF_EMAIL], - password=user_input[CONF_PASSWORD], - session=async_get_clientsession(self.hass), - ) - try: - await client.all_devices() - except PowerfoxAuthenticationError: - errors["base"] = "invalid_auth" - except PowerfoxConnectionError: - errors["base"] = "cannot_connect" - else: - if reconfigure_entry.data[CONF_EMAIL] != user_input[CONF_EMAIL]: - self._async_abort_entries_match( - {CONF_EMAIL: user_input[CONF_EMAIL]} - ) - return self.async_update_reload_and_abort( - reconfigure_entry, data_updates=user_input - ) - return self.async_show_form( - step_id="reconfigure", - data_schema=STEP_USER_DATA_SCHEMA, - errors=errors, + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate credentials and return error string or None if valid.""" + client = Powerfox( + username=email, + password=password, + session=async_get_clientsession(self.hass), ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + return "invalid_auth" + except PowerfoxConnectionError: + return "cannot_connect" + return None diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py index 790f241ae8e..4119077c6c7 100644 --- a/homeassistant/components/powerfox/const.py +++ b/homeassistant/components/powerfox/const.py @@ -1,7 +1,5 @@ """Constants for the Powerfox integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index ae0de87d3ee..9cb45bd94d0 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Powerfox integration.""" -from __future__ import annotations - from datetime import datetime from powerfox import ( @@ -24,7 +22,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL type PowerfoxCoordinator = ( - "PowerfoxDataUpdateCoordinator" | "PowerfoxReportDataUpdateCoordinator" + PowerfoxDataUpdateCoordinator | PowerfoxReportDataUpdateCoordinator ) type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxCoordinator]] diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py index 18d68c68ae6..c4adf7a85df 100644 --- a/homeassistant/components/powerfox/diagnostics.py +++ b/homeassistant/components/powerfox/diagnostics.py @@ -1,7 +1,5 @@ """Support for Powerfox diagnostics.""" -from __future__ import annotations - from datetime import datetime from typing import Any @@ -31,8 +29,12 @@ async def async_get_config_entry_diagnostics( "power": coordinator.data.power, "energy_usage": coordinator.data.energy_usage, "energy_return": coordinator.data.energy_return, - "energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff, - "energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff, + "energy_usage_high_tariff": ( + coordinator.data.energy_usage_high_tariff + ), + "energy_usage_low_tariff": ( + coordinator.data.energy_usage_low_tariff + ), } } if isinstance(coordinator.data, PowerMeter) @@ -74,8 +76,12 @@ async def async_get_config_entry_diagnostics( "sum": coordinator.data.gas.sum, "consumption": coordinator.data.gas.consumption, "consumption_kwh": coordinator.data.gas.consumption_kwh, - "current_consumption": coordinator.data.gas.current_consumption, - "current_consumption_kwh": coordinator.data.gas.current_consumption_kwh, + "current_consumption": ( + coordinator.data.gas.current_consumption + ), + "current_consumption_kwh": ( + coordinator.data.gas.current_consumption_kwh + ), "sum_currency": coordinator.data.gas.sum_currency, } } diff --git a/homeassistant/components/powerfox/entity.py b/homeassistant/components/powerfox/entity.py index 619a6188b58..0526e0ef818 100644 --- a/homeassistant/components/powerfox/entity.py +++ b/homeassistant/components/powerfox/entity.py @@ -1,7 +1,5 @@ """Generic entity for Powerfox.""" -from __future__ import annotations - from typing import Any from powerfox import Device diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py index 0ba564bd843..b46d9653186 100644 --- a/homeassistant/components/powerfox/sensor.py +++ b/homeassistant/components/powerfox/sensor.py @@ -1,7 +1,5 @@ """Sensors for Powerfox integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json index 6b98677cf19..cb3598e0a41 100644 --- a/homeassistant/components/powerfox/strings.json +++ b/homeassistant/components/powerfox/strings.json @@ -20,18 +20,6 @@ "description": "The password for {email} is no longer valid.", "title": "[%key:common::config_flow::title::reauth%]" }, - "reconfigure": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "email": "[%key:component::powerfox::config::step::user::data_description::email%]", - "password": "[%key:component::powerfox::config::step::user::data_description::password%]" - }, - "description": "Powerfox is already configured. Would you like to reconfigure it?", - "title": "Reconfigure your Powerfox account" - }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/homeassistant/components/powerfox_local/__init__.py b/homeassistant/components/powerfox_local/__init__.py index 89398607fa7..789a05ebf09 100644 --- a/homeassistant/components/powerfox_local/__init__.py +++ b/homeassistant/components/powerfox_local/__init__.py @@ -1,7 +1,5 @@ """The Powerfox Local integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/powerfox_local/config_flow.py b/homeassistant/components/powerfox_local/config_flow.py index 61850cf28e5..c4dd0daa409 100644 --- a/homeassistant/components/powerfox_local/config_flow.py +++ b/homeassistant/components/powerfox_local/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Powerfox Local integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/powerfox_local/const.py b/homeassistant/components/powerfox_local/const.py index f600db578ae..35258baa597 100644 --- a/homeassistant/components/powerfox_local/const.py +++ b/homeassistant/components/powerfox_local/const.py @@ -1,7 +1,5 @@ """Constants for the Powerfox Local integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/powerfox_local/coordinator.py b/homeassistant/components/powerfox_local/coordinator.py index b8a2bfe8a23..cb6e5eade4a 100644 --- a/homeassistant/components/powerfox_local/coordinator.py +++ b/homeassistant/components/powerfox_local/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Powerfox Local integration.""" -from __future__ import annotations - from powerfox import ( LocalResponse, PowerfoxAuthenticationError, diff --git a/homeassistant/components/powerfox_local/diagnostics.py b/homeassistant/components/powerfox_local/diagnostics.py index 7cfd196cf5a..8d01cfcf1cb 100644 --- a/homeassistant/components/powerfox_local/diagnostics.py +++ b/homeassistant/components/powerfox_local/diagnostics.py @@ -1,7 +1,5 @@ """Support for Powerfox Local diagnostics.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/powerfox_local/entity.py b/homeassistant/components/powerfox_local/entity.py index afa49a6c16c..bccd40b1748 100644 --- a/homeassistant/components/powerfox_local/entity.py +++ b/homeassistant/components/powerfox_local/entity.py @@ -1,7 +1,5 @@ """Base entity for Powerfox Local.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/powerfox_local/sensor.py b/homeassistant/components/powerfox_local/sensor.py index 10c03c05db2..56558491eeb 100644 --- a/homeassistant/components/powerfox_local/sensor.py +++ b/homeassistant/components/powerfox_local/sensor.py @@ -1,7 +1,5 @@ """Sensors for Powerfox Local integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index f2eea199df5..4ee8b433879 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -1,7 +1,5 @@ """The Tesla Powerwall integration.""" -from __future__ import annotations - from contextlib import AsyncExitStack import logging @@ -10,6 +8,7 @@ from tesla_powerwall import ( AccessDeniedError, ApiError, MissingAttributeError, + OperationMode, Powerwall, PowerwallUnreachableError, ) @@ -180,7 +179,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> except (TimeoutError, PowerwallUnreachableError) as err: raise ConfigEntryNotReady from err except MissingAttributeError as err: - # The error might include some important information about what exactly changed. + # The error might include some important + # information about what exactly changed. _LOGGER.error("The powerwall api has changed: %s", str(err)) persistent_notification.async_create( hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE @@ -302,6 +302,14 @@ async def get_backup_reserve_percentage(power_wall: Powerwall) -> float | None: return None +async def get_operation_mode(power_wall: Powerwall) -> OperationMode | None: + """Return the operation mode.""" + try: + return await power_wall.get_operation_mode() + except MissingAttributeError: + return None + + async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: """Process and update powerwall data.""" # We await each call individually since the powerwall @@ -309,6 +317,7 @@ async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: # as its faster than establishing a new connection when # run concurrently. backup_reserve = await get_backup_reserve_percentage(power_wall) + operation_mode = await get_operation_mode(power_wall) charge = await power_wall.get_charge() site_master = await power_wall.get_sitemaster() meters = await power_wall.get_meters() @@ -322,6 +331,7 @@ async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: grid_services_active=grid_services_active, grid_status=grid_status, backup_reserve=backup_reserve, + operation_mode=operation_mode, batteries={battery.serial_number: battery for battery in batteries}, ) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index b082016e562..6e065fb81b8 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tesla Powerwall integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/powerwall/coordinator.py b/homeassistant/components/powerwall/coordinator.py index 80546460c15..bcbf5f585b6 100644 --- a/homeassistant/components/powerwall/coordinator.py +++ b/homeassistant/components/powerwall/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Tesla Powerwall integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -12,6 +10,7 @@ from tesla_powerwall import ( DeviceType, GridStatus, MetersAggregatesResponse, + OperationMode, Powerwall, PowerwallStatusResponse, SiteInfoResponse, @@ -55,6 +54,7 @@ class PowerwallData: grid_services_active: bool grid_status: GridStatus backup_reserve: float | None + operation_mode: OperationMode | None batteries: dict[str, BatteryResponse] diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 928e9797ec3..097fb7b411f 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["tesla_powerwall"], - "requirements": ["tesla-powerwall==0.5.2"] + "requirements": ["tesla-powerwall==0.5.3"] } diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b8df599feb6..e9aa186d52c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -1,13 +1,17 @@ """Support for powerwall sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter, methodcaller from typing import TYPE_CHECKING -from tesla_powerwall import BatteryResponse, GridState, MeterResponse, MeterType +from tesla_powerwall import ( + BatteryResponse, + GridState, + MeterResponse, + MeterType, + OperationMode, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -227,6 +231,9 @@ async def async_setup_entry( if data.backup_reserve is not None: entities.append(PowerWallBackupReserveSensor(powerwall_data)) + if data.operation_mode is not None: + entities.append(PowerWallOperationModeSensor(powerwall_data)) + for meter in data.meters.meters: entities.append(PowerWallExportSensor(powerwall_data, meter)) entities.append(PowerWallImportSensor(powerwall_data, meter)) @@ -311,6 +318,26 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): return round(self.data.backup_reserve) +class PowerWallOperationModeSensor(PowerWallEntity, SensorEntity): + """Representation of the Powerwall operation mode.""" + + _attr_translation_key = "operation_mode" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [mode.value for mode in OperationMode] + + @property + def unique_id(self) -> str: + """Device Uniqueid.""" + return f"{self.base_unique_id}_operation_mode" + + @property + def native_value(self) -> str | None: + """Get the current operation mode.""" + if self.data.operation_mode is None: + return None + return self.data.operation_mode.value + + class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Direction Energy sensor.""" diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 2b65308198b..eef164b6179 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -142,6 +142,14 @@ "load_instant_voltage": { "name": "Load voltage" }, + "operation_mode": { + "name": "Operation mode", + "state": { + "autonomous": "Autonomous", + "backup": "Backup", + "self_consumption": "Self-consumption" + } + }, "site_export": { "name": "Site export" }, diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 685faf73f96..9ac4dc0b9e2 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -62,7 +62,7 @@ class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity): f"Setting off-grid operation to {island_mode} failed: {ex}" ) from ex - self._attr_is_on = island_mode == IslandMode.OFFGRID + self._attr_is_on = island_mode is IslandMode.OFFGRID self.async_write_ha_state() await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/prana/__init__.py b/homeassistant/components/prana/__init__.py index 68c3a7f2f65..ed2eda1f4a3 100644 --- a/homeassistant/components/prana/__init__.py +++ b/homeassistant/components/prana/__init__.py @@ -3,8 +3,6 @@ Sets up the update coordinator and forwards platform setups. """ -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/prana/fan.py b/homeassistant/components/prana/fan.py index 58948720631..23793ca941f 100644 --- a/homeassistant/components/prana/fan.py +++ b/homeassistant/components/prana/fan.py @@ -106,7 +106,8 @@ class PranaFan(PranaBaseEntity, FanEntity): @property def _api_target_key(self) -> str: """Return the correct target key for API commands based on bounded state.""" - # If the device is in bound mode, both supply and extract fans control the same bounded fan speeds. + # If the device is in bound mode, both supply and + # extract fans control the same bounded fan speeds. if self.coordinator.data.bound: return PranaFanType.BOUNDED # Otherwise, return the specific fan type (supply or extract) for API commands. diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py index ab4de9ef04d..16a8156d075 100644 --- a/homeassistant/components/private_ble_device/__init__.py +++ b/homeassistant/components/private_ble_device/__init__.py @@ -1,7 +1,5 @@ """Private BLE Device integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index 90340bc70fa..887f40a01ae 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the BLE Tracker.""" -from __future__ import annotations - import base64 import binascii import logging diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 3e7bafed748..95d20d7ff2b 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -1,7 +1,5 @@ """Central manager for tracking devices with random but resolvable MAC addresses.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import cast @@ -26,8 +24,8 @@ def async_last_service_info( ) -> bluetooth.BluetoothServiceInfoBleak | None: """Find a BluetoothServiceInfoBleak for the irk. - This iterates over all currently visible mac addresses and checks them against `irk`. - It returns the newest. + This iterates over all currently visible mac addresses + and checks them against `irk`. It returns the newest. """ # This can't use existing data collected by the coordinator - its called when @@ -45,12 +43,14 @@ def async_last_service_info( class PrivateDevicesCoordinator: - """Monitor private bluetooth devices and correlate them with known IRK. + """Monitor private bluetooth devices and correlate with IRK. - This class should not be instanced directly - use `async_get_coordinator` to get an instance. + This class should not be instanced directly - use + `async_get_coordinator` to get an instance. - There is a single shared coordinator for all instances of this integration. This is to avoid - unnecessary hashing (AES) operations as much as possible. + There is a single shared coordinator for all instances + of this integration. This is to avoid unnecessary + hashing (AES) operations as much as possible. """ def __init__(self, hass: HomeAssistant) -> None: @@ -94,7 +94,8 @@ class PrivateDevicesCoordinator: def _async_track_unavailable( self, service_info: bluetooth.BluetoothServiceInfoBleak ) -> None: - # This should be called when the current MAC address associated with an IRK goes away. + # This should be called when the current MAC address + # associated with an IRK goes away. if resolved := self._mac_to_irk.get(service_info.address): if callbacks := self._unavailable_callbacks.get(resolved): for cb in callbacks: @@ -242,6 +243,8 @@ def async_get_coordinator(hass: HomeAssistant) -> PrivateDevicesCoordinator: if existing := hass.data.get(DOMAIN): return cast(PrivateDevicesCoordinator, existing) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data pdm = hass.data[DOMAIN] = PrivateDevicesCoordinator(hass) return pdm diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py index eaccbd6c785..3661f84e43f 100644 --- a/homeassistant/components/private_ble_device/device_tracker.py +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -1,15 +1,11 @@ """Tracking for bluetooth low energy devices.""" -from __future__ import annotations - from collections.abc import Mapping import logging from homeassistant.components import bluetooth -from homeassistant.components.device_tracker import SourceType -from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.components.device_tracker import BaseScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,11 +23,12 @@ async def async_setup_entry( async_add_entities([BasePrivateDeviceTracker(config_entry)]) -class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity): +class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseScannerEntity): """A trackable Private Bluetooth Device.""" _attr_should_poll = False _attr_has_entity_name = True + _attr_source_type: SourceType = SourceType.BLUETOOTH_LE _attr_translation_key = "device_tracker" _attr_name = None @@ -62,11 +59,6 @@ class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity): self.async_write_ha_state() @property - def state(self) -> str: - """Return the state of the device.""" - return STATE_HOME if self._last_info else STATE_NOT_HOME - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.BLUETOOTH_LE + def is_connected(self) -> bool: + """Return true if the device is connected.""" + return bool(self._last_info) diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py index 2c574805c53..87090eae1fc 100644 --- a/homeassistant/components/private_ble_device/entity.py +++ b/homeassistant/components/private_ble_device/entity.py @@ -1,7 +1,5 @@ """Tracking for bluetooth low energy devices.""" -from __future__ import annotations - from abc import abstractmethod import binascii @@ -72,4 +70,4 @@ class BasePrivateDeviceEntity(Entity): service_info: bluetooth.BluetoothServiceInfoBleak, change: bluetooth.BluetoothChange, ) -> None: - """Respond when the bluetooth device being tracked broadcasted updated information.""" + """Respond when the tracked device broadcasted updated info.""" diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 26a2954d289..386dcb0ac9b 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.4"] + "requirements": ["bluetooth-data-tools==1.29.18"] } diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index aee2a22e977..7766e3e8c6c 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -1,7 +1,5 @@ """Support for Private BLE Device sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/probe_plus/__init__.py b/homeassistant/components/probe_plus/__init__.py index 0d29fb86b59..1319c17c58b 100644 --- a/homeassistant/components/probe_plus/__init__.py +++ b/homeassistant/components/probe_plus/__init__.py @@ -1,7 +1,5 @@ """The Probe Plus integration.""" -from __future__ import annotations - from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/probe_plus/config_flow.py b/homeassistant/components/probe_plus/config_flow.py index c73a0cab861..e30d8eea36a 100644 --- a/homeassistant/components/probe_plus/config_flow.py +++ b/homeassistant/components/probe_plus/config_flow.py @@ -1,7 +1,5 @@ """Config flow for probe_plus integration.""" -from __future__ import annotations - import dataclasses import logging from typing import Any diff --git a/homeassistant/components/probe_plus/coordinator.py b/homeassistant/components/probe_plus/coordinator.py index 1e37340726b..07b967f6ea7 100644 --- a/homeassistant/components/probe_plus/coordinator.py +++ b/homeassistant/components/probe_plus/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the probe_plus integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 66b35eaff21..39463745a0f 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -70,7 +70,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_MAX_OBJECTS = 5 -CONF_ENABLED = "enabled" CONF_SECONDS = "seconds" CONF_MAX_OBJECTS = "max_objects" @@ -85,6 +84,8 @@ async def async_setup_entry( # noqa: C901 ) -> bool: """Set up Profiler from a config entry.""" lock = asyncio.Lock() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data domain_data = hass.data[DOMAIN] = {} async def _async_run_profile(call: ServiceCall) -> None: @@ -283,6 +284,7 @@ async def async_setup_entry( # noqa: C901 base_logger.setLevel(logging.INFO) hass.loop.set_debug(enabled) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -293,6 +295,7 @@ async def async_setup_entry( # noqa: C901 ), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -303,6 +306,7 @@ async def async_setup_entry( # noqa: C901 ), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -317,6 +321,7 @@ async def async_setup_entry( # noqa: C901 ), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -324,6 +329,7 @@ async def async_setup_entry( # noqa: C901 _async_stop_log_objects, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -341,6 +347,7 @@ async def async_setup_entry( # noqa: C901 ), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -348,6 +355,7 @@ async def async_setup_entry( # noqa: C901 _async_stop_object_sources, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -356,6 +364,7 @@ async def async_setup_entry( # noqa: C901 schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -363,6 +372,7 @@ async def async_setup_entry( # noqa: C901 _dump_sockets, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -370,6 +380,7 @@ async def async_setup_entry( # noqa: C901 _lru_stats, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -377,6 +388,7 @@ async def async_setup_entry( # noqa: C901 _async_dump_thread_frames, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -384,6 +396,7 @@ async def async_setup_entry( # noqa: C901 _async_dump_scheduled, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, @@ -392,6 +405,7 @@ async def async_setup_entry( # noqa: C901 schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service( hass, DOMAIN, diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 14b2f09018d..960d883f627 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -1,7 +1,5 @@ """Support for Proliphix NT10e Thermostats.""" -from __future__ import annotations - from typing import Any import proliphix diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 2596e0077bb..00f93bcfa7d 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -1,7 +1,5 @@ """Support for Prometheus metrics export.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Sequence from dataclasses import astuple, dataclass @@ -44,7 +42,8 @@ from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMID from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.sensor import SensorDeviceClass -# Alias water_heater constants to avoid name clashes with similarly named climate constants +# Alias water_heater constants to avoid name clashes with +# similarly named climate constants from homeassistant.components.water_heater import ( ATTR_AWAY_MODE as WATER_HEATER_ATTR_AWAY_MODE, ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, @@ -59,6 +58,8 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MODE, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, @@ -72,6 +73,7 @@ from homeassistant.const import ( STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfLength, UnitOfTemperature, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State @@ -105,7 +107,7 @@ from homeassistant.helpers.floor_registry import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_timestamp -from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_conversion import DistanceConverter, TemperatureConverter _LOGGER = logging.getLogger(__name__) @@ -349,7 +351,10 @@ class PrometheusMetrics: def handle_entity_registry_updated( self, event: Event[EventEntityRegistryUpdatedData] ) -> None: - """Listen for deleted, disabled or renamed entities and remove them from the Prometheus Registry.""" + """Listen for entity changes and remove from Prometheus Registry. + + Handles deleted, disabled, or renamed entities. + """ if event.data["action"] in (None, "create"): return @@ -491,7 +496,7 @@ class PrometheusMetrics: entity_id: str, ignored_metric_names: set[str] | None = None, ) -> None: - """Remove labelsets matching the given entity id from all non-ignored metrics.""" + """Remove labelsets matching the entity id from non-ignored metrics.""" if ignored_metric_names is None: ignored_metric_names = set() metric_set = self._metrics_by_entity_id[entity_id] @@ -555,7 +560,7 @@ class PrometheusMetrics: @staticmethod def _sanitize_metric_name(metric: str) -> str: - metric.replace("\u03bc", "\u00b5") + metric = metric.replace("\u03bc", "\u00b5") return "".join( [c if c in ALLOWED_METRIC_CHARS else f"u{hex(ord(c))}" for c in metric] ) @@ -752,7 +757,7 @@ class PrometheusMetrics: metric.set(value) def _handle_binary_sensor(self, state: State) -> None: - self._numeric_metric(state, "binary_sensor", "binary boolean") + self._numeric_metric(state, "binary_sensor", "binary sensor") def _handle_input_boolean(self, state: State) -> None: self._numeric_metric(state, "input_boolean", "input boolean") @@ -769,6 +774,33 @@ class PrometheusMetrics: def _handle_person(self, state: State) -> None: self._numeric_metric(state, "person", "person") + def _handle_geo_location(self, state: State) -> None: + labels = self._labels(state, {"source": state.attributes.get("source", "")}) + if (value := self.state_as_number(state)) is not None: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit is not None: + value = DistanceConverter.convert(value, unit, UnitOfLength.METERS) + self._metric( + "geo_location_distance_meters", + prometheus_client.Gauge, + "Distance of the geo location event from home in meters", + labels, + ).set(value) + if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None: + self._metric( + "geo_location_latitude_degrees", + prometheus_client.Gauge, + "Latitude of the geo location event in degrees", + labels, + ).set(latitude) + if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None: + self._metric( + "geo_location_longitude_degrees", + prometheus_client.Gauge, + "Longitude of the geo location event in degrees", + labels, + ).set(longitude) + def _handle_lock(self, state: State) -> None: self._numeric_metric(state, "lock", "lock") @@ -903,7 +935,7 @@ class PrometheusMetrics: state, WATER_HEATER_ATTR_CURRENT_TEMPERATURE, "water_heater_current_temperature_celsius", - "Target temperature in degrees Celsius", + "Current temperature in degrees Celsius", ) self._temperature_metric( state, @@ -1060,7 +1092,10 @@ class PrometheusMetrics: @staticmethod def _sensor_timestamp_metric(state: State, unit: str | None) -> str | None: - """Get metric for timestamp sensors, which have no unit of measurement attribute.""" + """Get metric for timestamp sensors. + + These have no unit of measurement attribute. + """ metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 335737b40ed..0d48d6fe3e2 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Prosegur alarm control panels.""" -from __future__ import annotations - import logging from pyprosegur.auth import Auth diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 59bae6f71f0..24f0ddf9163 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -1,7 +1,5 @@ """Support for Prosegur cameras.""" -from __future__ import annotations - import logging from pyprosegur.auth import Auth @@ -83,6 +81,7 @@ class ProsegurCamera(Camera): try: return await self._installation.get_image(self._auth, self._camera.id) + # pylint: disable-next=home-assistant-action-swallowed-exception except ProsegurException as err: _LOGGER.error("Image %s doesn't exist: %s", self._camera.description, err) @@ -95,6 +94,7 @@ class ProsegurCamera(Camera): try: await self._installation.request_image(self._auth, self._camera.id) + # pylint: disable-next=home-assistant-action-swallowed-exception except ProsegurException as err: _LOGGER.error( "Could not request image from camera %s: %s", diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py index 944a84c3acb..4639973d4e8 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Prosegur.""" -from __future__ import annotations - from typing import Any from pyprosegur.installation import Installation diff --git a/homeassistant/components/prowl/config_flow.py b/homeassistant/components/prowl/config_flow.py index cea3ee6e106..275973d1510 100644 --- a/homeassistant/components/prowl/config_flow.py +++ b/homeassistant/components/prowl/config_flow.py @@ -1,7 +1,5 @@ """The config flow for the Prowl component.""" -from __future__ import annotations - import logging from typing import Any @@ -47,6 +45,8 @@ class ProwlConfigFlow(ConfigFlow, domain=DOMAIN): vol.Schema( { vol.Required(CONF_API_KEY): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME): str, }, ), diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json index deac43d1657..9c31ce7965e 100644 --- a/homeassistant/components/prowl/manifest.json +++ b/homeassistant/components/prowl/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["prowl"], - "requirements": ["prowlpy==1.1.1"] + "requirements": ["prowlpy==1.1.5"] } diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index d013480417a..5fcd89bb4c4 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -1,7 +1,5 @@ """Prowl notification service.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 4dc87554055..1ac625fd217 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -1,7 +1,5 @@ """Support for tracking the proximity of a device.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index f60dcfae7b5..c6af10f1eb5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -1,7 +1,5 @@ """Config flow for proximity.""" -from __future__ import annotations - from typing import Any, cast import voluptuous as vol diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 856138c9051..61e2157a870 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -171,7 +171,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): longitude, ) - # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + # it is ensured, that distance can't be None, + # since zones must have lat/lon coordinates assert distance_to_centre is not None zone_radius: float = zone.attributes["radius"] @@ -218,7 +219,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): new_longitude, ) - # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + # it is ensured, that distance can't be None, + # since zones must have lat/lon coordinates assert old_distance is not None assert new_distance is not None distance_travelled = round(new_distance - old_distance, 1) diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index 805cbc192f9..c304b4822f3 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Proximity.""" -from __future__ import annotations - from typing import Any from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 72203a2dff4..6148204a771 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -1,7 +1,5 @@ """Support for Proximity sensors.""" -from __future__ import annotations - from typing import NamedTuple from homeassistant.components.sensor import ( @@ -175,7 +173,11 @@ class ProximityTrackedEntitySensor( self.entity_description = description self.tracked_entity_id = tracked_entity_descriptor.entity_id - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{tracked_entity_descriptor.identifier}" + f"_{description.key}" + ) self._attr_device_info = _device_info(coordinator) self._attr_translation_placeholders = { "tracked_entity": tracked_entity_descriptor.name diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6512b1761cd..2f969fae5be 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -1,7 +1,5 @@ """Support for Proxmox VE.""" -from __future__ import annotations - import logging import voluptuous as vol @@ -11,6 +9,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -33,7 +32,6 @@ from .const import ( CONF_NODE, CONF_NODES, CONF_REALM, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, CONF_VMS, @@ -189,9 +187,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> # Migration for additional configuration options added to support API tokens if entry.version < 3: data = dict(entry.data) + # If CONF_REALM wasn't there yet, extract from username + if CONF_REALM not in data: + data[CONF_REALM] = DEFAULT_REALM + if "@" in data.get(CONF_USERNAME, ""): + username, realm = data[CONF_USERNAME].split("@", 1) + data[CONF_USERNAME] = username + data[CONF_REALM] = realm.lower() + realm = data[CONF_REALM].lower() - # If the realm is one of the base providers, set the provider to match the realm. + # If the realm is one of the base providers, + # set the provider to match the realm. data[CONF_AUTH_METHOD] = realm if realm in (AUTH_PAM, AUTH_PVE) else AUTH_OTHER data.setdefault(CONF_TOKEN, False) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index f0064465e5d..4f6ca3e13c7 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor to read Proxmox VE data.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 8098152fbb5..964caa196a7 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -1,7 +1,5 @@ """Button platform for Proxmox VE.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass @@ -304,7 +302,10 @@ async def async_setup_entry( class ProxmoxBaseButton(ButtonEntity): - """Common base for Proxmox buttons. Basically to ensure the async_press logic isn't duplicated.""" + """Common base for Proxmox buttons. + + Ensures the async_press logic isn't duplicated. + """ entity_description: ButtonEntityDescription coordinator: ProxmoxCoordinator diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py index 790f5ffa891..73dee442372 100644 --- a/homeassistant/components/proxmoxve/common.py +++ b/homeassistant/components/proxmoxve/common.py @@ -3,9 +3,9 @@ from collections.abc import Mapping from typing import Any -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_TOKEN, CONF_USERNAME -from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM +from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]: @@ -21,4 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]: data[CONF_REALM] = realm data[CONF_USERNAME] = f"{username}@{realm}" + if data.get(CONF_TOKEN) and data.get(CONF_TOKEN_ID) and "!" in data[CONF_TOKEN_ID]: + data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1] + return data diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 7845f5405b9..b970f0e7bfb 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Proxmox VE integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -17,6 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -37,14 +36,15 @@ from .const import ( CONF_NODE, CONF_NODES, CONF_REALM, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, CONF_VMS, DEFAULT_PORT, DEFAULT_REALM, + DEFAULT_TIMEOUT, DEFAULT_VERIFY_SSL, DOMAIN, + NODE_ONLINE, ) _LOGGER = logging.getLogger(__name__) @@ -80,14 +80,14 @@ TOKEN_SCHEMA = vol.Schema( def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: """Validate the user input and fetch data (sync, for executor).""" - auth_kwargs = { - "password": data.get(CONF_PASSWORD), - } - if data.get(CONF_TOKEN): - auth_kwargs = { + auth_kwargs = ( + { "token_name": data[CONF_TOKEN_ID], "token_value": data[CONF_TOKEN_SECRET], } + if data.get(CONF_TOKEN) + else {"password": data.get(CONF_PASSWORD)} + ) data = sanitize_config_entry(data) try: client = ProxmoxAPI( @@ -95,8 +95,22 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: port=data[CONF_PORT], user=data[CONF_USERNAME], verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + timeout=DEFAULT_TIMEOUT, **auth_kwargs, ) + except AuthenticationError as err: + raise ProxmoxAuthenticationError from err + except SSLError as err: + raise ProxmoxSSLError from err + except ConnectTimeout as err: + raise ProxmoxConnectTimeout from err + except ResourceException as err: + _LOGGER.debug("Error during Proxmox client initialisation", exc_info=True) + raise ProxmoxInitFailed from err + except requests.exceptions.ConnectionError as err: + raise ProxmoxConnectionError from err + + try: nodes = client.nodes.get() except AuthenticationError as err: raise ProxmoxAuthenticationError from err @@ -105,17 +119,30 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: except ConnectTimeout as err: raise ProxmoxConnectTimeout from err except ResourceException as err: + _LOGGER.debug("Error fetching nodes", exc_info=True) raise ProxmoxNoNodesFound from err except requests.exceptions.ConnectionError as err: raise ProxmoxConnectionError from err + if not nodes: + raise ProxmoxNoNodesFound("No nodes found") + nodes_data: list[dict[str, Any]] = [] for node in nodes: + if node.get("status") != NODE_ONLINE: + _LOGGER.debug( + "Node %s is offline, skipping VM/container fetch", + node["node"], + ) + continue try: vms = client.nodes(node["node"]).qemu.get() containers = client.nodes(node["node"]).lxc.get() except ResourceException as err: - raise ProxmoxNoNodesFound from err + _LOGGER.debug( + "Error fetching VMs/LXC for node %s", node["node"], exc_info=True + ) + raise ProxmoxNoVMLXCFound from err except requests.exceptions.ConnectionError as err: raise ProxmoxConnectionError from err @@ -298,9 +325,15 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): except ProxmoxSSLError as exc: errors["base"] = "ssl_error" err = exc + except ProxmoxInitFailed as exc: + errors["base"] = "api_error_no_details" + err = exc except ProxmoxNoNodesFound as exc: errors["base"] = "no_nodes_found" err = exc + except ProxmoxNoVMLXCFound as exc: + errors["base"] = "no_vmlxc_found" + err = exc except ProxmoxConnectionError as exc: errors["base"] = "cannot_connect" err = exc @@ -370,6 +403,14 @@ class ProxmoxNoNodesFound(ProxmoxError): """Error to indicate no nodes found.""" +class ProxmoxNoVMLXCFound(ProxmoxError): + """Error to indicate no LXC or VM found.""" + + +class ProxmoxInitFailed(ProxmoxError): + """Error to indicate API initialisation failure.""" + + class ProxmoxConnectTimeout(ProxmoxError): """Error to indicate a connection timeout.""" diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index cd7bd7db54b..bfd944612a0 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -7,7 +7,6 @@ CONF_AUTH_METHOD = "auth_method" CONF_REALM = "realm" CONF_NODE = "node" CONF_NODES = "nodes" -CONF_TOKEN = "token" CONF_TOKEN_ID = "token_id" CONF_TOKEN_SECRET = "token_value" CONF_VMS = "vms" @@ -30,6 +29,7 @@ AUTH_METHODS = [AUTH_PAM, AUTH_PVE, AUTH_OTHER] DEFAULT_PORT = 8006 DEFAULT_REALM = AUTH_PAM +DEFAULT_TIMEOUT = 30 DEFAULT_VERIFY_SSL = True TYPE_VM = 0 TYPE_CONTAINER = 1 diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index a15b7c897f1..f44c4d7bd93 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -1,7 +1,5 @@ """Data Update Coordinator for Proxmox VE integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field from datetime import timedelta @@ -18,6 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -28,9 +27,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .common import sanitize_config_entry from .const import ( CONF_NODE, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, + DEFAULT_TIMEOUT, DEFAULT_VERIFY_SSL, DOMAIN, NODE_ONLINE, @@ -219,6 +218,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): port=data[CONF_PORT], user=data[CONF_USERNAME], verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + timeout=DEFAULT_TIMEOUT, **auth_kwargs, ) diff --git a/homeassistant/components/proxmoxve/diagnostics.py b/homeassistant/components/proxmoxve/diagnostics.py index 68d3e333724..a42133dfd51 100644 --- a/homeassistant/components/proxmoxve/diagnostics.py +++ b/homeassistant/components/proxmoxve/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Proxmox VE.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py index acb72a8179b..75103967173 100644 --- a/homeassistant/components/proxmoxve/entity.py +++ b/homeassistant/components/proxmoxve/entity.py @@ -1,7 +1,5 @@ """Proxmox parent entity class.""" -from __future__ import annotations - from typing import Any from yarl import URL @@ -57,7 +55,11 @@ class ProxmoxNodeEntity(ProxmoxCoordinatorEntity): ), ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{node_data.node['id']}" + f"_{entity_description.key}" + ) @property def available(self) -> bool: @@ -101,7 +103,11 @@ class ProxmoxStorageEntity(ProxmoxCoordinatorEntity): ), ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._node_name}_{self.device_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self._node_name}_{self.device_id}" + f"_{entity_description.key}" + ) @property def available(self) -> bool: @@ -151,7 +157,10 @@ class ProxmoxVMEntity(ProxmoxCoordinatorEntity): ), ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self.device_id}_{entity_description.key}" + ) @property def available(self) -> bool: @@ -204,7 +213,10 @@ class ProxmoxContainerEntity(ProxmoxCoordinatorEntity): ), ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}" + f"_{self.device_id}_{entity_description.key}" + ) @property def available(self) -> bool: diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index 3cd8b3717fa..348a9ea8bbb 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Proxmox VE integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index a88c366f1fd..a92e6ef4506 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -6,10 +6,12 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "api_error_no_details": "An error occurred while communicating with the Proxmox VE instance.", "cannot_connect": "Cannot connect to Proxmox VE server", "connect_timeout": "[%key:common::config_flow::error::timeout_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_nodes_found": "No active nodes found", + "no_nodes_found": "No active nodes were found on the Proxmox VE server.", + "no_vmlxc_found": "No LXC or VM were found on the Proxmox VE server.", "ssl_error": "SSL check failed. Check the SSL settings" }, "step": { @@ -324,6 +326,9 @@ "no_permission_vm_lxc_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." }, + "no_vmlxc_found": { + "message": "No LXC or VM were found on the Proxmox VE server." + }, "permissions_error": { "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." }, diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index 47fa9454deb..6ea544c2a32 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -1,7 +1,5 @@ """Proxy camera platform that enables image processing of camera data.""" -from __future__ import annotations - import asyncio from datetime import timedelta import io diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index dfdb172f675..4c89754f04f 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==12.1.1"] + "requirements": ["Pillow==12.2.0"] } diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index d181502accc..7fcc48cf8e5 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -1,7 +1,5 @@ """The PrusaLink integration.""" -from __future__ import annotations - from pyprusalink import PrusaLink from pyprusalink.types import InvalidAuth @@ -18,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.httpx_client import get_async_client -from .config_flow import ConfigFlow +from .config_flow import PrusaLinkConfigFlow from .const import DOMAIN from .coordinator import ( InfoUpdateCoordinator, @@ -27,6 +25,7 @@ from .coordinator import ( PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator, StatusCoordinator, + VersionUpdateCoordinator, ) PLATFORMS: list[Platform] = [ @@ -54,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> "status": StatusCoordinator(hass, entry, api), "job": JobUpdateCoordinator(hass, entry, api), "info": InfoUpdateCoordinator(hass, entry, api), + "version": VersionUpdateCoordinator(hass, entry, api), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() @@ -67,7 +67,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - if config_entry.version > ConfigFlow.VERSION: + if (config_entry.version, config_entry.minor_version) > ( + PrusaLinkConfigFlow.VERSION, + PrusaLinkConfigFlow.MINOR_VERSION, + ): # This means the user has downgraded from a future version return False @@ -104,7 +107,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", }, ) - # There is a check in the async_setup_entry to prevent the setup if minor_version < 2 + # There is a check in the async_setup_entry to + # prevent the setup if minor_version < 2 # Currently we can't reload the config entry # if the migration returns False. # Return True here to workaround that. diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index fff24eef195..7e12e9253d5 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -1,15 +1,14 @@ """PrusaLink binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import cast -from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus +from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus, StatusInfo from pyprusalink.types_legacy import LegacyPrinterStatus from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -17,30 +16,35 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator -from .entity import PrusaLinkEntity - -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) +from .entity import PrusaLinkEntity, PrusaLinkEntityDescription -@dataclass(frozen=True) -class PrusaLinkBinarySensorEntityDescriptionMixin(Generic[T]): - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class PrusaLinkBinarySensorEntityDescription[ + T: (PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) +]( + BinarySensorEntityDescription, + PrusaLinkEntityDescription, +): + """Describes PrusaLink sensor entity.""" value_fn: Callable[[T], bool] -@dataclass(frozen=True) -class PrusaLinkBinarySensorEntityDescription( - BinarySensorEntityDescription, - PrusaLinkBinarySensorEntityDescriptionMixin[T], - Generic[T], -): - """Describes PrusaLink sensor entity.""" - - available_fn: Callable[[T], bool] = lambda _: True - - BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = { + "status": ( + PrusaLinkBinarySensorEntityDescription[PrinterStatus]( + key="printer.status_connect", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda data: cast( + bool, cast(StatusInfo, data["printer"]["status_connect"])["ok"] + ), + supported_fn=lambda data: ( + data["printer"].get("status_connect") is not None + and data["printer"]["status_connect"].get("ok") is not None + ), + ), + ), "info": ( PrusaLinkBinarySensorEntityDescription[PrinterInfo]( key="info.mmu", @@ -48,6 +52,20 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = value_fn=lambda data: data["mmu"], entity_registry_enabled_default=False, ), + PrusaLinkBinarySensorEntityDescription[PrinterInfo]( + key="info.sd_ready", + translation_key="sd_ready", + value_fn=lambda data: data["sd_ready"], + supported_fn=lambda data: data.get("sd_ready") is not None, + entity_registry_enabled_default=False, + ), + PrusaLinkBinarySensorEntityDescription[PrinterInfo]( + key="info.farm_mode", + translation_key="farm_mode", + value_fn=lambda data: data["farm_mode"], + supported_fn=lambda data: data.get("farm_mode") is not None, + entity_registry_enabled_default=False, + ), ), } @@ -66,6 +84,7 @@ async def async_setup_entry( entities.extend( PrusaLinkBinarySensorEntity(coordinator, sensor_description) for sensor_description in binary_sensors + if sensor_description.supported_fn(coordinator.data) ) async_add_entities(entities) diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index a619204eb86..04244decbf8 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -1,10 +1,8 @@ """PrusaLink sensors.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink from pyprusalink.types import Conflict, PrinterState @@ -15,25 +13,19 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator -from .entity import PrusaLinkEntity - -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) +from .entity import PrusaLinkEntity, PrusaLinkEntityDescription -@dataclass(frozen=True) -class PrusaLinkButtonEntityDescriptionMixin(Generic[T]): - """Mixin for required keys.""" - - press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]] - - -@dataclass(frozen=True) -class PrusaLinkButtonEntityDescription( - ButtonEntityDescription, PrusaLinkButtonEntityDescriptionMixin[T], Generic[T] +@dataclass(frozen=True, kw_only=True) +class PrusaLinkButtonEntityDescription[ + T: (PrinterStatus, LegacyPrinterStatus, JobInfo) +]( + ButtonEntityDescription, + PrusaLinkEntityDescription, ): """Describes PrusaLink button entity.""" - available_fn: Callable[[T], bool] = lambda _: True + press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]] BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { @@ -63,6 +55,14 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { bool, data["printer"]["state"] == PrinterState.PAUSED.value ), ), + PrusaLinkButtonEntityDescription[PrinterStatus]( + key="job.continue_job", + translation_key="continue_job", + press_fn=lambda api: api.continue_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.ATTENTION.value + ), + ), ), } @@ -102,13 +102,6 @@ class PrusaLinkButtonEntity(PrusaLinkEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - @property - def available(self) -> bool: - """Return if sensor is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) - async def async_press(self) -> None: """Press the button.""" job_id = self.coordinator.data["job"]["id"] diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index 0ab5d517d57..6b21c282200 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -1,15 +1,22 @@ """Camera entity for PrusaLink.""" -from __future__ import annotations +from dataclasses import dataclass from pyprusalink.types import PrinterState -from homeassistant.components.camera import Camera +from homeassistant.components.camera import Camera, CameraEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator -from .entity import PrusaLinkEntity +from .entity import PrusaLinkEntity, PrusaLinkEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class PrusaLinkCameraEntityDescription( + CameraEntityDescription, PrusaLinkEntityDescription +): + """Describes PrusaLink camera entity.""" async def async_setup_entry( @@ -25,9 +32,17 @@ async def async_setup_entry( class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): """Defines a PrusaLink camera.""" + entity_description = PrusaLinkCameraEntityDescription( + key="job_preview", + translation_key="job_preview", + available_fn=lambda data: bool( + data.get("state") != PrinterState.IDLE.value + and (file := data.get("file")) + and file.get("refs", {}).get("thumbnail") + ), + ) last_path = "" last_image: bytes - _attr_translation_key = "job_preview" def __init__(self, coordinator: PrusaLinkUpdateCoordinator) -> None: """Initialize a PrusaLink camera entity.""" @@ -35,16 +50,6 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): Camera.__init__(self) self._attr_unique_id = f"{self.coordinator.config_entry.entry_id}_job_preview" - @property - def available(self) -> bool: - """Get if camera is available.""" - return ( - super().available - and self.coordinator.data.get("state") != PrinterState.IDLE.value - and (file := self.coordinator.data.get("file")) - and file.get("refs", {}).get("thumbnail") - ) - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 6fa72d6a5fd..e193033647f 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for PrusaLink integration.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -43,9 +41,11 @@ def ensure_printer_is_supported(version: VersionInfo) -> None: # Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports # the 2.0.0 API, but doesn't advertise it yet - if version.get("original", "").startswith( - ("PrusaLink I3MK3", "PrusaLink I3MK2") - ) and AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"]): + original_value = version.get("original") + original = original_value if isinstance(original_value, str) else "" + if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and ( + AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"]) + ): return except AwesomeVersionException as err: diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index e50ef66815b..24b54032e20 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -1,7 +1,5 @@ """Coordinators for the PrusaLink integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from datetime import timedelta @@ -16,6 +14,7 @@ from pyprusalink import ( PrinterInfo, PrinterStatus, PrusaLink, + VersionInfo, ) from pyprusalink.types import InvalidAuth, PrusaLinkError @@ -32,7 +31,20 @@ _LOGGER = logging.getLogger(__name__) # rapidly-changing metrics. _MINIMUM_REFRESH_INTERVAL = 1.0 -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) +# Job is the only coordinator whose payload can be None — pyprusalink's +# get_job() returns None on HTTP 204 when no job is running. The other +# endpoints always return data or raise on failure. Using `bound=` rather +# than constraint members so `JobInfo | None` fits without forcing a union +# into the constraint list. +T = TypeVar( + "T", + bound=PrinterStatus + | LegacyPrinterStatus + | JobInfo + | None + | PrinterInfo + | VersionInfo, +) type PrusaLinkConfigEntry = ConfigEntry[dict[str, PrusaLinkUpdateCoordinator]] @@ -86,8 +98,15 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Expect a change.""" self.expect_change_until = monotonic() + 30 - def _get_update_interval(self, data: T) -> timedelta: - """Get new update interval.""" + def _get_update_interval(self, data: T | None) -> timedelta: + """Get new update interval. + + `data` is unused by the base implementation today, but kept on the + signature so subclasses can override based on payload state — e.g. a + future transfer coordinator that polls faster while a transfer is + active. The base class is called once from `__init__` with `None` + before the first fetch, hence `T | None`. + """ if self.expect_change_until > monotonic(): return timedelta(seconds=5) @@ -110,10 +129,15 @@ class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): return await self.api.get_legacy_printer() -class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): - """Job update coordinator.""" +class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo | None]): + """Job update coordinator. - async def _fetch_data(self) -> JobInfo: + The job endpoint returns nothing (HTTP 204) when no job is running, so + `data` can legitimately be `None` here. Entity code that reads from this + coordinator's data must be `None`-aware. + """ + + async def _fetch_data(self) -> JobInfo | None: """Fetch the printer data.""" return await self.api.get_job() @@ -124,3 +148,11 @@ class InfoUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]): async def _fetch_data(self) -> PrinterInfo: """Fetch the printer data.""" return await self.api.get_info() + + +class VersionUpdateCoordinator(PrusaLinkUpdateCoordinator[VersionInfo]): + """Version update coordinator.""" + + async def _fetch_data(self) -> VersionInfo: + """Fetch the version data.""" + return await self.api.get_version() diff --git a/homeassistant/components/prusalink/entity.py b/homeassistant/components/prusalink/entity.py index e0bc62ba3c0..b36b455cbbd 100644 --- a/homeassistant/components/prusalink/entity.py +++ b/homeassistant/components/prusalink/entity.py @@ -1,25 +1,57 @@ """The PrusaLink integration.""" -from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator +@dataclass(frozen=True, kw_only=True) +class PrusaLinkEntityDescription(EntityDescription): + """Base description for PrusaLink entities.""" + + available_fn: Callable[[Any], bool] = lambda _: True + supported_fn: Callable[[Any], bool] = lambda _: True + + class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): """Defines a base PrusaLink entity.""" _attr_has_entity_name = True + entity_description: PrusaLinkEntityDescription + + @property + def available(self) -> bool: + """Return if entity is available.""" + # `coordinator.data` can be None when the underlying endpoint + # returns no payload — e.g. the job coordinator yields None when + # no job is running on pyprusalink >= 3.0.0. Short-circuit to + # avoid passing None into `available_fn` lambdas that assume a + # dict (.get(), index, etc.). + return ( + super().available + and self.coordinator.data is not None + and self.entity_description.available_fn(self.coordinator.data) + ) @property def device_info(self) -> DeviceInfo: """Return device information about this PrusaLink device.""" + coordinators = self.coordinator.config_entry.runtime_data + info_data = coordinators["info"].data or {} + version_data = coordinators["version"].data or {} return DeviceInfo( identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=self.coordinator.config_entry.title, manufacturer="Prusa", + serial_number=info_data.get("serial"), + sw_version=version_data.get("firmware"), configuration_url=self.coordinator.api.client.host, + suggested_area=info_data.get("location"), ) diff --git a/homeassistant/components/prusalink/icons.json b/homeassistant/components/prusalink/icons.json index d2b956f10ec..59f6dcc823e 100644 --- a/homeassistant/components/prusalink/icons.json +++ b/homeassistant/components/prusalink/icons.json @@ -1,9 +1,23 @@ { "entity": { + "binary_sensor": { + "farm_mode": { + "default": "mdi:server-network" + }, + "mmu": { + "default": "mdi:printer-3d-nozzle-alert" + }, + "sd_ready": { + "default": "mdi:micro-sd" + } + }, "button": { "cancel_job": { "default": "mdi:cancel" }, + "continue_job": { + "default": "mdi:play-circle" + }, "pause_job": { "default": "mdi:pause" }, diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index 7343d39d94b..4430280ed28 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/prusalink", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyprusalink==2.1.1"] + "requirements": ["pyprusalink==3.0.0"] } diff --git a/homeassistant/components/prusalink/quality_scale.yaml b/homeassistant/components/prusalink/quality_scale.yaml new file mode 100644 index 00000000000..05b29fd0546 --- /dev/null +++ b/homeassistant/components/prusalink/quality_scale.yaml @@ -0,0 +1,168 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: | + - `test_form` has a stale name/docstring. + - Use a `mock_setup_entry` fixture instead of patching inline. + - Typo `resultn` should reuse `result`. + - Merge `test_form_invalid_auth` and `test_form_unknown` with + `pytest.mark.parametrize`, and have every flow test end in + `CREATE_ENTRY` by repatching the mock to verify recovery. + config-flow: + status: todo + comment: | + Add `data_description` in `strings.json` for the host/username/ + password fields on the user step. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Coordinator-polled; no library events to subscribe to. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: todo + comment: | + Config flow does not call `set_unique_id`. Use the serial number + from `/api/v1/info` (already fetched during validation) — and + reuse it as the device identifier and entity unique_id prefix in + place of `entry_id`. + + # Silver + action-exceptions: + status: todo + comment: | + Button presses raise `HomeAssistantError("Action conflicts with + current printer state")` with a hard-coded English string when + the printer rejects the action. We don't register custom service + actions, but button presses are user-triggered actions in spirit + so this rule applies. Switch to `translation_key` (tracked + alongside `exception-translations`). + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow; host/username/password are captured at setup. + docs-installation-parameters: + status: todo + comment: | + Same gap as `config-flow`: needs `data_description`. + entity-unavailable: done + integration-owner: + status: todo + comment: | + Assign a codeowner in `manifest.json`. + log-when-unavailable: done + parallel-updates: + status: todo + comment: | + Declare `PARALLEL_UPDATES` on each platform module. + reauthentication-flow: + status: todo + comment: | + `InvalidAuth` currently surfaces as `UpdateFailed`; should trigger + `async_step_reauth` instead so the user can re-enter credentials + without removing the entry. + test-coverage: + status: todo + comment: | + - Use `snapshot_platform` to fixate entities instead of asserting + manually. + - Replace per-method `patch(...)` calls with a single `PrusaLink` + patch (see `mealie` for the pattern). + - Use `freezegun`/`freezer` in `test_failed_update` rather than + firing time changes directly. + - Introduce `CONF_HOST`-style constants for fixture values rather + than literal strings. + - Set up the config entry via + `hass.config_entries.async_setup(entry.entry_id)` instead of + `async_setup_component(hass, "prusalink", {})`. + + # Gold + devices: done + diagnostics: + status: todo + comment: | + Add `diagnostics.py` returning the latest coordinator data with + sensitive fields redacted. + discovery-update-info: + status: todo + comment: | + Once `async_step_dhcp` lands (`discovery`), also update the + stored host on rediscovery so dynamic-IP printers don't drift. + discovery: + status: todo + comment: | + `manifest.json` declares a DHCP MAC-prefix matcher but the config + flow has no `async_step_dhcp` — DHCP-discovered printers should + onboard without prompting for an IP. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: todo + comment: | + Existing device list needs a refresh against the current + Buddy-firmware lineup (Core One/Core One L/Core One+/MK4/MK4S/ + MK3.9(S)/MK3.5(S)) and the firmware-field caveats (XL 6.4.x, + MINI 6.4.0 lack the `printer.firmware` field). + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + One printer per config entry; sub-resources are entities on that + device. + entity-category: + status: todo + comment: | + Mark static-config sensors (nozzle diameter, min extrusion temp) + as `EntityCategory.DIAGNOSTIC`. Operational sensors stay primary. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: | + Buttons raise `HomeAssistantError("Action conflicts with current + printer state")` with a hard-coded English string; switch to + `translation_key`. + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + Add `async_step_reconfigure` so host/credentials can change + without re-adding the entry. + repair-issues: done + stale-devices: + status: exempt + comment: | + One printer per config entry; sub-resources are entities. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: | + Claimable once HA pins `pyprusalink>=3.0.0` (ships `py.typed` and + `mypy --strict` in CI). diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index dbfcb8886bc..6bb3330a6ea 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -1,14 +1,18 @@ """PrusaLink sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Generic, TypeVar, cast +from typing import cast -from pyprusalink.types import JobInfo, PrinterInfo, PrinterState, PrinterStatus -from pyprusalink.types_legacy import LegacyPrinterStatus +from pyprusalink.types import ( + JobFilePrint, + JobInfo, + PrinterInfo, + PrinterState, + PrinterStatus, +) +from pyprusalink.types_legacy import LegacyPrinterStatus, LegacyPrinterTelemetry from homeassistant.components.sensor import ( SensorDeviceClass, @@ -29,25 +33,19 @@ from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator -from .entity import PrusaLinkEntity - -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) +from .entity import PrusaLinkEntity, PrusaLinkEntityDescription -@dataclass(frozen=True) -class PrusaLinkSensorEntityDescriptionMixin(Generic[T]): - """Mixin for required keys.""" - - value_fn: Callable[[T], datetime | StateType] - - -@dataclass(frozen=True) -class PrusaLinkSensorEntityDescription( - SensorEntityDescription, PrusaLinkSensorEntityDescriptionMixin[T], Generic[T] +@dataclass(frozen=True, kw_only=True) +class PrusaLinkSensorEntityDescription[ + T: (PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) +]( + SensorEntityDescription, + PrusaLinkEntityDescription, ): """Describes PrusaLink sensor entity.""" - available_fn: Callable[[T], bool] = lambda _: True + value_fn: Callable[[T], datetime | StateType] SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { @@ -55,7 +53,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.state", name=None, - value_fn=lambda data: cast(str, data["printer"]["state"].lower()), + value_fn=lambda data: cast(str, data["printer"]["state"]).lower(), device_class=SensorDeviceClass.ENUM, options=[state.value.lower() for state in PrinterState], translation_key="printer_state", @@ -105,6 +103,26 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { value_fn=lambda data: cast(float, data["printer"]["axis_z"]), entity_registry_enabled_default=False, ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.x-position", + translation_key="x_position", + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["printer"]["axis_x"]), + supported_fn=lambda data: data["printer"].get("axis_x") is not None, + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.y-position", + translation_key="y_position", + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data["printer"]["axis_y"]), + supported_fn=lambda data: data["printer"].get("axis_y") is not None, + entity_registry_enabled_default=False, + ), PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.print-speed", translation_key="print_speed", @@ -137,7 +155,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { PrusaLinkSensorEntityDescription[LegacyPrinterStatus]( key="printer.telemetry.material", translation_key="material", - value_fn=lambda data: cast(str, data["telemetry"]["material"]), + value_fn=lambda data: cast( + str, cast(LegacyPrinterTelemetry, data["telemetry"])["material"] + ), + available_fn=lambda data: data.get("telemetry") is not None, ), ), "job": ( @@ -154,7 +175,11 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", - value_fn=lambda data: cast(str, data["file"]["display_name"]), + # `available_fn` guarantees `file` is not None at this point; + # the inner cast narrows the Optional for the index. + value_fn=lambda data: cast( + str, cast(JobFilePrint, data["file"])["display_name"] + ), available_fn=lambda data: ( data.get("file") is not None and data.get("state") != PrinterState.IDLE.value @@ -177,8 +202,12 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { key="job.finish", translation_key="print_finish", device_class=SensorDeviceClass.TIMESTAMP, + # `available_fn` guarantees `time_remaining` is not None at this + # point; the cast narrows the Optional for `timedelta`. value_fn=ignore_variance( - lambda data: utcnow() + timedelta(seconds=data["time_remaining"]), + lambda data: ( + utcnow() + timedelta(seconds=cast(int, data["time_remaining"])) + ), timedelta(minutes=2), ), available_fn=lambda data: ( @@ -196,6 +225,15 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { value_fn=lambda data: cast(str, data["nozzle_diameter"]), entity_registry_enabled_default=False, ), + PrusaLinkSensorEntityDescription[PrinterInfo]( + key="info.min_extrusion_temp", + translation_key="min_extrusion_temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data["min_extrusion_temp"], + supported_fn=lambda data: data.get("min_extrusion_temp") is not None, + entity_registry_enabled_default=False, + ), ), } @@ -215,6 +253,7 @@ async def async_setup_entry( entities.extend( PrusaLinkSensorEntity(coordinator, sensor_description) for sensor_description in sensors + if sensor_description.supported_fn(coordinator.data) ) async_add_entities(entities) @@ -239,10 +278,3 @@ class PrusaLinkSensorEntity(PrusaLinkEntity, SensorEntity): def native_value(self) -> datetime | StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) - - @property - def available(self) -> bool: - """Return if sensor is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 3c3b7257dfb..153c3d9a823 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -18,14 +18,23 @@ }, "entity": { "binary_sensor": { + "farm_mode": { + "name": "Farm mode" + }, "mmu": { "name": "MMU" + }, + "sd_ready": { + "name": "SD card" } }, "button": { "cancel_job": { "name": "Cancel job" }, + "continue_job": { + "name": "Continue job" + }, "pause_job": { "name": "Pause job" }, @@ -57,6 +66,9 @@ "material": { "name": "Material" }, + "min_extrusion_temp": { + "name": "Minimum extrusion temperature" + }, "nozzle_diameter": { "name": "Nozzle diameter" }, @@ -94,6 +106,12 @@ "progress": { "name": "Progress" }, + "x_position": { + "name": "X-Position" + }, + "y_position": { + "name": "Y-Position" + }, "z_height": { "name": "Z-Height" } diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 59d5929cb17..b5e8e317b71 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -1,7 +1,5 @@ """Support for PlayStation 4 consoles.""" -from __future__ import annotations - from dataclasses import dataclass import logging import os diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index abb1f2ad381..0f5e0d64985 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -215,6 +215,8 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): link_schema[vol.Required(CONF_CODE)] = vol.All( vol.Strip, vol.Length(max=PIN_LENGTH), vol.Coerce(int) ) + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field link_schema[vol.Required(CONF_NAME, default=DEFAULT_NAME)] = str return self.async_show_form( diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index e1d3a6a241b..51825023406 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,7 +1,5 @@ """Constants for PlayStation 4.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/ps4/services.py b/homeassistant/components/ps4/services.py index 583366602ed..dd655fd3fd7 100644 --- a/homeassistant/components/ps4/services.py +++ b/homeassistant/components/ps4/services.py @@ -1,7 +1,5 @@ """Support for PlayStation 4 consoles.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py new file mode 100644 index 00000000000..9a557749494 --- /dev/null +++ b/homeassistant/components/ptdevices/__init__.py @@ -0,0 +1,46 @@ +"""The PTDevices integration.""" + +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface + +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: PTDevicesConfigEntry +) -> bool: + """Set up PTDevices from a config entry.""" + auth_token: str = config_entry.data[CONF_API_TOKEN] + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=auth_token, + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + config_entry.runtime_data = coordinator = PTDevicesCoordinator( + hass, + config_entry, + ptdevices_interface, + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PTDevicesConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/ptdevices/config_flow.py b/homeassistant/components/ptdevices/config_flow.py new file mode 100644 index 00000000000..505ed7053bd --- /dev/null +++ b/homeassistant/components/ptdevices/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for PTDevices integration.""" + +import logging +from typing import Any + +import aioptdevices +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_CONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=data[CONF_API_TOKEN], + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + # Test Connection + try: + response = await ptdevices_interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise CannotConnect from err + + except aioptdevices.PTDevicesUnauthorizedError as err: + raise InvalidAuth from err + + body = response["body"] + + # Ensure the first device exists + first_device = next(iter(body.values()), None) + if first_device is None: + raise NoDevicesFound + + user_name = first_device.get("user_name") + user_id = first_device.get("user_id") + + title: str = str(user_name) + unique_id: str = str(user_id) + + # Return title to be used for hub name + return (title, unique_id) + + +class PTDevicesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for PTDevices.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + + # Test connection when user data is available + if user_input is not None: + # Test connection + try: + title, unique_id = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_access_token" + except NoDevicesFound: + errors["base"] = "no_devices_found" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Connection Successful + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=user_input) + + # Show setup form + return self.async_show_form( + step_id="user", data_schema=_CONF_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class NoDevicesFound(HomeAssistantError): + """No devices were found in the account.""" diff --git a/homeassistant/components/ptdevices/const.py b/homeassistant/components/ptdevices/const.py new file mode 100644 index 00000000000..829272fc271 --- /dev/null +++ b/homeassistant/components/ptdevices/const.py @@ -0,0 +1,4 @@ +"""Constants for the PTDevices integration.""" + +DOMAIN = "ptdevices" +DEFAULT_URL = "https://api.ptdevices.com/token/v1" diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py new file mode 100644 index 00000000000..353918356f9 --- /dev/null +++ b/homeassistant/components/ptdevices/coordinator.py @@ -0,0 +1,88 @@ +"""Coordinator for PTDevices integration.""" + +from datetime import timedelta +import logging +from typing import Final + +import aioptdevices +from aioptdevices.interface import Interface, PTDevicesResponseData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +REFRESH_COOLDOWN: Final = 30 +UPDATE_INTERVAL = timedelta(seconds=60) + +type PTDevicesConfigEntry = ConfigEntry[PTDevicesCoordinator] + + +class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): + """Class for interacting with PTDevices get_data.""" + + config_entry: PTDevicesConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + ptdevices_interface: Interface, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + cooldown=REFRESH_COOLDOWN, + ), + ) + + self.interface = ptdevices_interface + + async def _async_update_data(self) -> PTDevicesResponseData: + try: + data = await self.interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except aioptdevices.PTDevicesUnauthorizedError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_access_token", + translation_placeholders={"error": repr(err)}, + ) from err + + # Purge stale devices + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{device_data['user_id']}_{device_id}") + for device_id, device_data in data["body"].items() + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing stale device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) + + return data["body"] diff --git a/homeassistant/components/ptdevices/entity.py b/homeassistant/components/ptdevices/entity.py new file mode 100644 index 00000000000..f8df42c330e --- /dev/null +++ b/homeassistant/components/ptdevices/entity.py @@ -0,0 +1,49 @@ +"""PTDevices integration.""" + +from typing import Any + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PTDevicesCoordinator + + +class PTDevicesEntity(CoordinatorEntity[PTDevicesCoordinator]): + """Defines a base PTDevices entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PTDevicesCoordinator, + sensor_key: str, + device_id: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator=coordinator) + self._sensor_key = sensor_key + self._device_id = device_id + self._user_id = coordinator.data[self._device_id]["user_id"] + + self._attr_unique_id = f"{self._user_id}_{device_id}_{sensor_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._user_id}_{self._device_id}")}, + connections={(CONNECTION_NETWORK_MAC, self._device_id)}, + configuration_url=f"https://www.ptdevices.com/device/level/{self.device['id']}", + manufacturer="ParemTech Inc.", + model=self.device["device_type"], + sw_version=str(self.device["version"]), + name=self.device["title"], + ) + + @property + def device(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self._device_id in self.coordinator.data diff --git a/homeassistant/components/ptdevices/icons.json b/homeassistant/components/ptdevices/icons.json new file mode 100644 index 00000000000..8c17cf0a8a8 --- /dev/null +++ b/homeassistant/components/ptdevices/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "battery_voltage": { + "default": "mdi:battery" + }, + "depth_level": { + "default": "mdi:water" + }, + "percent_level": { + "default": "mdi:water-percent" + }, + "probe_temperature": { + "default": "mdi:thermometer" + }, + "status": { + "default": "mdi:information-outline" + }, + "tx_signal": { + "default": "mdi:wifi" + }, + "volume_level": { + "default": "mdi:water" + }, + "wifi_signal": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/ptdevices/manifest.json b/homeassistant/components/ptdevices/manifest.json new file mode 100644 index 00000000000..149e6710618 --- /dev/null +++ b/homeassistant/components/ptdevices/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ptdevices", + "name": "PTDevices", + "codeowners": ["@ParemTech-Inc", "@frogman85978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ptdevices", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aioptdevices"], + "quality_scale": "bronze", + "requirements": ["aioptdevices==2026.03.2"] +} diff --git a/homeassistant/components/ptdevices/quality_scale.yaml b/homeassistant/components/ptdevices/quality_scale.yaml new file mode 100644 index 00000000000..5a6ae39af27 --- /dev/null +++ b/homeassistant/components/ptdevices/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide any actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ptdevices/sensor.py b/homeassistant/components/ptdevices/sensor.py new file mode 100644 index 00000000000..df9549ac228 --- /dev/null +++ b/homeassistant/components/ptdevices/sensor.py @@ -0,0 +1,203 @@ +"""Sensors for PTDevices device.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import cast + +from aioptdevices.interface import PTDevicesStatusStates + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfLength, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class PTDevicesSensors(StrEnum): + """Store keys for PTDevices sensors.""" + + LEVEL_PERCENT = "percent_level" + LEVEL_VOLUME = "volume_level" + LEVEL_DEPTH = "depth_level" + PROBE_TEMPERATURE = "probe_temperature" + DEVICE_STATUS = "status" + DEVICE_WIFI_STRENGTH = "wifi_signal" + DEVICE_BATTERY_VOLTAGE = "battery_voltage" + TX_SIGNAL_STRENGTH = "tx_signal" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesSensorEntityDescription(SensorEntityDescription): + """Description for PTDevices sensor entities.""" + + value_fn: Callable[[dict[str, str | int | float | None]], str | int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[PTDevicesSensorEntityDescription, ...] = ( + # Percent of water in the tank + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_PERCENT, + translation_key=PTDevicesSensors.LEVEL_PERCENT, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_PERCENT)), + ), + # Volume of water in the tank (Liters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_VOLUME, + translation_key=PTDevicesSensors.LEVEL_VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_VOLUME)), + ), + # Depth of water in the tank (Meters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_DEPTH, + translation_key=PTDevicesSensors.LEVEL_DEPTH, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_DEPTH)), + suggested_display_precision=3, + ), + # Temperature measured by external temperature probe (Celsius) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.PROBE_TEMPERATURE, + translation_key=PTDevicesSensors.PROBE_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.PROBE_TEMPERATURE)), + ), + # Status of the device + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_STATUS, + translation_key=PTDevicesSensors.DEVICE_STATUS, + device_class=SensorDeviceClass.ENUM, + options=[ + member.value + for member in PTDevicesStatusStates + if member.value != "unknown" + ], + value_fn=lambda data: ( + cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) + if cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) != "unknown" + else None + ), + ), + # Wifi signal strength (%) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + translation_key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + int, data.get(PTDevicesSensors.DEVICE_WIFI_STRENGTH) + ), + ), + # LoRa signal strength (dBm) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + translation_key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.TX_SIGNAL_STRENGTH) + ), + ), + # Battery voltage (Volts) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + translation_key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.DEVICE_BATTERY_VOLTAGE) + ), + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PTDevices sensors from config entries.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesSensorEntity(config_entry.runtime_data, sensor, device_id) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesSensorEntity(PTDevicesEntity, SensorEntity): + """Sensor entity for PTDevices Integration.""" + + entity_description: PTDevicesSensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesSensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + def native_value(self) -> float | int | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json new file mode 100644 index 00000000000..318c4fd1266 --- /dev/null +++ b/homeassistant/components/ptdevices/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "no_devices_found": "No devices are registered to your PTDevices account.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for your PTDevices account." + }, + "description": "Enter the API token for your PTDevices account" + } + } + }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "depth_level": { + "name": "Level depth" + }, + "percent_level": { + "name": "Level percent" + }, + "probe_temperature": { + "name": "Probe temperature" + }, + "status": { + "name": "Status", + "state": { + "not_connected": "Not connected", + "not_connected_yet": "Not connected yet", + "power_internet_out_or_receiver_not_working": "Power or internet out or receiver not working", + "press_transmitter_connect_button": "Press transmitter connect button", + "transmitter_not_reporting": "Transmitter not reporting", + "working": "Working" + } + }, + "tx_signal": { + "name": "LoRa signal strength" + }, + "volume_level": { + "name": "Level volume" + }, + "wifi_signal": { + "name": "Wi-Fi signal strength" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Failed to connect: {error}" + }, + "invalid_access_token": { + "message": "Invalid access token: {error}" + } + } +} diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py index c0e23b271d1..e5bb3413de0 100644 --- a/homeassistant/components/pterodactyl/__init__.py +++ b/homeassistant/components/pterodactyl/__init__.py @@ -1,7 +1,5 @@ """The Pterodactyl integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py index 44d3a6d0a82..e348f176879 100644 --- a/homeassistant/components/pterodactyl/button.py +++ b/homeassistant/components/pterodactyl/button.py @@ -1,7 +1,5 @@ """Button platform for the Pterodactyl integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -98,7 +96,9 @@ class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): ) except PterodactylConnectionError as err: raise HomeAssistantError( - f"Failed to send action '{self.entity_description.key}': Connection error" + "Failed to send action" + f" '{self.entity_description.key}':" + " Connection error" ) from err except PterodactylAuthorizationError as err: raise HomeAssistantError( diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index db03c89f95e..5e9ec466c0f 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Pterodactyl integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 6d644e96e4c..7fb7f4c4ac8 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator of the Pterodactyl integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py index 812a82a9955..d1613485749 100644 --- a/homeassistant/components/pterodactyl/sensor.py +++ b/homeassistant/components/pterodactyl/sensor.py @@ -1,7 +1,5 @@ """Sensor platform of the Pterodactyl integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index cb7bd8ce654..67ba4946629 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -1,7 +1,5 @@ """Switch logic for loading/unloading pulseaudio loopback modules.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 4ece35a3f1c..6987f4990aa 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -1,7 +1,5 @@ """The Pure Energie integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py index 0dcb1a9ab13..9e4bc317024 100644 --- a/homeassistant/components/pure_energie/config_flow.py +++ b/homeassistant/components/pure_energie/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Pure Energie integration.""" -from __future__ import annotations - from typing import Any from gridnet import Device, GridNet, GridNetConnectionError diff --git a/homeassistant/components/pure_energie/const.py b/homeassistant/components/pure_energie/const.py index bba7708c174..b7298112155 100644 --- a/homeassistant/components/pure_energie/const.py +++ b/homeassistant/components/pure_energie/const.py @@ -1,7 +1,5 @@ """Constants for the Pure Energie integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/pure_energie/coordinator.py b/homeassistant/components/pure_energie/coordinator.py index cd66ab060eb..b3be3f1601e 100644 --- a/homeassistant/components/pure_energie/coordinator.py +++ b/homeassistant/components/pure_energie/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Pure Energie integration.""" -from __future__ import annotations - from typing import NamedTuple from gridnet import Device, GridNet, SmartBridge diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py index 5098a298e85..ae30f954e75 100644 --- a/homeassistant/components/pure_energie/diagnostics.py +++ b/homeassistant/components/pure_energie/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Pure Energie.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index ad57206adeb..17a471e957a 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -1,7 +1,5 @@ """Support for Pure Energie sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 0b7acdb1eb0..3438c4d347c 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -1,7 +1,5 @@ """The PurpleAir integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 29139872913..08b7316008c 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -1,7 +1,5 @@ """Config flow for PurpleAir integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from copy import deepcopy diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index 1d51e402ef4..6aa53c80dd4 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -1,7 +1,5 @@ """Define a PurpleAir DataUpdateCoordinator.""" -from __future__ import annotations - from datetime import timedelta from aiopurpleair import API diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index 71b83e277d3..23bc3ee501a 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for PurpleAir.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 410fdd9b942..23bd83e29a9 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -1,7 +1,5 @@ """The PurpleAir integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 3a2e42e63cb..205d57266f8 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -1,7 +1,5 @@ """Support for PurpleAir sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 26c91bb6d29..16e80ab4cac 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -1,7 +1,5 @@ """Camera platform that receives images through HTTP POST.""" -from __future__ import annotations - import asyncio from collections import deque from datetime import timedelta @@ -120,7 +118,7 @@ class PushCamera(Camera): self._filename = None self._expired_listener = None self._timeout = timeout - self.queue: deque[bytes] = deque([], buffer_size) + self.queue: deque[bytes] = deque(maxlen=buffer_size) self._current_image: bytes | None = None self._image_field = image_field self.webhook_id = webhook_id diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index 4adfbcad4f9..a087909aa9e 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -1,7 +1,5 @@ """The pushbullet component.""" -from __future__ import annotations - import logging from pushbullet import InvalidKeyError, PushBullet, PushbulletError diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index 72805a9aa94..52d9b8a12bb 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,7 +1,5 @@ """Pushbullet Notification provider.""" -from __future__ import annotations - from typing import Any from pushbullet import Listener, PushBullet diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index 08ade743aee..bf1916df07b 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for pushbullet integration.""" -from __future__ import annotations - from typing import Any from pushbullet import InvalidKeyError, PushBullet, PushbulletError @@ -15,6 +13,8 @@ from .const import DEFAULT_NAME, DOMAIN CONFIG_SCHEMA = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(), vol.Required(CONF_API_KEY): selector.TextSelector(), } diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 26ecc859ad2..e8dbc7b594a 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -1,7 +1,5 @@ """Pushbullet platform for notify component.""" -from __future__ import annotations - import logging import mimetypes from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index ade6f9362ed..f817665c7d9 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,7 +1,5 @@ """Pushbullet platform for sensor component.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback @@ -117,7 +115,8 @@ class PushBulletNotificationSensor(SensorEntity): """ try: value = self.pb_provider.data[self.entity_description.key] - # Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes + # Truncate state value to MAX_LENGTH_STATE_STATE + # while preserving full content in attributes if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE: self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..." else: diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index f8d3c0ef53d..16e850f5e88 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -1,7 +1,5 @@ """The pushover component.""" -from __future__ import annotations - from pushover_complete import BadAPIRequestError, PushoverAPI from requests.exceptions import RequestException from urllib3.exceptions import HTTPError diff --git a/homeassistant/components/pushover/config_flow.py b/homeassistant/components/pushover/config_flow.py index fcc28b45ede..92cae73065c 100644 --- a/homeassistant/components/pushover/config_flow.py +++ b/homeassistant/components/pushover/config_flow.py @@ -1,7 +1,5 @@ """Config flow for pushover integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -16,6 +14,8 @@ from .const import CONF_USER_KEY, DEFAULT_NAME, DOMAIN USER_SCHEMA = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_API_KEY): str, vol.Required(CONF_USER_KEY): str, diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 62c14b4dae8..6d1169a4faf 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -1,7 +1,5 @@ """Pushover platform for notify component.""" -from __future__ import annotations - import logging from typing import Any @@ -45,6 +43,8 @@ async def async_get_service( """Get the Pushover notification service.""" if discovery_info is None: return None + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]] return PushoverNotificationService( hass, pushover_api, discovery_info[CONF_USER_KEY] @@ -89,6 +89,7 @@ class PushoverNotificationService(BaseNotificationService): file_handle = open(data[ATTR_ATTACHMENT], "rb") # Replace the attachment identifier with file object. image = file_handle + # pylint: disable-next=home-assistant-action-swallowed-exception except OSError as ex_val: _LOGGER.error(ex_val) # Remove attachment key to send without attachment. diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index 1810bbc68aa..fa0393f7579 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -1,7 +1,5 @@ """Pushsafer platform for notify component.""" -from __future__ import annotations - import base64 from http import HTTPStatus import logging diff --git a/homeassistant/components/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py index 9932ff24d14..84520a5c926 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -1,7 +1,5 @@ """The PVOutput integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index ad2d759056f..6a7efb16fe8 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the PVOutput integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -32,8 +30,6 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - imported_name: str | None = None - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -56,7 +52,7 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(str(user_input[CONF_SYSTEM_ID])) self._abort_if_unique_id_configured() return self.async_create_entry( - title=self.imported_name or str(user_input[CONF_SYSTEM_ID]), + title=str(user_input[CONF_SYSTEM_ID]), data={ CONF_SYSTEM_ID: user_input[CONF_SYSTEM_ID], CONF_API_KEY: user_input[CONF_API_KEY], @@ -83,6 +79,45 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a PVOutput entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + await validate_input( + self.hass, + api_key=user_input[CONF_API_KEY], + system_id=reconfigure_entry.data[CONF_SYSTEM_ID], + ) + except PVOutputAuthenticationError: + errors["base"] = "invalid_auth" + except PVOutputError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + description_placeholders={ + "account_url": "https://pvoutput.org/account.jsp" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/pvoutput/const.py b/homeassistant/components/pvoutput/const.py index be63053a899..dd771a55e46 100644 --- a/homeassistant/components/pvoutput/const.py +++ b/homeassistant/components/pvoutput/const.py @@ -1,7 +1,5 @@ """Constants for the PVOutput integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/pvoutput/coordinator.py b/homeassistant/components/pvoutput/coordinator.py index 8b90144e6a8..1d48db02c18 100644 --- a/homeassistant/components/pvoutput/coordinator.py +++ b/homeassistant/components/pvoutput/coordinator.py @@ -1,8 +1,13 @@ """DataUpdateCoordinator for the PVOutput integration.""" -from __future__ import annotations - -from pvo import PVOutput, PVOutputAuthenticationError, PVOutputNoDataError, Status +from pvo import ( + PVOutput, + PVOutputAuthenticationError, + PVOutputConnectionError, + PVOutputError, + PVOutputNoDataError, + Status, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -37,7 +42,20 @@ class PVOutputDataUpdateCoordinator(DataUpdateCoordinator[Status]): """Fetch system status from PVOutput.""" try: return await self.pvoutput.status() - except PVOutputNoDataError as err: - raise UpdateFailed("PVOutput has no data available") from err except PVOutputAuthenticationError as err: raise ConfigEntryAuthFailed from err + except PVOutputNoDataError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_data_available", + ) from err + except PVOutputConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except PVOutputError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/pvoutput/diagnostics.py b/homeassistant/components/pvoutput/diagnostics.py index e75a0b59f20..5d046c17f32 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for PVOutput.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index dee5f9cda6e..58792a0dbc5 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["pvo==2.2.1"] + "requirements": ["pvo==3.0.0"] } diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index b92d4ea2ec5..dc709780f60 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -1,7 +1,5 @@ """Support for getting collected information from PVOutput.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -27,6 +25,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SYSTEM_ID, DOMAIN from .coordinator import PvOutputConfigEntry, PVOutputDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PVOutputSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index f8fbf4581ae..342ed952eb9 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +16,12 @@ }, "description": "To re-authenticate with PVOutput you'll need to get the API key at {account_url}." }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Reconfigure your PVOutput integration. You can update your API key at {account_url}." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -42,5 +49,16 @@ "name": "Power generation" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the PVOutput service." + }, + "no_data_available": { + "message": "The PVOutput service has no data available for this system." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the PVOutput service." + } } } diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 2efb9cad939..a09c50abdd7 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,11 +1,9 @@ """Config flow for pvpc_hourly_pricing.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any -from aiopvpc import DEFAULT_POWER_KW, PVPCData +from esios_api import DEFAULT_POWER_KW, PVPCData import voluptuous as vol from homeassistant.config_entries import ( @@ -15,7 +13,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_API_TOKEN, CONF_NAME +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util @@ -65,11 +63,11 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[ATTR_TARIFF]) self._abort_if_unique_id_configured() + calc_name = f"{DEFAULT_NAME} - {user_input[ATTR_TARIFF]}" if not user_input[CONF_USE_API_TOKEN]: return self.async_create_entry( - title=user_input[CONF_NAME], + title=calc_name, data={ - CONF_NAME: user_input[CONF_NAME], ATTR_TARIFF: user_input[ATTR_TARIFF], ATTR_POWER: user_input[ATTR_POWER], ATTR_POWER_P3: user_input[ATTR_POWER_P3], @@ -77,7 +75,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - self._name = user_input[CONF_NAME] + self._name = calc_name self._tariff = user_input[ATTR_TARIFF] self._power = user_input[ATTR_POWER] self._power_p3 = user_input[ATTR_POWER_P3] @@ -86,7 +84,6 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): data_schema = vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(ATTR_TARIFF, default=DEFAULT_TARIFF): VALID_TARIFF, vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER, vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER, @@ -135,7 +132,6 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): ) data = { - CONF_NAME: self._name, ATTR_TARIFF: self._tariff, ATTR_POWER: self._power, ATTR_POWER_P3: self._power_p3, @@ -155,7 +151,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): """Handle re-authentication with ESIOS Token.""" self._api_token = entry_data.get(CONF_API_TOKEN) self._use_api_token = self._api_token is not None - self._name = entry_data[CONF_NAME] + self._name = f"{DEFAULT_NAME} - {entry_data[ATTR_TARIFF]}" self._tariff = entry_data[ATTR_TARIFF] self._power = entry_data[ATTR_POWER] self._power_p3 = entry_data[ATTR_POWER_P3] diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py index 9aaa46233cb..b4f23c1ac66 100644 --- a/homeassistant/components/pvpc_hourly_pricing/const.py +++ b/homeassistant/components/pvpc_hourly_pricing/const.py @@ -1,6 +1,6 @@ """Constant values for pvpc_hourly_pricing.""" -from aiopvpc.const import TARIFFS +from esios_api.const import TARIFFS import voluptuous as vol DOMAIN = "pvpc_hourly_pricing" diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index c357551be8f..ce6011bf472 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData +from esios_api import BadApiTokenAuthError, EsiosApiData, PVPCData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN diff --git a/homeassistant/components/pvpc_hourly_pricing/helpers.py b/homeassistant/components/pvpc_hourly_pricing/helpers.py index e0792f76404..5836044c0e2 100644 --- a/homeassistant/components/pvpc_hourly_pricing/helpers.py +++ b/homeassistant/components/pvpc_hourly_pricing/helpers.py @@ -1,6 +1,6 @@ """Helper functions to relate sensors keys and unique ids.""" -from aiopvpc.const import ( +from esios_api.const import ( ALL_SENSORS, KEY_INJECTION, KEY_MAG, diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 18287a2d5e9..f6198840ed0 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -1,11 +1,11 @@ { "domain": "pvpc_hourly_pricing", "name": "Spain electricity hourly pricing (PVPC)", - "codeowners": ["@azogue"], + "codeowners": ["@azogue", "@chiro79"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["aiopvpc"], - "requirements": ["aiopvpc==4.3.1"] + "loggers": ["esios_api"], + "requirements": ["esios_api==4.4.0"] } diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index c49756290ab..fdfb1167e8c 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -1,13 +1,11 @@ """Sensor to collect the reference daily prices of electricity ('PVPC') in Spain.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import datetime import logging from typing import Any -from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC +from esios_api.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC from homeassistant.components.sensor import ( SensorEntity, diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index ca7bbb0c1dc..de0c3550cd2 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -1,7 +1,5 @@ """The pyLoad integration.""" -from __future__ import annotations - import logging from aiohttp import CookieJar @@ -9,6 +7,7 @@ from pyloadapi import PyLoadAPI from yarl import URL from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_PORT, @@ -39,8 +38,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo pyloadapi = PyLoadAPI( session, api_url=URL(entry.data[CONF_URL]), - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], + username=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + api_key=entry.data.get(CONF_API_KEY), ) coordinator = PyLoadCoordinator(hass, entry, pyloadapi) diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 5ee10a327d1..68834cd1c4d 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -1,7 +1,5 @@ """Support for monitoring pyLoad.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index a13dc1f9410..592c8a215b2 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -1,7 +1,5 @@ """Config flow for pyLoad integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -13,13 +11,14 @@ from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_NAME, + CONF_API_KEY, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -41,13 +40,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ), ), vol.Required(CONF_VERIFY_SSL, default=True): bool, - vol.Required(CONF_USERNAME): TextSelector( + vol.Exclusive(CONF_API_KEY, "credentials"): cv.string, + vol.Exclusive(CONF_USERNAME, "credentials"): TextSelector( TextSelectorConfig( type=TextSelectorType.TEXT, autocomplete="username", ), ), - vol.Required(CONF_PASSWORD): TextSelector( + vol.Optional(CONF_PASSWORD): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, autocomplete="current-password", @@ -58,13 +58,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema( REAUTH_SCHEMA = vol.Schema( { - vol.Required(CONF_USERNAME): TextSelector( + vol.Exclusive(CONF_API_KEY, "credentials"): cv.string, + vol.Exclusive(CONF_USERNAME, "credentials"): TextSelector( TextSelectorConfig( type=TextSelectorType.TEXT, autocomplete="username", ), ), - vol.Required(CONF_PASSWORD): TextSelector( + vol.Optional(CONF_PASSWORD): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, autocomplete="current-password", @@ -86,8 +87,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non pyload = PyLoadAPI( session, api_url=URL(user_input[CONF_URL]), - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + api_key=user_input.get(CONF_API_KEY), ) await pyload.get_status() @@ -113,7 +115,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): await validate_input(self.hass, user_input) except CannotConnect, ParserError: errors["base"] = "cannot_connect" - except InvalidAuth: + except InvalidAuth, ValueError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -156,7 +158,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): await validate_input(self.hass, {**reauth_entry.data, **user_input}) except CannotConnect, ParserError: errors["base"] = "cannot_connect" - except InvalidAuth: + except InvalidAuth, ValueError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -171,12 +173,11 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( REAUTH_SCHEMA, { - CONF_USERNAME: user_input[CONF_USERNAME] + CONF_USERNAME: user_input.get(CONF_USERNAME) if user_input is not None - else reauth_entry.data[CONF_USERNAME] + else reauth_entry.data.get(CONF_USERNAME) }, ), - description_placeholders={CONF_NAME: reauth_entry.data[CONF_USERNAME]}, errors=errors, ) @@ -192,7 +193,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): await validate_input(self.hass, user_input) except CannotConnect, ParserError: errors["base"] = "cannot_connect" - except InvalidAuth: + except InvalidAuth, ValueError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -213,10 +214,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): STEP_USER_DATA_SCHEMA, suggested_values, ), - description_placeholders={ - CONF_NAME: reconfig_entry.data[CONF_USERNAME], - **PLACEHOLDER, - }, + description_placeholders=PLACEHOLDER, errors=errors, ) @@ -252,7 +250,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect, ParserError: _LOGGER.debug("Cannot connect", exc_info=True) errors["base"] = "cannot_connect" - except InvalidAuth: + except InvalidAuth, ValueError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index a69ba0c67dd..fb9fd0847c8 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -7,7 +7,6 @@ import logging from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -68,7 +67,6 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: self.pyload.username}, ) from e except CannotConnect as e: raise UpdateFailed( @@ -100,7 +98,4 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="setup_authentication_exception", - translation_placeholders={ - CONF_USERNAME: self.config_entry.data[CONF_USERNAME] - }, ) from e diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index 98fab38da1d..6222b858aed 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for pyLoad.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/pyload/entity.py b/homeassistant/components/pyload/entity.py index 58e93431ca1..0a6892a4498 100644 --- a/homeassistant/components/pyload/entity.py +++ b/homeassistant/components/pyload/entity.py @@ -1,7 +1,5 @@ """Base entity for pyLoad.""" -from __future__ import annotations - from homeassistant.components.button import EntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index fe36327cc75..2a008128f86 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyloadapi"], "quality_scale": "platinum", - "requirements": ["PyLoadAPI==2.0.0"] + "requirements": ["PyLoadAPI==2.1.0"] } diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index 7425c543fe1..90aff876057 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring pyLoad.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index bfa7d40599c..f41cda8d496 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -13,10 +13,12 @@ "step": { "hassio_confirm": { "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { + "api_key": "[%key:component::pyload::config::step::user::data_description::api_key%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]" }, @@ -25,10 +27,12 @@ }, "reauth_confirm": { "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { + "api_key": "[%key:component::pyload::config::step::user::data_description::api_key%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]" }, @@ -36,12 +40,14 @@ }, "reconfigure": { "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { + "api_key": "[%key:component::pyload::config::step::user::data_description::api_key%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", "url": "[%key:component::pyload::config::step::user::data_description::url%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]", @@ -50,15 +56,17 @@ }, "user": { "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "password": "The password associated with the pyLoad account.", + "api_key": "The API key to authenticate with pyLoad. To create a new API key, navigate to **Settings > Users > Actions > Manage API Keys** and select **Generate**.", + "password": "The password associated with the pyLoad account. **Deprecated:** No longer supported in pyLoad 0.5.0b3.dev97 or later.", "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `{example_url}`", - "username": "The username used to access the pyLoad instance.", + "username": "The username used to access the pyLoad instance. **Deprecated:** No longer supported in pyLoad 0.5.0b3.dev97 or later.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } } @@ -113,7 +121,7 @@ "message": "Unable to send command to pyLoad due to a connection error, try again later" }, "setup_authentication_exception": { - "message": "Authentication failed for {username}, verify your login credentials" + "message": "Authentication with pyLoad failed, verify your login credentials" }, "setup_parse_exception": { "message": "Unable to parse data from pyLoad API" diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index 46a54451b9a..afc4b4d950b 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -1,7 +1,5 @@ """Support for monitoring pyLoad.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 0729d73a034..afed00363c9 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -35,7 +35,6 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, raise_if_invalid_filename from homeassistant.util.yaml.loader import load_yaml_dict @@ -195,7 +194,6 @@ def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: return op_fun(target, operand) -@bind_hass def execute_script( hass: HomeAssistant, name: str, @@ -210,7 +208,6 @@ def execute_script( return execute(hass, filename, source, data, return_response=return_response) -@bind_hass def execute( hass: HomeAssistant, filename: str, diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 62f671fc5c4..820157f2bd6 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -71,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entry: QBittorrentConfigEntry | None = hass.config_entries.async_get_entry( entry_id ) - if entry is None or entry.state != ConfigEntryState.LOADED: + if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_entry_id", diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index c7f7d9ecfe7..3419c2acd6f 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -1,7 +1,5 @@ """Config flow for qBittorrent.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 007945a18e7..5b8673ad6d4 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -1,7 +1,5 @@ """The QBittorrent coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2f813e35557..601f96a30c1 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["qbittorrent-api==2024.9.67"] + "requirements": ["qbittorrent-api==2026.5.1"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index c942dec6e6c..e185600748d 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the qBittorrent API.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass import logging diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py index 176e0942b25..4148fd9867f 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -1,7 +1,5 @@ """Support for monitoring the qBittorrent API.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/qbus/binary_sensor.py b/homeassistant/components/qbus/binary_sensor.py index d91b6c9cbe6..db6f0976ab5 100644 --- a/homeassistant/components/qbus/binary_sensor.py +++ b/homeassistant/components/qbus/binary_sensor.py @@ -84,7 +84,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity): diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py index a19ec4d0156..a6b2f368bc8 100644 --- a/homeassistant/components/qbus/climate.py +++ b/homeassistant/components/qbus/climate.py @@ -51,7 +51,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusClimate(QbusEntity, ClimateEntity): diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py index 2f08c5b47e2..22d89ae4790 100644 --- a/homeassistant/components/qbus/config_flow.py +++ b/homeassistant/components/qbus/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Qbus.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index 3ecab64059a..3cf2a302f12 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -11,6 +11,7 @@ PLATFORMS: list[Platform] = [ Platform.COVER, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py index c3fbf4b60bb..e13a3c8057d 100644 --- a/homeassistant/components/qbus/coordinator.py +++ b/homeassistant/components/qbus/coordinator.py @@ -1,7 +1,5 @@ """Qbus coordinator.""" -from __future__ import annotations - from datetime import datetime import logging from typing import cast @@ -59,10 +57,7 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]): self._subscribed_to_controller_state = False self._controller: QbusMqttDevice | None = None - # Clean up when HA stops - self.config_entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) async def _async_update_data(self) -> QbusMqttDevice | None: return self._controller @@ -128,12 +123,10 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]): controller_state_topic, ) self._subscribed_to_controller_state = True - self.config_entry.async_on_unload( - await mqtt.async_subscribe( - self.hass, - controller_state_topic, - self._controller_state_received, - ) + await mqtt.async_subscribe( + self.hass, + controller_state_topic, + self._controller_state_received, ) async def _controller_state_received(self, msg: ReceiveMessage) -> None: diff --git a/homeassistant/components/qbus/cover.py b/homeassistant/components/qbus/cover.py index 3fc1b20602a..f6fabf9a9d1 100644 --- a/homeassistant/components/qbus/cover.py +++ b/homeassistant/components/qbus/cover.py @@ -45,7 +45,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusCover(QbusEntity, CoverEntity): diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 784af0594fb..4b69dc00cbe 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -1,7 +1,5 @@ """Base class for Qbus entities.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable import re diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 61225f11243..9fde56f77be 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusLight(QbusEntity, LightEntity): @@ -79,8 +79,10 @@ class QbusLight(QbusEntity, LightEntity): await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: - percentage = round(state.read_percentage()) - self._set_state(percentage) + percentage = state.read_percentage() + + if percentage is not None: + self._set_state(round(percentage)) def _set_state(self, percentage: int) -> None: self._attr_is_on = percentage > 0 diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 15392f6cc97..b1e8fd4fded 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -14,5 +14,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.4.2"] + "requirements": ["qbusmqttapi==1.5.0"] } diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py index 4403fe28259..d100ad4360b 100644 --- a/homeassistant/components/qbus/scene.py +++ b/homeassistant/components/qbus/scene.py @@ -35,7 +35,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusScene(QbusEntity, BaseScene): diff --git a/homeassistant/components/qbus/select.py b/homeassistant/components/qbus/select.py new file mode 100644 index 00000000000..9f6f7bb0aba --- /dev/null +++ b/homeassistant/components/qbus/select.py @@ -0,0 +1,91 @@ +"""Support for Qbus select.""" + +from qbusmqttapi.const import KEY_PROPERTIES_VALUE +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttStepperState, StateType + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, create_new_entities + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + """Add newly discovered outputs as entities.""" + entities = create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "stepper", + QbusStepper, + ) + + async_add_entities(entities) + + _check_outputs() + coordinator.async_add_listener(_check_outputs) + + +class QbusStepper(QbusEntity, SelectEntity): + """Representation of a Qbus stepper entity.""" + + _state_cls = QbusMqttStepperState + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize stepper entity.""" + + super().__init__(mqtt_output, link_to_main_device=True) + + self._attr_name = mqtt_output.name.title() + + value_settings: dict = mqtt_output.properties.get(KEY_PROPERTIES_VALUE, {}) + value_list: list[dict] = value_settings.get("valueList", []) + + self._name_to_value: dict[str, int] = { + item["name"]: item["value"] for item in value_list + } + self._value_to_name: dict[int, str] = { + item["value"]: item["name"] for item in value_list + } + self._attr_options = [item["name"] for item in value_list] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + value = self._name_to_value.get(option) + + if value is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_option", + translation_placeholders={ + "option": option, + "options": ", ".join(self._attr_options), + }, + ) + + state = QbusMqttStepperState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(value) + + await self._async_publish_output_state(state) + + async def _handle_state_received(self, state: QbusMqttStepperState) -> None: + """Update the state from a received Qbus state.""" + value = state.read_value() + + if value is not None: + self._attr_current_option = self._value_to_name.get(value) diff --git a/homeassistant/components/qbus/sensor.py b/homeassistant/components/qbus/sensor.py index e983e0a8cbb..2efd00ddd29 100644 --- a/homeassistant/components/qbus/sensor.py +++ b/homeassistant/components/qbus/sensor.py @@ -1,6 +1,7 @@ """Support for Qbus sensor.""" from dataclasses import dataclass +from enum import StrEnum from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import ( @@ -13,6 +14,7 @@ from qbusmqttapi.state import ( ) from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -48,8 +50,10 @@ class QbusWeatherDescription(SensorEntityDescription): """Description for Qbus weather entities.""" property: str + scale_factor: int | None = None +# Qbus reports illuminance in klux, HA only supports lux. _WEATHER_DESCRIPTIONS = ( QbusWeatherDescription( key="daylight", @@ -58,6 +62,7 @@ _WEATHER_DESCRIPTIONS = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, + scale_factor=1000, ), QbusWeatherDescription( key="light", @@ -65,6 +70,7 @@ _WEATHER_DESCRIPTIONS = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, + scale_factor=1000, ), QbusWeatherDescription( key="light_east", @@ -73,6 +79,7 @@ _WEATHER_DESCRIPTIONS = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, + scale_factor=1000, ), QbusWeatherDescription( key="light_south", @@ -81,6 +88,7 @@ _WEATHER_DESCRIPTIONS = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, + scale_factor=1000, ), QbusWeatherDescription( key="light_west", @@ -89,6 +97,7 @@ _WEATHER_DESCRIPTIONS = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, + scale_factor=1000, ), QbusWeatherDescription( key="temperature", @@ -289,7 +298,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusGaugeVariantSensor(QbusEntity, SensorEntity): @@ -308,9 +317,32 @@ class QbusGaugeVariantSensor(QbusEntity, SensorEntity): variant = str(mqtt_output.variant) self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()] + allowed_units = ( + DEVICE_CLASS_UNITS.get(self.entity_description.device_class) + if self.entity_description.device_class + else None + ) + value_properties: dict = mqtt_output.properties.get("currentValue", {}) + unit = self._find_matching_unit(value_properties.get("unit"), allowed_units) + + if allowed_units is not None and unit in allowed_units: + self._attr_native_unit_of_measurement = unit + async def _handle_state_received(self, state: QbusMqttGaugeState) -> None: self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE) + def _find_matching_unit( + self, + unit: str | None, + allowed_units: set[type[StrEnum] | str | None] | None, + ) -> str | None: + """Do a case-insensitive search in the allowed units. Returns the properly cased unit if found, else None.""" + if unit is None or allowed_units is None: + return None + + lookup = {str(u).casefold(): str(u) for u in allowed_units if u is not None} + return lookup.get(unit.casefold()) + class QbusHumiditySensor(QbusEntity, SensorEntity): """Representation of a Qbus sensor entity for humidity modules.""" @@ -375,4 +407,8 @@ class QbusWeatherSensor(QbusEntity, SensorEntity): async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: if value := state.read_property(self.entity_description.property, None): - self.native_value = value + self.native_value = ( + value * self.entity_description.scale_factor + if self.entity_description.scale_factor is not None + else value + ) diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index 2f7e9afc3e4..58335d2c80a 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -41,6 +41,9 @@ } }, "exceptions": { + "invalid_option": { + "message": "Option \"{option}\" is not valid. Valid options are: {options}." + }, "invalid_preset": { "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." } diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 3c4d280fa30..cc266bb8e60 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -35,7 +35,7 @@ async def async_setup_entry( async_add_entities(entities) _check_outputs() - entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + coordinator.async_add_listener(_check_outputs) class QbusSwitch(QbusEntity, SwitchEntity): diff --git a/homeassistant/components/qingping/__init__.py b/homeassistant/components/qingping/__init__.py index d0dcb7bfee7..ccd9c86d79c 100644 --- a/homeassistant/components/qingping/__init__.py +++ b/homeassistant/components/qingping/__init__.py @@ -1,7 +1,5 @@ """The Qingping integration.""" -from __future__ import annotations - import logging from qingping_ble import QingpingBluetoothDeviceData diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index 3431204595a..76ed2180d7a 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Qingping binary sensors.""" -from __future__ import annotations - from qingping_ble import ( BinarySensorDeviceClass as QingpingBinarySensorDeviceClass, SensorUpdate, @@ -57,7 +55,9 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[ description.device_class ] - for device_key, description in sensor_update.binary_entity_descriptions.items() + for device_key, description in ( + sensor_update.binary_entity_descriptions.items() + ) if description.device_class }, entity_data={ diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index 990eb5116eb..9550de9c53e 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -1,12 +1,11 @@ """Config flow for Qingping integration.""" -from __future__ import annotations - from typing import Any from qingping_ble import QingpingBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfoBleak, @@ -98,6 +97,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/qingping/device.py b/homeassistant/components/qingping/device.py index 466ac43f079..f71bf14d220 100644 --- a/homeassistant/components/qingping/device.py +++ b/homeassistant/components/qingping/device.py @@ -1,7 +1,5 @@ """Support for Qingping devices.""" -from __future__ import annotations - from qingping_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index ba62d35dede..65ae9236f25 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/qingping", "integration_type": "device", "iot_class": "local_push", - "requirements": ["qingping-ble==1.1.0"] + "requirements": ["qingping-ble==1.1.5"] } diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py index ee2a63b169a..d882903a219 100644 --- a/homeassistant/components/qingping/sensor.py +++ b/homeassistant/components/qingping/sensor.py @@ -1,7 +1,5 @@ """Support for Qingping sensors.""" -from __future__ import annotations - from qingping_ble import ( SensorDeviceClass as QingpingSensorDeviceClass, SensorUpdate, diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index c235d441133..a844b96e202 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -1,7 +1,5 @@ """Support for Queensland Bushfire Alert Feeds.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging diff --git a/homeassistant/components/qnap/__init__.py b/homeassistant/components/qnap/__init__.py index 3315eadac76..8ccbf372415 100644 --- a/homeassistant/components/qnap/__init__.py +++ b/homeassistant/components/qnap/__init__.py @@ -1,7 +1,5 @@ """The qnap component.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index c9b84faf8d6..f6df6fc1385 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure qnap component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 8351727183c..93e3830cfdd 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -1,7 +1,5 @@ """Data coordinator for the qnap integration.""" -from __future__ import annotations - from contextlib import contextmanager, nullcontext from datetime import timedelta import logging diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 8f47ebf1428..866c34f8490 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,7 +1,5 @@ """Support for QNAP NAS Sensors.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py index 8e90e06bc10..534179b8b32 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -1,7 +1,5 @@ """The QNAP QSW integration.""" -from __future__ import annotations - import logging from aioqsw.localapi import ConnectionOptions, QnapQswApi diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index bae91da4b48..a43e3358c72 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the QNAP QSW binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass, replace from typing import Final @@ -142,7 +140,7 @@ class QswBinarySensor(QswSensorEntity, BinarySensorEntity): ) -> None: """Initialize.""" super().__init__(coordinator, entry, type_id) - if description.name == UNDEFINED: + if description.name is UNDEFINED: self._attr_has_entity_name = True else: self._attr_name = f"{self.product} {description.name}" diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 8ca05db84cd..824bad98e35 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -1,7 +1,5 @@ """Support for the QNAP QSW buttons.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 3ccb13e0f64..a34867d57f5 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -1,7 +1,5 @@ """Config flow for QNAP QSW.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index 6f369915a6c..5e764e8fa27 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -1,7 +1,5 @@ """The QNAP QSW coordinator.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/qnap_qsw/diagnostics.py b/homeassistant/components/qnap_qsw/diagnostics.py index d6a8b958829..b9f6580c2ca 100644 --- a/homeassistant/components/qnap_qsw/diagnostics.py +++ b/homeassistant/components/qnap_qsw/diagnostics.py @@ -1,7 +1,5 @@ """Support for the QNAP QSW diagnostics.""" -from __future__ import annotations - from typing import Any from aioqsw.const import QSD_MAC, QSD_SERIAL diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 40670c9f288..f432a2e5743 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -1,7 +1,5 @@ """Entity classes for the QNAP QSW integration.""" -from __future__ import annotations - from dataclasses import dataclass from enum import StrEnum from typing import Any diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index bed69472c85..71af758d21d 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -1,7 +1,5 @@ """Support for the QNAP QSW sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, replace from datetime import datetime @@ -359,7 +357,7 @@ class QswSensor(QswSensorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator, entry, type_id) - if description.name == UNDEFINED: + if description.name is UNDEFINED: self._attr_has_entity_name = True else: self._attr_name = f"{self.product} {description.name}" diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index f9652d4e4f4..528b941eca8 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -1,7 +1,5 @@ """Support for the QNAP QSW update.""" -from __future__ import annotations - from typing import Any, Final from aioqsw.const import ( diff --git a/homeassistant/components/qrcode/image_processing.py b/homeassistant/components/qrcode/image_processing.py index f81969b63b6..e59a247378c 100644 --- a/homeassistant/components/qrcode/image_processing.py +++ b/homeassistant/components/qrcode/image_processing.py @@ -1,7 +1,5 @@ """Support for the QR code image processing.""" -from __future__ import annotations - import io from PIL import Image diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 25cce8f09c4..7d3e750f441 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==12.1.1", "pyzbar==0.1.7"] + "requirements": ["Pillow==12.2.0", "pyzbar==0.1.9"] } diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index c3eddc37f22..069a26c25a0 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -1,7 +1,5 @@ """Support for Verizon FiOS Quantum Gateways.""" -from __future__ import annotations - from quantum_gateway import QuantumGatewayScanner from requests.exceptions import RequestException import voluptuous as vol diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 6496ce304a7..b5efff083cf 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -1,7 +1,5 @@ """Support for QVR Pro streams.""" -from __future__ import annotations - import logging from typing import Any @@ -100,6 +98,7 @@ class QVRProCamera(Camera): try: return self._client.get_snapshot(self.guid) + # pylint: disable-next=home-assistant-action-swallowed-exception except QVRResponseError as ex: _LOGGER.error("Error getting image: %s", ex) self._client.connect() diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index 7dedee04508..aafb718fc60 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -1,7 +1,5 @@ """Support for Qwikswitch devices.""" -from __future__ import annotations - import logging from pyqwikswitch.async_ import QSUsb diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py index 25a9917297e..b90f1d013bb 100644 --- a/homeassistant/components/qwikswitch/binary_sensor.py +++ b/homeassistant/components/qwikswitch/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Qwikswitch Binary Sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/qwikswitch/const.py b/homeassistant/components/qwikswitch/const.py index 2a5cc69af50..2a536312521 100644 --- a/homeassistant/components/qwikswitch/const.py +++ b/homeassistant/components/qwikswitch/const.py @@ -1,7 +1,5 @@ """Support for Qwikswitch devices.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py index b07d857a1f1..ca50fcce9e7 100644 --- a/homeassistant/components/qwikswitch/entity.py +++ b/homeassistant/components/qwikswitch/entity.py @@ -1,7 +1,5 @@ """Support for Qwikswitch devices.""" -from __future__ import annotations - from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 9de959d7009..7672db9a872 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -1,7 +1,5 @@ """Support for Qwikswitch Relays and Dimmers.""" -from __future__ import annotations - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 8a3a4f01032..6ce47ea721c 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -1,7 +1,5 @@ """Support for Qwikswitch Sensors.""" -from __future__ import annotations - import logging from typing import Any @@ -47,7 +45,8 @@ class QSSensor(QSEntity, SensorEntity): self._attr_unique_id = f"qs{self.qsid}:{self.channel}" decode, unit = SENSORS[sensor_type] - # this cannot happen because it only happens in bool and this should be redirected to binary_sensor + # this cannot happen because it only happens in bool + # and this should be redirected to binary_sensor assert not isinstance(unit, type), ( f"boolean sensor id={sensor['id']} name={sensor['name']}" ) diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py index 4b3cddee0d9..ac57b64e76a 100644 --- a/homeassistant/components/qwikswitch/switch.py +++ b/homeassistant/components/qwikswitch/switch.py @@ -1,7 +1,5 @@ """Support for Qwikswitch relays.""" -from __future__ import annotations - from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py index d6530b322b0..b50a7bf213f 100644 --- a/homeassistant/components/rabbitair/__init__.py +++ b/homeassistant/components/rabbitair/__init__.py @@ -1,25 +1,19 @@ """The Rabbit Air integration.""" -from __future__ import annotations - from rabbitair import Client, UdpClient from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.FAN] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool: """Set up Rabbit Air from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - host: str = entry.data[CONF_HOST] token: str = entry.data[CONF_ACCESS_TOKEN] @@ -30,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -39,14 +33,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 43959e1e42c..11a51cee2e3 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rabbit Air integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py index 75453fe4d24..ccc9566a622 100644 --- a/homeassistant/components/rabbitair/coordinator.py +++ b/homeassistant/components/rabbitair/coordinator.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type RabbitAirConfigEntry = ConfigEntry[RabbitAirDataUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) @@ -43,10 +45,10 @@ class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: RabbitAirConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Client + self, hass: HomeAssistant, config_entry: RabbitAirConfigEntry, device: Client ) -> None: """Initialize global data updater.""" self.device = device diff --git a/homeassistant/components/rabbitair/entity.py b/homeassistant/components/rabbitair/entity.py index 47a1b7db3eb..c95bbfa40d3 100644 --- a/homeassistant/components/rabbitair/entity.py +++ b/homeassistant/components/rabbitair/entity.py @@ -1,19 +1,16 @@ """A base class for Rabbit Air entities.""" -from __future__ import annotations - import logging from typing import Any from rabbitair import Model -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -31,7 +28,7 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]): def __init__( self, coordinator: RabbitAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, ) -> None: """Initialize the entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index 4c13f3a8b02..99aa80dae00 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -1,13 +1,10 @@ """Support for Rabbit Air fan entity.""" -from __future__ import annotations - from typing import Any from rabbitair import Mode, Model, Speed from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -15,8 +12,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator from .entity import RabbitAirBaseEntity SPEED_LIST = [ @@ -27,9 +23,9 @@ SPEED_LIST = [ Speed.Turbo, ] -PRESET_MODE_AUTO = "Auto" -PRESET_MODE_MANUAL = "Manual" -PRESET_MODE_POLLEN = "Pollen" +PRESET_MODE_AUTO = "auto" +PRESET_MODE_MANUAL = "manual" +PRESET_MODE_POLLEN = "pollen" PRESET_MODES = { PRESET_MODE_AUTO: Mode.Auto, @@ -40,17 +36,17 @@ PRESET_MODES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RabbitAirFanEntity(coordinator, entry)]) + async_add_entities([RabbitAirFanEntity(entry.runtime_data, entry)]) class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): """Fan control functions of the Rabbit Air air purifier.""" + _attr_translation_key = "rabbitair" _attr_supported_features = ( FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED @@ -61,7 +57,7 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): def __init__( self, coordinator: RabbitAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, ) -> None: """Initialize the entity.""" super().__init__(coordinator, entry) @@ -106,7 +102,7 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): else: # Get key by value in dictionary self._attr_preset_mode = next( - k for k, v in PRESET_MODES.items() if v == data.mode + k for k, v in PRESET_MODES.items() if v is data.mode ) async def async_set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/rabbitair/icons.json b/homeassistant/components/rabbitair/icons.json new file mode 100644 index 00000000000..5ee0b6eccf1 --- /dev/null +++ b/homeassistant/components/rabbitair/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "fan": { + "rabbitair": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:fan-auto", + "manual": "mdi:fan", + "pollen": "mdi:flower-pollen" + } + } + } + } + } + } +} diff --git a/homeassistant/components/rabbitair/strings.json b/homeassistant/components/rabbitair/strings.json index 070ba3be3c5..d24af81560b 100644 --- a/homeassistant/components/rabbitair/strings.json +++ b/homeassistant/components/rabbitair/strings.json @@ -18,5 +18,20 @@ } } } + }, + "entity": { + "fan": { + "rabbitair": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "pollen": "Pollen" + } + } + } + } + } } } diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index 4956d204a98..d50b65b367d 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rachio integration.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index a5dd3dba054..32f302d8837 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -1,7 +1,5 @@ """Adapter to wrap the rachiopy api for home assistant.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any @@ -160,7 +158,8 @@ class RachioPerson: for controller in devices: webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] # The API does not provide a way to tell if a controller is shared - # or if they are the owner. To work around this problem we fetch the webhooks + # or if they are the owner. To work around this problem + # we fetch the webhooks # before we setup the device so we can skip it instead of failing. # webhooks are normally a list, however if there is an error # rachio hands us back a dict @@ -259,7 +258,8 @@ class RachioIro: def _deinit_webhooks(_) -> None: """Stop getting updates from the Rachio API.""" if not self._webhooks: - # We fetched webhooks when we created the device, however if we call _init_webhooks + # We fetched webhooks when we created the device, + # however if we call _init_webhooks # again we need to fetch again self._webhooks = self.rachio.notification.get_device_webhook( self.controller_id diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index c25315ae99b..0669b81e063 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -156,6 +156,7 @@ async def async_setup_entry( if not zone_entities: return + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_START_MULTIPLE_ZONES, @@ -322,7 +323,8 @@ class RachioRainDelay(RachioSwitch): KEY_RAIN_DELAY ] / 1000 > as_timestamp(now()) - # If the controller was in a rain delay state during a reboot, this re-sets the timer + # If the controller was in a rain delay state during + # a reboot, this re-sets the timer if self._attr_is_on is True: delay_end = utc_from_timestamp( self._controller.init_data[KEY_RAIN_DELAY] / 1000 diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 5a69451a6de..c912b51f026 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -1,7 +1,5 @@ """Webhooks used by rachio.""" -from __future__ import annotations - from aiohttp import web from homeassistant.components import cloud, webhook diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 585b5011176..01235b5b903 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -1,7 +1,5 @@ """The Radarr component.""" -from __future__ import annotations - from dataclasses import fields from aiopyarr.models.host_configuration import PyArrHostConfiguration diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index f09e6015b53..dc9dc4fd304 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Radarr binary sensors.""" -from __future__ import annotations - from aiopyarr import Health from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index 4bca75123e0..091c35746b6 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -1,7 +1,5 @@ """Support for Radarr calendar items.""" -from __future__ import annotations - from datetime import datetime from homeassistant.components.calendar import ( @@ -48,7 +46,7 @@ class RadarrCalendarEntity(RadarrEntity, CalendarEntity): description=self.coordinator.event.description, ) - # pylint: disable-next=hass-return-type + # pylint: disable-next=home-assistant-return-type async def async_get_events( # type: ignore[override] self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[RadarrEvent]: diff --git a/homeassistant/components/radarr/config_flow.py b/homeassistant/components/radarr/config_flow.py index 800b4b4968d..598c1adf15b 100644 --- a/homeassistant/components/radarr/config_flow.py +++ b/homeassistant/components/radarr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Radarr.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 1fe92e79061..757df1f5d71 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Radarr integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass @@ -57,7 +55,7 @@ class RadarrEvent(CalendarEvent, RadarrEventMixIn): """A class to describe a Radarr calendar event.""" -class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): +class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): # noqa: UP046 """Data update coordinator for the Radarr integration.""" config_entry: RadarrConfigEntry diff --git a/homeassistant/components/radarr/entity.py b/homeassistant/components/radarr/entity.py index 1f3e1e98c07..80009844493 100644 --- a/homeassistant/components/radarr/entity.py +++ b/homeassistant/components/radarr/entity.py @@ -1,7 +1,5 @@ """The Radarr component.""" -from __future__ import annotations - from homeassistant.const import ATTR_SW_VERSION from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index a6d29ee9d1d..2c2dafa8c33 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -1,7 +1,5 @@ """Support for Radarr.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from datetime import UTC, datetime @@ -47,7 +45,7 @@ def get_modified_description( @dataclasses.dataclass(frozen=True) -class RadarrSensorEntityDescriptionMixIn(Generic[T]): +class RadarrSensorEntityDescriptionMixIn(Generic[T]): # noqa: UP046 """Mixin for required keys.""" value_fn: Callable[[T, str], str | int | datetime] @@ -55,7 +53,9 @@ class RadarrSensorEntityDescriptionMixIn(Generic[T]): @dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescription( - SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] + SensorEntityDescription, + RadarrSensorEntityDescriptionMixIn[T], + Generic[T], # noqa: UP046 ): """Class to describe a Radarr sensor.""" diff --git a/homeassistant/components/radio_browser/__init__.py b/homeassistant/components/radio_browser/__init__.py index eff7796711f..92be0527e70 100644 --- a/homeassistant/components/radio_browser/__init__.py +++ b/homeassistant/components/radio_browser/__init__.py @@ -1,7 +1,5 @@ """The Radio Browser integration.""" -from __future__ import annotations - from aiodns.error import DNSError from radios import RadioBrowser, RadioBrowserError diff --git a/homeassistant/components/radio_browser/config_flow.py b/homeassistant/components/radio_browser/config_flow.py index 411259f31d3..1703adb1e08 100644 --- a/homeassistant/components/radio_browser/config_flow.py +++ b/homeassistant/components/radio_browser/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Radio Browser integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index 165d53860a4..ec674c830da 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -1,7 +1,5 @@ """Expose Radio Browser as a media source.""" -from __future__ import annotations - import mimetypes from aiodns.error import DNSError @@ -58,7 +56,7 @@ class RadioMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve selected Radio station to a streaming URL.""" - if self.entry.state != ConfigEntryState.LOADED: + if self.entry.state is not ConfigEntryState.LOADED: raise Unresolvable( translation_domain=DOMAIN, translation_key="config_entry_not_ready", @@ -88,7 +86,7 @@ class RadioMediaSource(MediaSource): ) -> BrowseMediaSource: """Return media.""" - if self.entry.state != ConfigEntryState.LOADED: + if self.entry.state is not ConfigEntryState.LOADED: raise BrowseError( translation_domain=DOMAIN, translation_key="config_entry_not_ready", @@ -173,7 +171,8 @@ class RadioMediaSource(MediaSource): # We show country in the root additionally, when there is no item if not item.identifier or category == "country": - # Trigger the lazy loading of the country database to happen inside the executor + # Trigger the lazy loading of the country database + # to happen inside the executor await self.hass.async_add_executor_job(lambda: len(pycountry.countries)) countries = await radios.countries(order=Order.NAME) return [ diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py new file mode 100644 index 00000000000..c19bd36bcc6 --- /dev/null +++ b/homeassistant/components/radio_frequency/__init__.py @@ -0,0 +1,154 @@ +"""Provides functionality to interact with radio frequency devices.""" + +from datetime import timedelta +import logging + +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN +from .entity import ( + RadioFrequencyTransmitterEntity, + RadioFrequencyTransmitterEntityDescription, +) + +__all__ = [ + "DOMAIN", + "ModulationType", + "RadioFrequencyTransmitterEntity", + "RadioFrequencyTransmitterEntityDescription", + "async_get_transmitters", + "async_send_command", +] + +_LOGGER = logging.getLogger(__name__) + +DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey( + DOMAIN +) +ENTITY_ID_FORMAT = DOMAIN + ".{}" +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the radio_frequency domain.""" + component = hass.data[DATA_COMPONENT] = EntityComponent[ + RadioFrequencyTransmitterEntity + ](_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + await component.async_setup(config) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +@callback +def async_get_transmitters( + hass: HomeAssistant, + frequency: int, + modulation: ModulationType, +) -> list[str]: + """Get entity IDs of all RF transmitters supporting the given frequency. + + Transmitters are filtered by both their supported frequency ranges and + their supported modulation types. An empty list means no compatible + transmitters. + + Raises: + HomeAssistantError: If the component is not loaded or if no + transmitters exist. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + entities = list(component.entities) + if not entities: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_transmitters", + ) + + return [ + entity.entity_id + for entity in entities + if entity.supports_modulation(modulation) + and entity.supports_frequency(frequency) + ] + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: RadioFrequencyCommand, + context: Context | None = None, +) -> None: + """Send an RF command to the specified radio_frequency entity. + + Raises: + vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity + registry UUID. + HomeAssistantError: If the radio_frequency component is not loaded or the + resolved entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if not entity.supports_frequency(command.frequency): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_frequency", + translation_placeholders={ + "entity_id": entity_id, + "frequency": str(command.frequency), + }, + ) + + if not entity.supports_modulation(command.modulation): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_modulation", + translation_placeholders={ + "entity_id": entity_id, + "modulation": command.modulation, + }, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) diff --git a/homeassistant/components/radio_frequency/const.py b/homeassistant/components/radio_frequency/const.py new file mode 100644 index 00000000000..04d50de7d8e --- /dev/null +++ b/homeassistant/components/radio_frequency/const.py @@ -0,0 +1,5 @@ +"""Constants for the Radio Frequency integration.""" + +from typing import Final + +DOMAIN: Final = "radio_frequency" diff --git a/homeassistant/components/radio_frequency/entity.py b/homeassistant/components/radio_frequency/entity.py new file mode 100644 index 00000000000..03959bd2f99 --- /dev/null +++ b/homeassistant/components/radio_frequency/entity.py @@ -0,0 +1,82 @@ +"""Base entity for the radio frequency integration.""" + +from abc import abstractmethod +from typing import final + +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + + +class RadioFrequencyTransmitterEntityDescription( + EntityDescription, frozen_or_thawed=True +): + """Describes radio frequency transmitter entities.""" + + +class RadioFrequencyTransmitterEntity(RestoreEntity): + """Base class for radio frequency transmitter entities.""" + + entity_description: RadioFrequencyTransmitterEntityDescription + _attr_should_poll = False + _attr_state: None = None + + __last_command_sent: str | None = None + + @property + @abstractmethod + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return list of (min_hz, max_hz) tuples.""" + + @callback + @final + def supports_frequency(self, frequency: int) -> bool: + """Return whether the transmitter supports the given frequency.""" + return any( + low <= frequency <= high for low, high in self.supported_frequency_ranges + ) + + @callback + @final + def supports_modulation(self, modulation: ModulationType) -> bool: + """Return whether the transmitter supports the given modulation.""" + return modulation == ModulationType.OOK + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_command_sent + + @final + async def async_send_command_internal(self, command: RadioFrequencyCommand) -> None: + """Send an RF command and update state. + + Should not be overridden, handles setting last sent timestamp. + """ + await self.async_send_command(command) + self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds") + self.async_write_ha_state() + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the radio frequency entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__last_command_sent = state.state + + @abstractmethod + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command. + + Args: + command: The RF command to send. + + Raises: + HomeAssistantError: If transmission fails. + """ diff --git a/homeassistant/components/radio_frequency/icons.json b/homeassistant/components/radio_frequency/icons.json new file mode 100644 index 00000000000..c7587d1f770 --- /dev/null +++ b/homeassistant/components/radio_frequency/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:radio-tower" + } + } +} diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json new file mode 100644 index 00000000000..049b7ddfc4f --- /dev/null +++ b/homeassistant/components/radio_frequency/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "radio_frequency", + "name": "Radio Frequency", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/radio_frequency", + "integration_type": "entity", + "quality_scale": "internal", + "requirements": ["rf-protocols==4.0.1"] +} diff --git a/homeassistant/components/radio_frequency/strings.json b/homeassistant/components/radio_frequency/strings.json new file mode 100644 index 00000000000..9674cd26023 --- /dev/null +++ b/homeassistant/components/radio_frequency/strings.json @@ -0,0 +1,19 @@ +{ + "exceptions": { + "component_not_loaded": { + "message": "Radio Frequency component not loaded" + }, + "entity_not_found": { + "message": "Radio Frequency entity `{entity_id}` not found" + }, + "no_transmitters": { + "message": "No Radio Frequency transmitters available" + }, + "unsupported_frequency": { + "message": "Radio Frequency entity `{entity_id}` does not support frequency {frequency} Hz" + }, + "unsupported_modulation": { + "message": "Radio Frequency entity `{entity_id}` does not support modulation {modulation}" + } + } +} diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 1c5f7f571a6..dfba8330082 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1,20 +1,16 @@ """The Radio Thermostat integration.""" -from __future__ import annotations - from collections.abc import Coroutine from typing import Any from urllib.error import URLError from radiotherm.validate import RadiothermTstatError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .data import async_get_init_data from .util import async_set_time @@ -38,7 +34,7 @@ async def _async_call_or_raise_not_ready[_T]( raise ConfigEntryNotReady(msg) from ex -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RadioThermConfigEntry) -> bool: """Set up Radio Thermostat from a config entry.""" host = entry.data[CONF_HOST] init_coro = async_get_init_data(hass, host) @@ -54,21 +50,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: time_coro = async_set_time(hass, init_data.tstat) await _async_call_or_raise_not_ready(time_coro, host) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: RadioThermConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RadioThermConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 8ede90f2718..9ce828a8138 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,7 +1,5 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" -from __future__ import annotations - from typing import Any import radiotherm @@ -17,13 +15,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .entity import RadioThermostatEntity ATTR_FAN_ACTION = "fan_action" @@ -101,12 +97,11 @@ def round_temp(temperature): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadioThermConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a radiotherm device.""" - coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RadioThermostat(coordinator)]) + async_add_entities([RadioThermostat(entry.runtime_data)]) class RadioThermostat(RadioThermostatEntity, ClimateEntity): diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index 298421d3964..ad56f8732af 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Radio Thermostat integration.""" -from __future__ import annotations - import logging from typing import Any from urllib.error import URLError diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 7d483426c83..115006a99ed 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for radiotherm.""" -from __future__ import annotations - from datetime import timedelta import logging from urllib.error import URLError @@ -14,6 +12,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .data import RadioThermInitData, RadioThermUpdate, async_get_data +type RadioThermConfigEntry = ConfigEntry[RadioThermUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=15) @@ -22,12 +22,12 @@ UPDATE_INTERVAL = timedelta(seconds=15) class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): """DataUpdateCoordinator to gather data for radio thermostats.""" - config_entry: ConfigEntry + config_entry: RadioThermConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RadioThermConfigEntry, init_data: RadioThermInitData, ) -> None: """Initialize DataUpdateCoordinator.""" diff --git a/homeassistant/components/radiotherm/data.py b/homeassistant/components/radiotherm/data.py index 92e2ee42273..aa56edf41ae 100644 --- a/homeassistant/components/radiotherm/data.py +++ b/homeassistant/components/radiotherm/data.py @@ -1,7 +1,5 @@ """The Radio Thermostat integration data.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py index 2952e1e5817..836c30c4083 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -1,16 +1,12 @@ """Support for radiotherm switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .entity import RadioThermostatEntity PARALLEL_UPDATES = 1 @@ -18,12 +14,11 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadioThermConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a radiotherm device.""" - coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RadioThermHoldSwitch(coordinator)]) + async_add_entities([RadioThermHoldSwitch(entry.runtime_data)]) class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity): diff --git a/homeassistant/components/radiotherm/util.py b/homeassistant/components/radiotherm/util.py index fb15531987a..50ef290d6d7 100644 --- a/homeassistant/components/radiotherm/util.py +++ b/homeassistant/components/radiotherm/util.py @@ -1,7 +1,5 @@ """Utils for radiotherm.""" -from __future__ import annotations - from radiotherm.thermostat import CommonThermostat from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 7b29b801459..a006f9ef636 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,7 +1,5 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -115,11 +113,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> except RainbirdApiException as err: raise ConfigEntryNotReady from err + # Rain Bird devices can only handle a single request at a time. This shared + # lock ensures that the background coordinators do not poll the device + # concurrently. + device_lock = asyncio.Lock() data = RainbirdData( controller, model_info, - coordinator=RainbirdUpdateCoordinator(hass, entry, controller, model_info), - schedule_coordinator=RainbirdScheduleUpdateCoordinator(hass, entry, controller), + coordinator=RainbirdUpdateCoordinator( + hass, entry, controller, model_info, device_lock + ), + schedule_coordinator=RainbirdScheduleUpdateCoordinator( + hass, entry, controller, device_lock + ), ) await data.coordinator.async_config_entry_first_refresh() @@ -154,7 +160,8 @@ async def _async_fix_unique_id( for existing_entry in entries: if existing_entry.unique_id == new_unique_id: _LOGGER.warning( - "Unable to fix missing unique id (already exists); Removing duplicate entry" + "Unable to fix missing unique id (already exists);" + " Removing duplicate entry" ) hass.async_create_background_task( hass.config_entries.async_remove(entry.entry_id), @@ -205,7 +212,8 @@ def _async_device_entry_to_keep( user previously renamed devices. """ # Prefer the new device if the user already gave it a name or area. Otherwise, - # do the same for the old entry. If no entries have been modified then keep the new one. + # do the same for the old entry. If no entries have been + # modified then keep the new one. if new_entry.disabled_by is None and ( new_entry.area_id is not None or new_entry.name_by_user is not None ): @@ -225,7 +233,8 @@ def _async_fix_device_id( ) -> None: """Migrate existing device identifiers to the new format. - This will rename any device ids that are prefixed with the serial number to be prefixed + This will rename any device ids that are prefixed with the + serial number to be prefixed with the mac address. This also cleans up from a bug that allowed devices to exist in both the old and new format. """ @@ -244,7 +253,8 @@ def _async_fix_device_id( for unique_id, new_unique_id in migrations.items(): old_entry = device_entry_map[unique_id] if (new_entry := device_entry_map.get(new_unique_id)) is not None: - # Device entries exist for both the old and new format and one must be removed + # Device entries exist for both the old and new + # format and one must be removed entry_to_keep = _async_device_entry_to_keep(old_entry, new_entry) if entry_to_keep == new_entry: _LOGGER.debug("Removing device entry %s", unique_id) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 0b27c7e33c4..ce84df95950 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" -from __future__ import annotations - import logging from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index c48ca438146..7aae43db851 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -1,7 +1,5 @@ """Rain Bird irrigation calendar.""" -from __future__ import annotations - from datetime import datetime import logging diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 18ce02da6b2..21bf347c497 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rain Bird.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 426df625697..be8ccfa7ea7 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -1,7 +1,5 @@ """Update coordinators for rainbird.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import datetime @@ -27,6 +25,7 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely # changes, so we refresh it less often. CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) +CALENDAR_TIMEOUT_SECONDS = 30 # The valves state are not immediately reflected after issuing a command. We add # small delay to give additional time to reflect the new state. @@ -66,6 +65,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, model_info: ModelAndVersion, + device_lock: asyncio.Lock, ) -> None: """Initialize RainbirdUpdateCoordinator.""" super().__init__( @@ -82,6 +82,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): self._unique_id = config_entry.unique_id self._zones: set[int] | None = None self._model_info = model_info + self._device_lock = device_lock @property def controller(self) -> AsyncRainbirdController: @@ -114,8 +115,9 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): async def _async_update_data(self) -> RainbirdDeviceState: """Fetch data from Rain Bird device.""" try: - async with asyncio.timeout(TIMEOUT_SECONDS): - return await self._fetch_data() + async with self._device_lock: + async with asyncio.timeout(TIMEOUT_SECONDS): + return await self._fetch_data() except RainbirdDeviceBusyException as err: raise UpdateFailed("Rain Bird device is busy") from err except RainbirdApiException as err: @@ -149,6 +151,7 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): hass: HomeAssistant, config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, + device_lock: asyncio.Lock, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( @@ -160,11 +163,13 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): update_interval=CALENDAR_UPDATE_INTERVAL, ) self._controller = controller + self._device_lock = device_lock async def _async_update_data(self) -> Schedule: """Fetch data from Rain Bird device.""" try: - async with asyncio.timeout(TIMEOUT_SECONDS): - return await self._controller.get_schedule() + async with self._device_lock: + async with asyncio.timeout(CALENDAR_TIMEOUT_SECONDS): + return await self._controller.get_schedule() except RainbirdApiException as err: raise UpdateFailed(f"Error communicating with Device: {err}") from err diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index 7f1dfe74752..6aebe791b4a 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -1,7 +1,5 @@ """The number platform for rainbird.""" -from __future__ import annotations - import logging from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index 8b4805a9b0e..32ef1017fdf 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -4,7 +4,7 @@ rules: brands: done dependency-transparency: done common-modules: done - has-entity-name: done + has-entity-name: todo action-setup: status: done comment: | @@ -25,7 +25,7 @@ rules: status: exempt comment: Integration is polling and does not subscribe to events. unique-config-entry: done - entity-unique-id: done + entity-unique-id: todo docs-installation-instructions: status: todo comment: | diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 9fab1af0a23..701ca5acd38 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -1,7 +1,5 @@ """Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorEntity, SensorEntityDescription diff --git a/homeassistant/components/rainbird/services.py b/homeassistant/components/rainbird/services.py index d889c4cb49d..669eed3072a 100644 --- a/homeassistant/components/rainbird/services.py +++ b/homeassistant/components/rainbird/services.py @@ -1,7 +1,5 @@ """Rain Bird Irrigation system services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index bb6f90c0356..048fb9eae1c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -1,7 +1,5 @@ """Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rainbird/types.py b/homeassistant/components/rainbird/types.py index cc43353ac17..22f6fcf6bff 100644 --- a/homeassistant/components/rainbird/types.py +++ b/homeassistant/components/rainbird/types.py @@ -1,7 +1,5 @@ """Types for Rain Bird integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 240550827d4..9e5e26e9166 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Melnor RainCloud sprinkler water timer.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 6804a7c3ccc..06b5a4e8d4d 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -1,7 +1,5 @@ """Support for Melnor RainCloud sprinkler water timer.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 23858ce2ad8..29887b040af 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -1,7 +1,5 @@ """Support for Melnor RainCloud sprinkler water timer.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 5be2e778c5d..87f817e4b0d 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -1,30 +1,26 @@ """The Rainforest Eagle integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EagleDataCoordinator +from .coordinator import EagleDataCoordinator, RainforestEagleConfigEntry PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RainforestEagleConfigEntry +) -> bool: """Set up Rainforest Eagle from a config entry.""" coordinator = EagleDataCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RainforestEagleConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index b7ac70527dc..87d489b3cef 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rainforest Eagle integration.""" -from __future__ import annotations - import logging from typing import Any @@ -76,22 +74,28 @@ class RainforestEagleConfigFlow(ConfigFlow, domain=DOMAIN): elif eagle_type == TYPE_EAGLE_100: user_input[CONF_TYPE] = eagle_type - # For EAGLE-100, there is no hardware address to select, so set it to None and move on + # For EAGLE-100, there is no hardware address + # to select, so set it to None and move on user_input[CONF_HARDWARE_ADDRESS] = None elif eagle_type == TYPE_EAGLE_200: user_input[CONF_TYPE] = eagle_type - # For EAGLE-200, a connected meter's hardware address is required to create the entry + # For EAGLE-200, a connected meter's hardware + # address is required to create the entry if not hardware_address: - # hardware_address will be None if there are no meters at all or if none are currently Connected + # hardware_address will be None if there are + # no meters at all or if none are + # currently Connected errors["base"] = "no_meters_connected" else: user_input[CONF_HARDWARE_ADDRESS] = hardware_address else: - # This is a device that isn't supported, yet, but was detected by async_get_type + # This is a device that isn't supported, yet, + # but was detected by async_get_type errors["base"] = "unsupported_device_type" - # All information gathering is done, so if there are no errors at this point, create the entry + # All information gathering is done, so if there + # are no errors at this point, create the entry if not errors: return self.async_create_entry( title=user_input[CONF_CLOUD_ID], data=user_input diff --git a/homeassistant/components/rainforest_eagle/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py index 11956681638..fe5ea26331b 100644 --- a/homeassistant/components/rainforest_eagle/coordinator.py +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -1,7 +1,5 @@ """Rainforest data.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -23,17 +21,21 @@ from .const import ( ) from .data import UPDATE_100_ERRORS +type RainforestEagleConfigEntry = ConfigEntry[EagleDataCoordinator] + _LOGGER = logging.getLogger(__name__) class EagleDataCoordinator(DataUpdateCoordinator): """Get the latest data from the Eagle device.""" - config_entry: ConfigEntry + config_entry: RainforestEagleConfigEntry eagle100_reader: Eagle100Reader | None = None eagle200_meter: aioeagle.ElectricMeter | None = None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: RainforestEagleConfigEntry + ) -> None: """Initialize the data object.""" if config_entry.data[CONF_TYPE] == TYPE_EAGLE_100: self.model = "EAGLE-100" diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index adf135d53f5..9049202725a 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -1,7 +1,5 @@ """Rainforest data.""" -from __future__ import annotations - import asyncio import logging @@ -33,8 +31,7 @@ class InvalidAuth(RainforestError): async def async_get_type(hass, cloud_id, install_code, host): - """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" - # For EAGLE-200, fetch the hardware address of the first connected meter, too. + """Try API call 'get_network_info' to see if target is Eagle-100 or Eagle-200.""" hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host ) @@ -50,7 +47,8 @@ async def async_get_type(hass, cloud_id, install_code, host): if meters is not None: if meters: - # If there is at least one meter, use the first one with a connection status of "Connected" + # If there is at least one meter, use the first + # one with a connection status of "Connected" hardware_address = next( ( m.hardware_address @@ -60,7 +58,9 @@ async def async_get_type(hass, cloud_id, install_code, host): None, ) else: - # If there are no meters (empty list, since None was already checked for), set the hardware address to None + # If there are no meters (empty list, since None + # was already checked for), set the hardware + # address to None hardware_address = None return TYPE_EAGLE_200, hardware_address @@ -70,7 +70,8 @@ async def async_get_type(hass, cloud_id, install_code, host): try: response = await hass.async_add_executor_job(reader.get_network_info) except ValueError as err: - # This could be invalid auth because it doesn't check 401 and tries to read JSON. + # This could be invalid auth because it doesn't check + # 401 and tries to read JSON. raise InvalidAuth from err except UPDATE_100_ERRORS as error: _LOGGER.error("Failed to connect during setup: %s", error) diff --git a/homeassistant/components/rainforest_eagle/diagnostics.py b/homeassistant/components/rainforest_eagle/diagnostics.py index ec40f2515b1..f6f06a1e986 100644 --- a/homeassistant/components/rainforest_eagle/diagnostics.py +++ b/homeassistant/components/rainforest_eagle/diagnostics.py @@ -1,26 +1,21 @@ """Diagnostics support for Eagle.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE, DOMAIN -from .coordinator import EagleDataCoordinator +from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE +from .coordinator import RainforestEagleConfigEntry TO_REDACT = {CONF_CLOUD_ID, CONF_INSTALL_CODE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: RainforestEagleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EagleDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": config_entry.runtime_data.data, } diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 6f4cbf4f02c..ade06b9cf23 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -1,14 +1,11 @@ """Support for the Rainforest Eagle energy monitor.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +14,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EagleDataCoordinator +from .coordinator import EagleDataCoordinator, RainforestEagleConfigEntry SENSORS = ( SensorEntityDescription( @@ -46,11 +43,11 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RainforestEagleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [EagleSensor(coordinator, description) for description in SENSORS] if coordinator.data.get("zigbee:Price") not in (None, "invalid"): @@ -78,7 +75,11 @@ class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.cloud_id}-${coordinator.hardware_address}-{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.cloud_id}" + f"-${coordinator.hardware_address}" + f"-{entity_description.key}" + ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.cloud_id)}, manufacturer="Rainforest Automation", diff --git a/homeassistant/components/rainforest_raven/__init__.py b/homeassistant/components/rainforest_raven/__init__.py index b68d995262a..21037f1c5f4 100644 --- a/homeassistant/components/rainforest_raven/__init__.py +++ b/homeassistant/components/rainforest_raven/__init__.py @@ -1,7 +1,5 @@ """Integration for Rainforest RAVEn devices.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index f8e3dde446a..d0efbd254f1 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -1,15 +1,11 @@ """Config flow for Rainforest RAVEn devices.""" -from __future__ import annotations - import asyncio from typing import Any from aioraven.data import MeterType from aioraven.device import RAVEnConnectionError from aioraven.serial import RAVEnSerialDevice -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -25,16 +21,19 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN -def _format_id(value: str | int) -> str: +def _format_id(value: str | int | None) -> str: if isinstance(value, str): return value return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: +def _generate_unique_id(info: usb.USBDevice | usb.SerialDevice | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" + vid = info.vid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None + pid = info.pid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None + return ( - f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" + f"{_format_id(vid)}:{_format_id(pid)}_{info.serial_number}" f"_{info.manufacturer}_{info.description}" ) @@ -60,7 +59,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): meter_info = await raven_device.get_meter_info(meter=meter) if meter_info and ( meter_info.meter_type is None - or meter_info.meter_type == MeterType.ELECTRIC + or meter_info.meter_type is MeterType.ELECTRIC ): self._meter_macs.add(meter.hex()) self._dev_path = dev_path @@ -101,8 +100,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" - device = discovery_info.device - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device unique_id = _generate_unique_id(discovery_info) await self.async_set_unique_id(unique_id) try: @@ -119,31 +117,29 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) existing_devices = [ entry.data[CONF_DEVICE] for entry in self._async_current_entries() ] - unused_ports = [ + port_map = { usb.human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - port.vid, - port.pid, - ) + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, + ): port for port in ports if port.device not in existing_devices - ] - if not unused_ports: + } + if not port_map: return self.async_abort(reason="no_devices_found") errors = {} if user_input is not None and user_input.get(CONF_DEVICE, "").strip(): - port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))] - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, port.device - ) + port = port_map[user_input[CONF_DEVICE]] + dev_path = port.device unique_id = _generate_unique_id(port) await self.async_set_unique_id(unique_id) try: @@ -155,5 +151,5 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): else: return await self.async_step_meters() - schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))}) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py index 31df922a168..a4bd6e3df9e 100644 --- a/homeassistant/components/rainforest_raven/coordinator.py +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -1,7 +1,5 @@ """Data update coordination for Rainforest RAVEn devices.""" -from __future__ import annotations - import asyncio from dataclasses import asdict from datetime import timedelta diff --git a/homeassistant/components/rainforest_raven/diagnostics.py b/homeassistant/components/rainforest_raven/diagnostics.py index 6c06b0d65cc..6a8a74cea54 100644 --- a/homeassistant/components/rainforest_raven/diagnostics.py +++ b/homeassistant/components/rainforest_raven/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for a Rainforest RAVEn device.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 658689c7e6c..ca6a69ff7d9 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -1,7 +1,5 @@ """Sensor entity for a Rainforest RAVEn device.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c4fe2b49006..18368b5e9ef 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -1,7 +1,5 @@ """Support for RainMachine devices.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta @@ -15,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_CONDITION, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PASSWORD, @@ -69,7 +68,6 @@ PLATFORMS = [ Platform.UPDATE, ] -CONF_CONDITION = "condition" CONF_DEWPOINT = "dewpoint" CONF_ET = "et" CONF_MAXRH = "maxrh" @@ -458,6 +456,7 @@ async def async_setup_entry( # noqa: C901 ): if hass.services.has_service(DOMAIN, service_name): continue + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, service_name, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index e4ed00930dd..9d96610b7fd 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -1,7 +1,5 @@ """Buttons for the RainMachine integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 6ce95d7e547..cc3cc4137f1 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the RainMachine component.""" -from __future__ import annotations - from typing import Any from regenmaschine import Client @@ -102,7 +100,10 @@ class RainMachineFlowHandler(ConfigFlow, domain=DOMAIN): # A new rain machine: We will change out the unique id # for the mac address once we authenticate, however we want to # prevent multiple different rain machines on the same network - # from being shown in discovery + # from being shown in discovery. + # Uses the discovered IP address as a temporary unique ID for + # discovery de-duplication until the MAC address is available. + # pylint: disable-next=home-assistant-unique-id-ip-based await self.async_set_unique_id(ip_address) self._abort_if_unique_id_configured() self.discovered_ip_address = ip_address diff --git a/homeassistant/components/rainmachine/coordinator.py b/homeassistant/components/rainmachine/coordinator.py index de43e5a073f..c73bd49a126 100644 --- a/homeassistant/components/rainmachine/coordinator.py +++ b/homeassistant/components/rainmachine/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the RainMachine integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from datetime import timedelta from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/rainmachine/diagnostics.py b/homeassistant/components/rainmachine/diagnostics.py index 598b8aefa5f..089c9416a9b 100644 --- a/homeassistant/components/rainmachine/diagnostics.py +++ b/homeassistant/components/rainmachine/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for RainMachine.""" -from __future__ import annotations - from typing import Any from regenmaschine.errors import RainMachineError diff --git a/homeassistant/components/rainmachine/entity.py b/homeassistant/components/rainmachine/entity.py index 441cf8237b6..2e1f65c8967 100644 --- a/homeassistant/components/rainmachine/entity.py +++ b/homeassistant/components/rainmachine/entity.py @@ -1,7 +1,5 @@ """Support for RainMachine devices.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT diff --git a/homeassistant/components/rainmachine/icons.json b/homeassistant/components/rainmachine/icons.json index d84e91d7c0c..785b5a599a0 100644 --- a/homeassistant/components/rainmachine/icons.json +++ b/homeassistant/components/rainmachine/icons.json @@ -29,29 +29,29 @@ } }, "sensor": { - "translation_key_0": { - "default": "mdi:abc" + "flow_sensor_clicks_cubic_meter": { + "default": "mdi:water-pump" }, - "translation_key_1": { - "default": "mdi:abc" + "flow_sensor_consumed_liters": { + "default": "mdi:water-pump" }, - "translation_key_2": { - "default": "mdi:abc" + "flow_sensor_leak_clicks": { + "default": "mdi:pipe-leak" }, - "translation_key_3": { - "default": "mdi:abc" + "flow_sensor_leak_volume": { + "default": "mdi:pipe-leak" }, - "translation_key_4": { - "default": "mdi:abc" + "flow_sensor_start_index": { + "default": "mdi:water-pump" }, - "translation_key_5": { - "default": "mdi:abc" + "flow_sensor_watering_clicks": { + "default": "mdi:water-pump" }, - "translation_key_6": { - "default": "mdi:abc" + "last_leak_detected": { + "default": "mdi:pipe-leak" }, - "translation_key_7": { - "default": "mdi:abc" + "rain_sensor_rain_start": { + "default": "mdi:weather-pouring" } }, "switch": { diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 5b23a5d79ef..c9271530130 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -1,7 +1,5 @@ """Support for RainMachine selects.""" -from __future__ import annotations - from dataclasses import dataclass from regenmaschine.errors import RainMachineError diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 4677a6d8bca..22c43ae850c 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,7 +1,5 @@ """Support for sensor data from RainMachine.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, cast diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 9b62b15d196..f2458c3bc78 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -1,7 +1,5 @@ """Component providing support for RainMachine programs and zones.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index 312937184e4..7a21e1d3b01 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -1,7 +1,5 @@ """Support for RainMachine updates.""" -from __future__ import annotations - from dataclasses import dataclass from enum import Enum from typing import Any diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index c784c3c471f..120ae76b299 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -1,7 +1,5 @@ """Define RainMachine utilities.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 1af85b43486..9e5eecf0b68 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -1,7 +1,5 @@ """Support for showing random states.""" -from __future__ import annotations - from collections.abc import Mapping from random import getrandbits from typing import Any diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 6ea296c791e..e70a69060e5 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -1,7 +1,5 @@ """Support for showing random numbers.""" -from __future__ import annotations - from collections.abc import Mapping from random import randrange from typing import Any diff --git a/homeassistant/components/rapt_ble/__init__.py b/homeassistant/components/rapt_ble/__init__.py index 4fd4c32a4cc..e3bc676d39a 100644 --- a/homeassistant/components/rapt_ble/__init__.py +++ b/homeassistant/components/rapt_ble/__init__.py @@ -1,7 +1,5 @@ """The rapt_ble integration.""" -from __future__ import annotations - import logging from rapt_ble import RAPTPillBluetoothDeviceData @@ -14,27 +12,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type RAPTBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: RAPTBLEConfigEntry) -> bool: """Set up RAPT BLE device from a config entry.""" address = entry.unique_id assert address is not None data = RAPTPillBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RAPTBLEConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rapt_ble/config_flow.py b/homeassistant/components/rapt_ble/config_flow.py index 3bbd18f387c..03eb4e4f62e 100644 --- a/homeassistant/components/rapt_ble/config_flow.py +++ b/homeassistant/components/rapt_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for rapt_ble.""" -from __future__ import annotations - from typing import Any from rapt_ble import RAPTPillBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -72,6 +71,7 @@ class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index 01aeedbd344..9213f8cd164 100644 --- a/homeassistant/components/rapt_ble/sensor.py +++ b/homeassistant/components/rapt_ble/sensor.py @@ -1,15 +1,11 @@ """Support for RAPT Pill hydrometers.""" -from __future__ import annotations - from rapt_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -28,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import RAPTBLEConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -98,20 +94,20 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: RAPTBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the RAPT Pill BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( RAPTPillBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class RAPTPillBluetoothSensorEntity( diff --git a/homeassistant/components/raspberry_pi/__init__.py b/homeassistant/components/raspberry_pi/__init__.py index 8095eb9dfe0..646c026f33a 100644 --- a/homeassistant/components/raspberry_pi/__init__.py +++ b/homeassistant/components/raspberry_pi/__init__.py @@ -1,8 +1,6 @@ """The Raspberry Pi integration.""" -from __future__ import annotations - -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import HassioNotReadyError, get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -16,9 +14,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - if (os_info := get_os_info(hass)) is None: - # The hassio integration has not yet fetched data from the supervisor - raise ConfigEntryNotReady + try: + os_info = get_os_info(hass) + except HassioNotReadyError as err: + raise ConfigEntryNotReady from err board: str | None if (board := os_info.get("board")) is None or not board.startswith("rpi"): diff --git a/homeassistant/components/raspberry_pi/config_flow.py b/homeassistant/components/raspberry_pi/config_flow.py index d049776a6e0..119fe55f27e 100644 --- a/homeassistant/components/raspberry_pi/config_flow.py +++ b/homeassistant/components/raspberry_pi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Raspberry Pi integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/raspberry_pi/hardware.py b/homeassistant/components/raspberry_pi/hardware.py index 1386f8628b3..b108423034b 100644 --- a/homeassistant/components/raspberry_pi/hardware.py +++ b/homeassistant/components/raspberry_pi/hardware.py @@ -1,7 +1,5 @@ """The Raspberry Pi hardware platform.""" -from __future__ import annotations - from homeassistant.components.hardware import BoardInfo, HardwareInfo from homeassistant.components.hassio import get_os_info from homeassistant.core import HomeAssistant, callback @@ -37,8 +35,7 @@ MODELS = { @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" - if (os_info := get_os_info(hass)) is None: - raise HomeAssistantError + os_info = get_os_info(hass) board: str | None if (board := os_info.get("board")) is None: raise HomeAssistantError diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 19a1b724c48..da3a4055f50 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -1,7 +1,5 @@ """Support for switches that can be controlled using the RaspyRFM rc module.""" -from __future__ import annotations - from typing import Any from raspyrfm_client import RaspyRFMClient @@ -9,8 +7,8 @@ from raspyrfm_client.device_implementations.controlunit.actions import Action from raspyrfm_client.device_implementations.controlunit.controlunit_constants import ( ControlUnitModel, ) -from raspyrfm_client.device_implementations.gateway.manufacturer.gateway_constants import ( - GatewayModel, +from raspyrfm_client.device_implementations.gateway.manufacturer import ( + gateway_constants as _gw, ) from raspyrfm_client.device_implementations.manufacturer_constants import Manufacturer import voluptuous as vol @@ -31,6 +29,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +GatewayModel = _gw.GatewayModel + CONF_GATEWAY_MANUFACTURER = "gateway_manufacturer" CONF_GATEWAY_MODEL = "gateway_model" CONF_CONTROLUNIT_MANUFACTURER = "controlunit_manufacturer" diff --git a/homeassistant/components/rdw/__init__.py b/homeassistant/components/rdw/__init__.py index 7a2cfbf6df3..0cf39a86857 100644 --- a/homeassistant/components/rdw/__init__.py +++ b/homeassistant/components/rdw/__init__.py @@ -1,31 +1,24 @@ """Support for RDW.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RDWConfigEntry) -> bool: """Set up RDW from a config entry.""" coordinator = RDWDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RDWConfigEntry) -> bool: """Unload RDW config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index d407cfc1b87..737884bc034 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -1,7 +1,5 @@ """Support for RDW binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -12,14 +10,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -46,49 +43,32 @@ BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RDWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - RDWBinarySensorEntity( - coordinator=coordinator, - description=description, - ) + RDWBinarySensorEntity(entry.runtime_data, description) for description in BINARY_SENSORS - if description.is_on_fn(coordinator.data) is not None + if description.is_on_fn(entry.runtime_data.data) is not None ) -class RDWBinarySensorEntity( - CoordinatorEntity[RDWDataUpdateCoordinator], BinarySensorEntity -): +class RDWBinarySensorEntity(RDWEntity, BinarySensorEntity): """Defines an RDW binary sensor.""" entity_description: RDWBinarySensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, description: RDWBinarySensorEntityDescription, ) -> None: """Initialize RDW binary sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.data.license_plate)}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) - @property def is_on(self) -> bool: """Return the state of the sensor.""" diff --git a/homeassistant/components/rdw/config_flow.py b/homeassistant/components/rdw/config_flow.py index cf59abc650c..e80d3c12515 100644 --- a/homeassistant/components/rdw/config_flow.py +++ b/homeassistant/components/rdw/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the RDW integration.""" -from __future__ import annotations - from typing import Any from vehicle import RDW, RDWError, RDWUnknownLicensePlateError diff --git a/homeassistant/components/rdw/const.py b/homeassistant/components/rdw/const.py index d9f99010dd7..0044df5be50 100644 --- a/homeassistant/components/rdw/const.py +++ b/homeassistant/components/rdw/const.py @@ -1,7 +1,5 @@ """Constants for the RDW integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/rdw/coordinator.py b/homeassistant/components/rdw/coordinator.py index 2b9bb866790..18c76501e72 100644 --- a/homeassistant/components/rdw/coordinator.py +++ b/homeassistant/components/rdw/coordinator.py @@ -1,23 +1,23 @@ """Data update coordinator for RDW.""" -from __future__ import annotations - -from vehicle import RDW, Vehicle +from vehicle import RDW, RDWConnectionError, RDWError, Vehicle from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL +type RDWConfigEntry = ConfigEntry[RDWDataUpdateCoordinator] + class RDWDataUpdateCoordinator(DataUpdateCoordinator[Vehicle]): """Class to manage fetching RDW data.""" - config_entry: ConfigEntry + config_entry: RDWConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: RDWConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -33,4 +33,15 @@ class RDWDataUpdateCoordinator(DataUpdateCoordinator[Vehicle]): async def _async_update_data(self) -> Vehicle: """Fetch data from RDW.""" - return await self._rdw.vehicle() + try: + return await self._rdw.vehicle() + except RDWConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except RDWError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/rdw/diagnostics.py b/homeassistant/components/rdw/diagnostics.py index bf5f8fbd904..a6e2b2fdd1a 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -1,20 +1,15 @@ """Diagnostics support for RDW.""" -from __future__ import annotations - from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RDWConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - data: dict[str, Any] = coordinator.data.to_dict() + data: dict[str, Any] = entry.runtime_data.data.to_dict() return data diff --git a/homeassistant/components/rdw/entity.py b/homeassistant/components/rdw/entity.py new file mode 100644 index 00000000000..3bd0b44e790 --- /dev/null +++ b/homeassistant/components/rdw/entity.py @@ -0,0 +1,25 @@ +"""Base entity for the RDW integration.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator + + +class RDWEntity(CoordinatorEntity[RDWDataUpdateCoordinator]): + """Defines an RDW entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RDWDataUpdateCoordinator) -> None: + """Initialize an RDW entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.data.license_plate)}, + manufacturer=coordinator.data.brand, + name=f"{coordinator.data.brand} {coordinator.data.license_plate}", + model=coordinator.data.model, + configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", + ) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2ab90e55ef0..647b25ada6a 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rdw", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["vehicle==2.2.2"] + "requirements": ["vehicle==3.0.0"] } diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 08e7d772d15..b0a5ef0354f 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -1,7 +1,5 @@ """Support for RDW sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date @@ -13,14 +11,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_LICENSE_PLATE, DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -48,47 +45,29 @@ SENSORS: tuple[RDWSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RDWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - RDWSensorEntity( - coordinator=coordinator, - license_plate=entry.data[CONF_LICENSE_PLATE], - description=description, - ) - for description in SENSORS + RDWSensorEntity(entry.runtime_data, description) for description in SENSORS ) -class RDWSensorEntity(CoordinatorEntity[RDWDataUpdateCoordinator], SensorEntity): +class RDWSensorEntity(RDWEntity, SensorEntity): """Defines an RDW sensor.""" entity_description: RDWSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, - license_plate: str, description: RDWSensorEntityDescription, ) -> None: """Initialize RDW sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{license_plate}_{description.key}" - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{license_plate}")}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) + self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" @property def native_value(self) -> date | str | float | None: diff --git a/homeassistant/components/rdw/strings.json b/homeassistant/components/rdw/strings.json index 5a2683588a4..16480fe4e0d 100644 --- a/homeassistant/components/rdw/strings.json +++ b/homeassistant/components/rdw/strings.json @@ -35,5 +35,13 @@ "name": "Ascription date" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the RDW service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the RDW service." + } } } diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index c805b491440..6aaa6d662e3 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -1,7 +1,5 @@ """The ReCollect Waste integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry @@ -9,19 +7,20 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> bool: """Set up ReCollect Waste as config entry.""" coordinator = ReCollectWasteDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -30,18 +29,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> bool: """Unload an ReCollect Waste config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index f057d1c3368..6fe1801eef8 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -1,25 +1,22 @@ """Support for ReCollect Waste calendars.""" -from __future__ import annotations - import datetime from aiorecollect.client import PickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @callback def async_get_calendar_event_from_pickup_event( - entry: ConfigEntry, pickup_event: PickupEvent + entry: RecollectWasteConfigEntry, pickup_event: PickupEvent ) -> CalendarEvent: """Get a HASS CalendarEvent from an aiorecollect PickupEvent.""" pickup_type_string = ", ".join( @@ -36,13 +33,11 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([ReCollectWasteCalendar(coordinator, entry)]) + async_add_entities([ReCollectWasteCalendar(entry.runtime_data, entry)]) class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): @@ -54,7 +49,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, ) -> None: """Initialize the ReCollect Waste entity.""" super().__init__(coordinator, entry) @@ -74,7 +69,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): current_event = next( event for event in self.coordinator.data - if event.date >= datetime.date.today() + if event.date >= dt_util.now().date() ) except StopIteration: self._event = None diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 299af2609e3..3ebc23de825 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -1,24 +1,18 @@ """Config flow for ReCollect Waste integration.""" -from __future__ import annotations - from typing import Any from aiorecollect.client import Client from aiorecollect.errors import RecollectError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_FRIENDLY_NAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER +from .coordinator import RecollectWasteConfigEntry DATA_SCHEMA = vol.Schema( {vol.Required(CONF_PLACE_ID): str, vol.Required(CONF_SERVICE_ID): str} @@ -33,7 +27,7 @@ class RecollectWasteConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RecollectWasteConfigEntry, ) -> RecollectWasteOptionsFlowHandler: """Define the config flow to handle options.""" return RecollectWasteOptionsFlowHandler() diff --git a/homeassistant/components/recollect_waste/coordinator.py b/homeassistant/components/recollect_waste/coordinator.py index 4a7e9d58b12..ab652e4c125 100644 --- a/homeassistant/components/recollect_waste/coordinator.py +++ b/homeassistant/components/recollect_waste/coordinator.py @@ -1,8 +1,6 @@ """Data update coordinator for ReCollect Waste.""" -from __future__ import annotations - -from datetime import date, timedelta +from datetime import timedelta from aiorecollect.client import Client, PickupEvent from aiorecollect.errors import RecollectError @@ -11,18 +9,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER +type RecollectWasteConfigEntry = ConfigEntry[ReCollectWasteDataUpdateCoordinator] + DEFAULT_UPDATE_INTERVAL = timedelta(days=1) class ReCollectWasteDataUpdateCoordinator(DataUpdateCoordinator[list[PickupEvent]]): """Class to manage fetching ReCollect Waste data.""" - config_entry: ConfigEntry + config_entry: RecollectWasteConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: RecollectWasteConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -50,7 +53,7 @@ class ReCollectWasteDataUpdateCoordinator(DataUpdateCoordinator[list[PickupEvent # This ensures that data about when the next pickup is will be # returned when the next pickup is the first day of the next month. # Ex: Today is August 31st, tomorrow is a pickup on September 1st. - today = date.today() + today = dt_util.now().date() return await self._client.async_get_pickup_events( start_date=today, end_date=today + timedelta(days=35), diff --git a/homeassistant/components/recollect_waste/diagnostics.py b/homeassistant/components/recollect_waste/diagnostics.py index a9007eb5d2c..4e40e4ec3d7 100644 --- a/homeassistant/components/recollect_waste/diagnostics.py +++ b/homeassistant/components/recollect_waste/diagnostics.py @@ -1,17 +1,14 @@ """Diagnostics support for ReCollect Waste.""" -from __future__ import annotations - import dataclasses from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import CONF_PLACE_ID, DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import CONF_PLACE_ID +from .coordinator import RecollectWasteConfigEntry CONF_AREA_NAME = "area_name" CONF_TITLE = "title" @@ -26,15 +23,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RecollectWasteConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data], + "data": [dataclasses.asdict(event) for event in entry.runtime_data.data], }, TO_REDACT, ) diff --git a/homeassistant/components/recollect_waste/entity.py b/homeassistant/components/recollect_waste/entity.py index 891f1706f77..6d051b548a5 100644 --- a/homeassistant/components/recollect_waste/entity.py +++ b/homeassistant/components/recollect_waste/entity.py @@ -1,11 +1,10 @@ """Define a base ReCollect Waste entity.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator class ReCollectWasteEntity(CoordinatorEntity[ReCollectWasteDataUpdateCoordinator]): @@ -16,7 +15,7 @@ class ReCollectWasteEntity(CoordinatorEntity[ReCollectWasteDataUpdateCoordinator def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 97d6c1413e1..f3d56524137 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,20 +1,16 @@ """Support for ReCollect Waste sensors.""" -from __future__ import annotations - -from datetime import date - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import LOGGER +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @@ -38,14 +34,12 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - ReCollectWasteSensor(coordinator, entry, description) + ReCollectWasteSensor(entry.runtime_data, entry, description) for description in SENSOR_DESCRIPTIONS ) @@ -63,7 +57,7 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize.""" @@ -75,7 +69,9 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - relevant_events = (e for e in self.coordinator.data if e.date >= date.today()) + relevant_events = ( + e for e in self.coordinator.data if e.date >= dt_util.now().date() + ) pickup_index = self.PICKUP_INDEX_MAP[self.entity_description.key] try: diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a350feac519..53bee619c28 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -1,7 +1,5 @@ """Support for recording details.""" -from __future__ import annotations - import logging from typing import Any @@ -25,7 +23,6 @@ from homeassistant.helpers.integration_platform import ( ) from homeassistant.helpers.recorder import DATA_INSTANCE from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.event_type import EventType # Pre-import backup to avoid it being imported @@ -128,7 +125,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: """Check if an entity is being recorded. diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py index fb3b38c61c5..a86abc46c5d 100644 --- a/homeassistant/components/recorder/auto_repairs/events/schema.py +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -1,7 +1,5 @@ """Events schema repairs.""" -from __future__ import annotations - from typing import TYPE_CHECKING from ...db_schema import EventData, Events diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index 2a09324dfe1..cfc43b778d0 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -1,7 +1,5 @@ """Schema repairs.""" -from __future__ import annotations - from collections.abc import Iterable, Mapping import logging from typing import TYPE_CHECKING @@ -87,7 +85,10 @@ def _validate_table_schema_has_correct_collation( instance: Recorder, table_object: type[DeclarativeBase], ) -> set[str]: - """Ensure the table has the correct collation to avoid union errors with mixed collations.""" + """Ensure the table has the correct collation. + + This avoids union errors with mixed collations. + """ schema_errors: set[str] = set() # Mark the session as read_only to ensure that the test data is not committed # to the database and we always rollback when the scope is exited @@ -225,7 +226,8 @@ def _check_columns( continue schema_errors.add(f"{table_name}.{supports}") _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + "Column %s in database table %s does not support" + " %s (stored=%s != expected=%s)", column, table_name, supports, diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py index 3900f4fb763..a44db7613ec 100644 --- a/homeassistant/components/recorder/auto_repairs/states/schema.py +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -1,7 +1,5 @@ """States schema repairs.""" -from __future__ import annotations - from typing import TYPE_CHECKING from ...db_schema import StateAttributes, States diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index f203d6ab69a..a7c9dc49d46 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -1,7 +1,5 @@ """Statistics duplication repairs.""" -from __future__ import annotations - import json import logging import os diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index 3cf16bd500f..0f50c44318f 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -1,7 +1,5 @@ """Statistics schema repairs.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index ce9aa452fae..eff2fdfe9ed 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -1,7 +1,5 @@ """The Recorder websocket API.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index e3448c86910..0e33470f697 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,7 +1,5 @@ """Recorder constants.""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 4f1a9a0d878..5b750bedc3d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1,7 +1,5 @@ """Support for recording details.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Iterable from concurrent.futures import CancelledError @@ -378,7 +376,7 @@ class Recorder(threading.Thread): return cast(int, self._psutil.psutil.virtual_memory().available) def _reached_max_backlog(self) -> bool: - """Check if the system has reached the max queue backlog and return True if it has.""" + """Check if the system has reached the max queue backlog.""" # First check the minimum value since its cheap if self.backlog < MAX_QUEUE_BACKLOG_MIN_VALUE: return False @@ -805,7 +803,7 @@ class Recorder(threading.Thread): def _activate_and_set_db_ready( self, schema_status: migration.SchemaValidationStatus ) -> None: - """Activate the table managers or schedule migrations and mark the db as ready.""" + """Activate table managers or schedule migrations and mark db as ready.""" with session_scope(session=self.get_session()) as session: # Prime the statistics meta manager as soon as possible # since we want the frontend queries to avoid a thundering @@ -870,7 +868,10 @@ class Recorder(threading.Thread): def _guarded_process_one_task_or_event_or_recover( self, task: RecorderTask | Event ) -> None: - """Process a task, guarding against exceptions to ensure the loop does not collapse.""" + """Process a task, guarding against exceptions. + + This ensures the loop does not collapse. + """ _LOGGER.debug("Processing task: %s", task) try: self._process_one_task_or_event_or_recover(task) @@ -1222,7 +1223,9 @@ class Recorder(threading.Thread): "state_id": state_id, "last_reported_ts": last_reported_timestamp, } - for state_id, last_reported_timestamp in pending_last_reported.items() + for state_id, last_reported_timestamp in ( + pending_last_reported.items() + ) ], ) session.commit() @@ -1298,7 +1301,10 @@ class Recorder(threading.Thread): @callback def async_get_commit_future(self) -> asyncio.Future[None] | None: - """Return a future that will wait for the next commit or None if nothing pending.""" + """Return a future that will wait for the next commit. + + Returns None if nothing is pending. + """ if self._queue.empty() and not self._event_session_has_pending_writes: return None future: asyncio.Future[None] = self.hass.loop.create_future() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 65de7e853a3..7120b1caa5c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -1,7 +1,5 @@ """Models for SQLAlchemy.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -179,20 +177,21 @@ class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): class NativeLargeBinary(LargeBinary): - """A faster version of LargeBinary for engines that support python bytes natively.""" + """A faster version of LargeBinary for native bytes engines.""" def result_processor(self, dialect: Dialect, coltype: Any) -> Callable | None: """No conversion needed for engines that support native bytes.""" return None -# Although all integers are same in SQLite, it does not allow an identity column to be BIGINT +# Although all integers are same in SQLite, it does not allow +# an identity column to be BIGINT # https://sqlite.org/forum/info/2dfa968a702e1506e885cb06d92157d492108b22bf39459506ab9f7125bca7fd ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite") # For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32 # for sqlite and postgresql we use a bigint UINT_32_TYPE = BigInteger().with_variant( - mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call] + mysql.INTEGER(unsigned=True), "mysql", "mariadb", ) @@ -206,12 +205,12 @@ JSONB_VARIANT_CAST = Text().with_variant( ) DATETIME_TYPE = ( DateTime(timezone=True) - .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call] + .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call] ) DOUBLE_TYPE = ( Float() - .with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call] + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) diff --git a/homeassistant/components/recorder/executor.py b/homeassistant/components/recorder/executor.py index a6d09e41dd2..c44b419a33d 100644 --- a/homeassistant/components/recorder/executor.py +++ b/homeassistant/components/recorder/executor.py @@ -1,7 +1,5 @@ """Database executor helpers.""" -from __future__ import annotations - from collections.abc import Callable from concurrent.futures.thread import _threads_queues, _worker import threading diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 509f0d2a067..7aec2e563ba 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -1,7 +1,5 @@ """Provide pre-made queries on top of the recorder component.""" -from __future__ import annotations - from collections.abc import Callable, Collection, Iterable from typing import Any @@ -159,7 +157,8 @@ class Filters: # - All entities included if not have_include and not have_exclude: raise RuntimeError( - "No filter configuration provided, check has_config before calling this method." + "No filter configuration provided, check" + " has_config before calling this method." ) # Case 2 - Only includes diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index 32e0b4f9a71..454662121f8 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -1,7 +1,5 @@ """Provide pre-made queries on top of the recorder component.""" -from __future__ import annotations - from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f4b37d36742..bf13510f163 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,8 +7,8 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.41", - "fnv-hash-fast==2.0.0", + "SQLAlchemy==2.0.50", + "fnv-hash-fast==2.0.3", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 9430ba52e33..7c632cbc713 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,7 +1,5 @@ """Schema migration helpers.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable, Iterable import contextlib @@ -957,7 +955,8 @@ def _delete_foreign_key_violations( f"t1.{column} IS NOT NULL AND " "NOT EXISTS " "(SELECT 1 " - f"FROM (SELECT {foreign_column} from {foreign_table}) AS t2 " + f"FROM (SELECT {foreign_column} " + f"from {foreign_table}) AS t2 " f"WHERE t2.{foreign_column} = t1.{column})) " "LIMIT 100000;" ) @@ -1612,7 +1611,8 @@ class _SchemaVersion32Migrator(_SchemaVersionMigrator, target_version=32): _drop_index(self.session_maker, "states", "ix_states_last_updated") _drop_index(self.session_maker, "events", "ix_events_time_fired") with session_scope(session=self.session_maker()) as session: - # In version 31 we migrated all the time_fired, last_updated, and last_changed + # In version 31 we migrated all the time_fired, + # last_updated, and last_changed # columns to be timestamps. In version 32 we need to wipe the old columns # since they are no longer used and take up a significant amount of space. assert self.instance.engine is not None, "engine should never be None" @@ -1832,13 +1832,17 @@ class _SchemaVersion39Migrator(_SchemaVersionMigrator, target_version=39): class _SchemaVersion40Migrator(_SchemaVersionMigrator, target_version=40): def _apply_update(self) -> None: """Version specific update method.""" - # ix_events_event_type_id is a left-prefix of ix_events_event_type_id_time_fired_ts + # ix_events_event_type_id is a left-prefix of + # ix_events_event_type_id_time_fired_ts _drop_index(self.session_maker, "events", "ix_events_event_type_id") - # ix_states_metadata_id is a left-prefix of ix_states_metadata_id_last_updated_ts + # ix_states_metadata_id is a left-prefix of + # ix_states_metadata_id_last_updated_ts _drop_index(self.session_maker, "states", "ix_states_metadata_id") - # ix_statistics_metadata_id is a left-prefix of ix_statistics_statistic_id_start_ts + # ix_statistics_metadata_id is a left-prefix of + # ix_statistics_statistic_id_start_ts _drop_index(self.session_maker, "statistics", "ix_statistics_metadata_id") - # ix_statistics_short_term_metadata_id is a left-prefix of ix_statistics_short_term_statistic_id_start_ts + # ix_statistics_short_term_metadata_id is a left-prefix + # of ix_statistics_short_term_statistic_id_start_ts _drop_index( self.session_maker, "statistics_short_term", @@ -2021,7 +2025,8 @@ class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49): self.session_maker, "statistics_meta", [ - f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}" + f"mean_type {self.column_types.small_int_type}" + f" NOT NULL DEFAULT {StatisticMeanType.NONE.value}" ], ) @@ -2036,7 +2041,8 @@ class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49): connection = session.connection() connection.execute( text( - "UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true" + "UPDATE statistics_meta SET mean_type=:mean_type" + " WHERE has_mean=true" ), {"mean_type": StatisticMeanType.ARITHMETIC.value}, ) @@ -2173,7 +2179,8 @@ def _migrate_statistics_columns_to_timestamp_removing_duplicates( # Log at error level to ensure the user sees this message in the log # since we logged the error above. _LOGGER.error( - "Statistics migration successfully recovered after statistics table duplicate cleanup" + "Statistics migration successfully recovered after" + " statistics table duplicate cleanup" ) @@ -2244,7 +2251,8 @@ def _wipe_old_string_time_columns( text( "UPDATE events set time_fired=NULL " "where event_id in " - "(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);" + "(select event_id from events" + " where time_fired_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -2252,7 +2260,9 @@ def _wipe_old_string_time_columns( text( "UPDATE states set last_updated=NULL, last_changed=NULL " "where state_id in " - "(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);" + "(select state_id from states" + " where last_updated_ts is NOT NULL" + " LIMIT 100000);" ) ) session.commit() @@ -2288,15 +2298,19 @@ def _migrate_columns_to_timestamp( ) ) elif engine.dialect.name == SupportedDialect.MYSQL: - # With MySQL we do this in chunks to avoid hitting the `innodb_buffer_pool_size` limit - # We also need to do this in a loop since we can't be sure that we have - # updated all rows in the table until the rowcount is 0 + # With MySQL we do this in chunks to avoid hitting + # the `innodb_buffer_pool_size` limit. + # We also need to do this in a loop since we can't + # be sure that we have updated all rows in the table + # until the rowcount is 0 while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," + "IF(time_fired is NULL" + " or UNIX_TIMESTAMP(time_fired)" + " is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " @@ -2309,7 +2323,9 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," + "IF(last_updated is NULL" + " or UNIX_TIMESTAMP(last_updated)" + " is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" @@ -2328,9 +2344,13 @@ def _migrate_columns_to_timestamp( text( "UPDATE events SET " "time_fired_ts= " - "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) " + "(case when time_fired is NULL then 0" + " else EXTRACT(EPOCH FROM" + " time_fired::timestamptz) end) " "WHERE event_id IN ( " - "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 " + "SELECT event_id FROM events" + " where time_fired_ts is NULL" + " LIMIT 100000 " " );" ) ) @@ -2340,10 +2360,15 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), " - "last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) " + "(case when last_updated is NULL then 0" + " else EXTRACT(EPOCH FROM" + " last_updated::timestamptz) end), " + "last_changed_ts=EXTRACT(EPOCH FROM" + " last_changed::timestamptz) " "where state_id IN ( " - "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 " + "SELECT state_id FROM states" + " where last_updated_ts is NULL" + " LIMIT 100000 " " );" ) ) @@ -2410,9 +2435,12 @@ def _migrate_statistics_columns_to_timestamp( # Migrate all data in statistics.start to statistics.start_ts # Migrate all data in statistics.created to statistics.created_ts # Migrate all data in statistics.last_reset to statistics.last_reset_ts - # Migrate all data in statistics_short_term.start to statistics_short_term.start_ts - # Migrate all data in statistics_short_term.created to statistics_short_term.created_ts - # Migrate all data in statistics_short_term.last_reset to statistics_short_term.last_reset_ts + # Migrate all data in statistics_short_term.start + # to statistics_short_term.start_ts + # Migrate all data in statistics_short_term.created + # to statistics_short_term.created_ts + # Migrate all data in statistics_short_term.last_reset + # to statistics_short_term.last_reset_ts result: CursorResult | None = None if engine.dialect.name == SupportedDialect.SQLITE: # With SQLite we do this in one go since it is faster @@ -2429,9 +2457,11 @@ def _migrate_statistics_columns_to_timestamp( ) ) elif engine.dialect.name == SupportedDialect.MYSQL: - # With MySQL we do this in chunks to avoid hitting the `innodb_buffer_pool_size` limit - # We also need to do this in a loop since we can't be sure that we have - # updated all rows in the table until the rowcount is 0 + # With MySQL we do this in chunks to avoid hitting + # the `innodb_buffer_pool_size` limit. + # We also need to do this in a loop since we can't + # be sure that we have updated all rows in the table + # until the rowcount is 0 for table in STATISTICS_TABLES: result = None while result is None or result.rowcount > 0: @@ -2461,11 +2491,17 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" # noqa: S608 - "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start::timestamptz) end), " - "created_ts=EXTRACT(EPOCH FROM created::timestamptz), " - "last_reset_ts=EXTRACT(EPOCH FROM last_reset::timestamptz) " + "(case when start is NULL then 0" + " else EXTRACT(EPOCH FROM" + " start::timestamptz) end), " + "created_ts=EXTRACT(EPOCH FROM" + " created::timestamptz), " + "last_reset_ts=EXTRACT(EPOCH FROM" + " last_reset::timestamptz) " "where id IN (" - f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000" + "SELECT id FROM " + f"{table} where start_ts is NULL" + " LIMIT 100000" ");" ) ) @@ -2626,7 +2662,7 @@ class BaseMigration(ABC): def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate some data, return if the migration needs to run and if it is done.""" - def migration_done(self, instance: Recorder, session: Session) -> None: + def migration_done(self, instance: Recorder, session: Session) -> None: # noqa: B027 """Will be called after migrate returns True or if migration is not needed.""" @abstractmethod @@ -2815,7 +2851,13 @@ class StatesContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): "context_parent_id": None, "context_parent_id_bin": _to_bytes(context_parent_id), } - for state_id, last_updated_ts, context_id, context_user_id, context_parent_id in states + for ( + state_id, + last_updated_ts, + context_id, + context_user_id, + context_parent_id, + ) in states ], ) is_done = not states @@ -2859,7 +2901,13 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): "context_parent_id": None, "context_parent_id_bin": _to_bytes(context_parent_id), } - for event_id, time_fired_ts, context_id, context_user_id, context_parent_id in events + for ( + event_id, + time_fired_ts, + context_id, + context_user_id, + context_parent_id, + ) in events ], ) is_done = not events @@ -2956,7 +3004,8 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): while we are migrating. 1. Link the states to the states_meta table - 2. Remove the entity_id column from the states table (in post_migrate_entity_ids) + 2. Remove the entity_id column from the states table + (in post_migrate_entity_ids) """ _LOGGER.debug("Migrating entity_ids") states_meta_manager = instance.states_meta_manager @@ -3188,7 +3237,8 @@ def rebuild_sqlite_table( session.connection().execute(text("PRAGMA foreign_keys=OFF")) # Step 2 - create a transaction with session_scope(session=session_maker()) as session: - # Step 3 - we know all the indexes, triggers, and views associated with table X + # Step 3 - we know all the indexes, triggers, and + # views associated with table X new_sql = str(CreateTable(table_table).compile(engine)).strip("\n") + ";" source_sql = f"CREATE TABLE {orig_name}" replacement_sql = f"CREATE TABLE {temp_name}" diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index 8f76982a900..edf0383785e 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -1,7 +1,5 @@ """Models for Recorder.""" -from __future__ import annotations - from .context import ( bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py index 90791163f82..dbfd90077e2 100644 --- a/homeassistant/components/recorder/models/context.py +++ b/homeassistant/components/recorder/models/context.py @@ -1,7 +1,5 @@ """Models for Recorder.""" -from __future__ import annotations - from contextlib import suppress from functools import lru_cache import logging diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index 2a4924edab3..f9e397e9147 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -1,7 +1,5 @@ """Models for the database in the Recorder.""" -from __future__ import annotations - from dataclasses import dataclass from awesomeversion import AwesomeVersion diff --git a/homeassistant/components/recorder/models/event.py b/homeassistant/components/recorder/models/event.py index 4e5030bfde7..4010c96588d 100644 --- a/homeassistant/components/recorder/models/event.py +++ b/homeassistant/components/recorder/models/event.py @@ -1,7 +1,5 @@ """Models events in for Recorder.""" -from __future__ import annotations - from typing import Any from homeassistant.util.event_type import EventType diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 28459cfef07..103a4b4c7f1 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -1,7 +1,5 @@ """Models states in for Recorder.""" -from __future__ import annotations - from datetime import datetime import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py index c9cc110e1e0..413063bc343 100644 --- a/homeassistant/components/recorder/models/state_attributes.py +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -1,7 +1,5 @@ """State attributes models.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py index c4d6ccded31..2bfec3749fa 100644 --- a/homeassistant/components/recorder/models/statistics.py +++ b/homeassistant/components/recorder/models/statistics.py @@ -1,7 +1,5 @@ """Models for statistics in the Recorder.""" -from __future__ import annotations - from datetime import datetime, timedelta from enum import IntEnum from typing import Literal, NotRequired, TypedDict diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 91acad1500e..b2aeadef8b3 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -1,7 +1,5 @@ """Models for Recorder.""" -from __future__ import annotations - from datetime import datetime import logging from typing import overload diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 2ee41ba2038..d54fde6423e 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -1,7 +1,5 @@ """A pool for sqlite connections.""" -from __future__ import annotations - import asyncio import logging import threading diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 6b6c2c2c365..13917fe9e31 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,7 +1,5 @@ """Purge old data helper.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index ad725235192..3d2994a3c48 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -1,7 +1,5 @@ """Queries for the recorder.""" -from __future__ import annotations - from collections.abc import Iterable from datetime import datetime @@ -423,7 +421,8 @@ def find_entity_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: def batch_cleanup_entity_ids() -> StatementLambdaElement: """Find entity_id to cleanup.""" - # Self join because This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery' + # Self join because this version of MariaDB doesn't yet + # support 'LIMIT & IN/ALL/ANY/SOME subquery' return lambda_stmt( lambda: ( update(States) diff --git a/homeassistant/components/recorder/repack.py b/homeassistant/components/recorder/repack.py index 8c7ad137d86..f9443083923 100644 --- a/homeassistant/components/recorder/repack.py +++ b/homeassistant/components/recorder/repack.py @@ -1,7 +1,5 @@ """Purge repack helper.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index e836dabed7a..259ec189e26 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -1,7 +1,5 @@ """Support for recorder services.""" -from __future__ import annotations - from datetime import timedelta from typing import cast diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 517bf77b282..329e51460ee 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1,7 +1,5 @@ """Statistics helper.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Iterable, Sequence import dataclasses @@ -57,6 +55,7 @@ from homeassistant.util.unit_conversion import ( ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -214,6 +213,7 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -687,14 +687,15 @@ def _get_first_id_stmt(start: datetime) -> StatementLambdaElement: CUSTOM_EQUIVALENT_UNITS_SCHEMA = vol.Schema({str: {vol.Any(str, None): str}}) -# Keep track of domains for which a warning about failure to collect custom units has been logged +# Keep track of domains for which a warning about failure +# to collect custom units has been logged _warn_custom_units_error: set[str] = set() def _get_custom_equivalent_units( hass: HomeAssistant, ) -> dict[str, dict[str | None, str]]: - """Check whether any integration supplies custom equivalent units for its entities.""" + """Check whether any integration supplies custom equivalent units.""" custom_equivalent_units_per_entity: dict[str, dict[str | None, str]] = {} for domain, platform in hass.data[DATA_RECORDER].recorder_platforms.items(): custom_equivalent_units = getattr( @@ -731,7 +732,8 @@ def _get_custom_equivalent_units( if domain not in _warn_custom_units_error: _warn_custom_units_error.add(domain) _LOGGER.warning( - "Error processing result of %s for recorder platform domain %s: %s for object: %s", + "Error processing result of %s for recorder" + " platform domain %s: %s for object: %s", INTEGRATION_PLATFORM_CUSTOM_EQUIVALENT_UNITS, domain, inv, @@ -1257,7 +1259,8 @@ def reduce_day_ts_factory() -> tuple[ _lower_bound: float = 0 _upper_bound: float = 0 - # We have to recreate _local_from_timestamp in the closure in case the timezone changes + # We have to recreate _local_from_timestamp in the closure + # in case the timezone changes _local_from_timestamp = partial( datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) @@ -1305,7 +1308,8 @@ def reduce_week_ts_factory() -> tuple[ _lower_bound: float = 0 _upper_bound: float = 0 - # We have to recreate _local_from_timestamp in the closure in case the timezone changes + # We have to recreate _local_from_timestamp in the closure + # in case the timezone changes _local_from_timestamp = partial( datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) @@ -1362,7 +1366,8 @@ def reduce_month_ts_factory() -> tuple[ _lower_bound: float = 0 _upper_bound: float = 0 - # We have to recreate _local_from_timestamp in the closure in case the timezone changes + # We have to recreate _local_from_timestamp in the closure + # in case the timezone changes _local_from_timestamp = partial( datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) @@ -1408,7 +1413,8 @@ def reduce_year_ts_factory() -> tuple[ _lower_bound: float = 0 _upper_bound: float = 0 - # We have to recreate _local_from_timestamp in the closure in case the timezone changes + # We have to recreate _local_from_timestamp in the closure + # in case the timezone changes _local_from_timestamp = partial( datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) @@ -2376,7 +2382,7 @@ def get_latest_short_term_statistics_with_session( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[StatisticsRow]]: - """Return the latest short term statistics for a list of statistic_ids with a session.""" + """Return latest short term statistics for a list of statistic_ids.""" # Fetch metadata for the given statistic_ids if not metadata: metadata = get_instance(hass).statistics_meta_manager.get_many( @@ -2797,7 +2803,8 @@ def _async_import_statistics( ) if start.minute != 0 or start.second != 0 or start.microsecond != 0: raise HomeAssistantError( - "Invalid timestamp: timestamps must be from the top of the hour (minutes and seconds = 0)" + "Invalid timestamp: timestamps must be from the" + " top of the hour (minutes and seconds = 0)" ) statistic["start"] = dt_util.as_utc(start) @@ -3158,7 +3165,8 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: session.connection().execute( text( - f"update {table} set start = NULL, created = NULL, last_reset = NULL;" # noqa: S608 + f"update {table} set start = NULL," # noqa: S608 + " created = NULL, last_reset = NULL;" ) ) elif engine.dialect.name == SupportedDialect.MYSQL: @@ -3168,7 +3176,10 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" # noqa: S608 + f"UPDATE {table} set start=NULL," # noqa: S608 + " created=NULL, last_reset=NULL" + " where start is not NULL" + " LIMIT 100000;" ) ) .rowcount @@ -3183,8 +3194,11 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # noqa: S608 - f"where id in (select id from {table} where start is not NULL LIMIT 100000)" + f"UPDATE {table} set start=NULL," # noqa: S608 + " created=NULL, last_reset=NULL " + "where id in (select id from " + f"{table} where start is not NULL" + " LIMIT 100000)" ) ) .rowcount diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 6923b792b8b..0e8db748a0e 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/recorder/system_health/mysql.py b/homeassistant/components/recorder/system_health/mysql.py index 21d9d952d3a..188a4a7ff2f 100644 --- a/homeassistant/components/recorder/system_health/mysql.py +++ b/homeassistant/components/recorder/system_health/mysql.py @@ -1,7 +1,5 @@ """Provide info to system health for mysql.""" -from __future__ import annotations - from sqlalchemy import text from sqlalchemy.orm.session import Session diff --git a/homeassistant/components/recorder/system_health/postgresql.py b/homeassistant/components/recorder/system_health/postgresql.py index b917e548ae5..be47f1ae18b 100644 --- a/homeassistant/components/recorder/system_health/postgresql.py +++ b/homeassistant/components/recorder/system_health/postgresql.py @@ -1,7 +1,5 @@ """Provide info to system health for postgresql.""" -from __future__ import annotations - from sqlalchemy import text from sqlalchemy.orm.session import Session diff --git a/homeassistant/components/recorder/system_health/sqlite.py b/homeassistant/components/recorder/system_health/sqlite.py index 95123d1fd14..e216ca91234 100644 --- a/homeassistant/components/recorder/system_health/sqlite.py +++ b/homeassistant/components/recorder/system_health/sqlite.py @@ -1,7 +1,5 @@ """Provide info to system health for sqlite.""" -from __future__ import annotations - from sqlalchemy import text from sqlalchemy.orm.session import Session diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index 82a08ebfc68..3e74ac4a673 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,7 +1,5 @@ """Managers for each table.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from lru import LRU diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 1bab49ec543..d28d7cc892b 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -1,7 +1,5 @@ """Support managing EventData.""" -from __future__ import annotations - from collections.abc import Collection, Iterable import logging from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 266c970fe1f..c180fa6f0cc 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -1,7 +1,5 @@ """Support managing EventTypes.""" -from __future__ import annotations - from collections.abc import Iterable from typing import TYPE_CHECKING, Any, cast @@ -123,7 +121,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): self._pending[event_type] = db_event_type def post_commit_pending(self) -> None: - """Call after commit to load the event_type_ids of the new EventTypes into the LRU. + """Call after commit to load new EventTypes into the LRU. This call is not thread-safe and must be called from the recorder thread. diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 191fa44c194..daf7e41b9bd 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -1,7 +1,5 @@ """Track recorder run history.""" -from __future__ import annotations - from datetime import datetime from sqlalchemy.orm.session import Session diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index aa7e6f3e926..083d53ef075 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -1,7 +1,5 @@ """Support managing StateAttributes.""" -from __future__ import annotations - from collections.abc import Collection, Iterable import logging from typing import TYPE_CHECKING, cast @@ -99,7 +97,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): def _load_from_hashes( self, hashes: Collection[int], session: Session ) -> dict[str, int | None]: - """Load the shared_attrs to attributes_ids mapping into memory from a list of hashes. + """Load shared_attrs to attributes_ids mapping from hashes. This call is not thread-safe and must be called from the recorder thread. @@ -127,7 +125,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): self._pending[shared_attrs] = db_state_attributes def post_commit_pending(self) -> None: - """Call after commit to load the attributes_ids of the new StateAttributes into the LRU. + """Call after commit to load new StateAttributes into the LRU. This call is not thread-safe and must be called from the recorder thread. diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py index fafcfa0ea61..b1031a66b68 100644 --- a/homeassistant/components/recorder/table_managers/states.py +++ b/homeassistant/components/recorder/table_managers/states.py @@ -1,7 +1,5 @@ """Support managing States.""" -from __future__ import annotations - from collections.abc import Sequence from typing import Any, cast diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 0ea2c7415b9..7e9dd4b5cd4 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -1,7 +1,5 @@ """Support managing StatesMeta.""" -from __future__ import annotations - from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, cast @@ -130,7 +128,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): self._pending[entity_id] = db_states_meta def post_commit_pending(self) -> None: - """Call after commit to load the metadata_ids of the new StatesMeta into the LRU. + """Call after commit to load new StatesMeta into the LRU. This call is not thread-safe and must be called from the recorder thread. diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index ce660bccc01..79e77e2baa6 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -1,7 +1,5 @@ """Support managing StatesMeta.""" -from __future__ import annotations - import logging import threading from typing import TYPE_CHECKING, Any, Final, Literal @@ -53,7 +51,8 @@ def _generate_get_metadata_stmt( ) -> StatementLambdaElement: """Generate a statement to fetch metadata with the passed filters. - Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. + Depending on the schema version, either mean_type (added in + version 49) or has_mean column is used. """ columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META) if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: @@ -133,7 +132,9 @@ class StatisticsMetaManager: mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE]) except ValueError: _LOGGER.warning( - "Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping", + "Invalid mean type found for" + " statistic_id: %s, mean_type:" + " %s. Skipping", statistic_id, row[INDEX_MEAN_TYPE], ) @@ -205,8 +206,10 @@ class StatisticsMetaManager: recorder thread. """ if "mean_type" not in new_metadata: - # To maintain backward compatibility after adding 'mean_type' in schema version 49, - # we must still check for its presence. Even though type hints suggest it should always exist, + # To maintain backward compatibility after adding + # 'mean_type' in schema version 49, we must still + # check for its presence. Even though type hints + # suggest it should always exist, # custom integrations might omit it, so we need to guard against that. new_metadata["mean_type"] = ( # type: ignore[unreachable] StatisticMeanType.ARITHMETIC @@ -267,7 +270,8 @@ class StatisticsMetaManager: ) -> dict[str, tuple[int, StatisticMetaData]]: """Fetch meta data. - Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. + Returns a dict of (metadata_id, StatisticMetaData) tuples + indexed by statistic_id. If statistic_ids is given, fetch metadata only for the listed statistics_ids. If statistic_type is given, fetch metadata only for statistic_ids supporting it. @@ -285,7 +289,8 @@ class StatisticsMetaManager: # so the code was ripped out to reduce the maintenance # burden. raise ValueError( - "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" + "Providing statistic_type and statistic_source" + " is mutually exclusive of statistic_ids" ) results = self.get_from_cache_threadsafe(statistic_ids) @@ -379,7 +384,8 @@ class StatisticsMetaManager: self._assert_in_recorder_thread() if self.get(session, new_statistic_id): _LOGGER.error( - "Cannot rename statistic_id `%s` to `%s` because the new statistic_id is already in use", + "Cannot rename statistic_id `%s` to `%s` because" + " the new statistic_id is already in use", old_statistic_id, new_statistic_id, ) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 9ce021c59a5..815764db7f8 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -1,7 +1,5 @@ """Support for recording details.""" -from __future__ import annotations - import abc import asyncio from collections.abc import Callable, Iterable @@ -177,7 +175,7 @@ class StatisticsTask(RecorderTask): @dataclass(slots=True) class CompileMissingStatisticsTask(RecorderTask): - """An object to insert into the recorder queue to run a compile missing statistics.""" + """An object to insert into the recorder queue to compile missing statistics.""" def run(self, instance: Recorder) -> None: """Run statistics task to compile missing statistics.""" diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 53beb6b43c2..1cfc0a92efb 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,7 +1,5 @@ """SQLAlchemy util functions.""" -from __future__ import annotations - from collections.abc import Callable, Generator, Sequence import contextlib from contextlib import contextmanager @@ -447,10 +445,10 @@ def setup_connection_for_dialect( slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: - old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] - dbapi_connection.isolation_level = None # type: ignore[attr-defined] + old_isolation = dbapi_connection.isolation_level + dbapi_connection.isolation_level = None execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL") - dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined] + dbapi_connection.isolation_level = old_isolation # WAL mode only needs to be setup once # instead of every time we open the sqlite connection # as its persistent and isn't free to call every time. diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 58dfd2271d2..764e4af613b 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,7 +1,5 @@ """The Recorder websocket API.""" -from __future__ import annotations - import asyncio from datetime import datetime as dt import logging @@ -30,6 +28,7 @@ from homeassistant.util.unit_conversion import ( ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -90,6 +89,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), + vol.Optional("frequency"): vol.In(FrequencyConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("nitrogen_dioxide"): vol.In( diff --git a/homeassistant/components/recovery_mode/manifest.json b/homeassistant/components/recovery_mode/manifest.json index 5837a648ecb..4323b54ac55 100644 --- a/homeassistant/components/recovery_mode/manifest.json +++ b/homeassistant/components/recovery_mode/manifest.json @@ -3,7 +3,6 @@ "name": "Recovery Mode", "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["persistent_notification"], "documentation": "https://www.home-assistant.io/integrations/recovery_mode", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index 6a49a9a5699..a8029e7a51e 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -1,7 +1,5 @@ """Support for Ankuoo RecSwitch MS6126 devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 963d7999c26..1e434aeaeff 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -1,7 +1,5 @@ """Support for Reddit.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/redgtech/__init__.py b/homeassistant/components/redgtech/__init__.py index dd1a44ddfaa..a5045fb320d 100644 --- a/homeassistant/components/redgtech/__init__.py +++ b/homeassistant/components/redgtech/__init__.py @@ -1,7 +1,5 @@ """Initialize the Redgtech integration for Home Assistant.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/redgtech/config_flow.py b/homeassistant/components/redgtech/config_flow.py index 05cddd43ba3..9cde0a03801 100644 --- a/homeassistant/components/redgtech/config_flow.py +++ b/homeassistant/components/redgtech/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Redgtech integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/redgtech/coordinator.py b/homeassistant/components/redgtech/coordinator.py index bbfdf79e306..2849e4b5ea2 100644 --- a/homeassistant/components/redgtech/coordinator.py +++ b/homeassistant/components/redgtech/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Redgtech integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta @@ -37,7 +35,8 @@ type RedgtechConfigEntry = ConfigEntry[RedgtechDataUpdateCoordinator] class RedgtechDataUpdateCoordinator(DataUpdateCoordinator[dict[str, RedgtechDevice]]): """Coordinator to manage fetching data from the Redgtech API. - Uses a dictionary keyed by unique_id for O(1) device lookup instead of O(n) list iteration. + Uses a dictionary keyed by unique_id for O(1) device lookup + instead of O(n) list iteration. """ config_entry: RedgtechConfigEntry diff --git a/homeassistant/components/redgtech/switch.py b/homeassistant/components/redgtech/switch.py index 6faf8ff0d59..e2c80e3aa93 100644 --- a/homeassistant/components/redgtech/switch.py +++ b/homeassistant/components/redgtech/switch.py @@ -1,7 +1,5 @@ """Integration for Redgtech switches.""" -from __future__ import annotations - from typing import Any from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index eb2085efda4..64a2191e814 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -1,17 +1,14 @@ """Refoss devices platform loader.""" -from __future__ import annotations - from datetime import timedelta from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval -from .bridge import DiscoveryService -from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .bridge import DiscoveryService, RefossConfigEntry +from .const import DISCOVERY_SCAN_INTERVAL from .util import refoss_discovery_server PLATFORMS: Final = [ @@ -20,12 +17,11 @@ PLATFORMS: Final = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Set up Refoss from a config entry.""" - hass.data.setdefault(DOMAIN, {}) discover = await refoss_discovery_server(hass) refoss_discovery = DiscoveryService(hass, entry, discover) - hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + entry.runtime_data = refoss_discovery await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -43,16 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: - refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] - refoss_discovery.discovery.clean_up() - hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(COORDINATORS) - - return unload_ok + entry.runtime_data.discovery.clean_up() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index a3ba9ea663d..e1ebf413772 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -1,7 +1,5 @@ """Refoss integration.""" -from __future__ import annotations - from refoss_ha.device import DeviceInfo from refoss_ha.device_manager import async_build_base_device from refoss_ha.discovery import Discovery, Listener @@ -10,15 +8,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .coordinator import RefossDataUpdateCoordinator +type RefossConfigEntry = ConfigEntry[DiscoveryService] + class DiscoveryService(Listener): """Discovery event handler for refoss devices.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, discovery: Discovery + self, hass: HomeAssistant, config_entry: RefossConfigEntry, discovery: Discovery ) -> None: """Init discovery service.""" self.hass = hass @@ -27,7 +27,7 @@ class DiscoveryService(Listener): self.discovery = discovery self.discovery.add_listener(self) - hass.data[DOMAIN].setdefault(COORDINATORS, []) + self.coordinators: list[RefossDataUpdateCoordinator] = [] async def device_found(self, device_info: DeviceInfo) -> None: """Handle new device found on the network.""" @@ -37,7 +37,7 @@ class DiscoveryService(Listener): return coordo = RefossDataUpdateCoordinator(self.hass, self.config_entry, device) - self.hass.data[DOMAIN][COORDINATORS].append(coordo) + self.coordinators.append(coordo) await coordo.async_refresh() _LOGGER.debug( @@ -49,7 +49,7 @@ class DiscoveryService(Listener): async def device_update(self, device_info: DeviceInfo) -> None: """Handle updates in device information, update if ip has changed.""" - for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + for coordinator in self.coordinators: if coordinator.device.device_info.mac == device_info.mac: _LOGGER.debug( "Update device %s ip to %s", diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py index 5b667940731..8a5d4f39a14 100644 --- a/homeassistant/components/refoss/config_flow.py +++ b/homeassistant/components/refoss/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Refoss integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py index 62db733ece5..ed1788c188d 100644 --- a/homeassistant/components/refoss/const.py +++ b/homeassistant/components/refoss/const.py @@ -1,7 +1,5 @@ """const.""" -from __future__ import annotations - from logging import Logger, getLogger _LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py index 381f64614b5..d31bf19c87e 100644 --- a/homeassistant/components/refoss/coordinator.py +++ b/homeassistant/components/refoss/coordinator.py @@ -1,7 +1,5 @@ """Helper and coordinator for refoss.""" -from __future__ import annotations - from datetime import timedelta from refoss_ha.controller.device import BaseDevice diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 92090a192e8..07487b2203c 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -1,7 +1,5 @@ """Support for refoss sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, @@ -25,15 +22,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .bridge import RefossDataUpdateCoordinator -from .const import ( - _LOGGER, - CHANNEL_DISPLAY_NAME, - COORDINATORS, - DISPATCH_DEVICE_DISCOVERED, - DOMAIN, - SENSOR_EM, -) +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, CHANNEL_DISPLAY_NAME, DISPATCH_DEVICE_DISCOVERED, SENSOR_EM from .entity import RefossEntity @@ -116,7 +106,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @@ -146,7 +136,7 @@ async def async_setup_entry( ) _LOGGER.debug("Device %s add sensor entity success", device.dev_name) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index 1d465f7f319..44b148be11b 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -1,31 +1,28 @@ """Switch for Refoss.""" -from __future__ import annotations - from typing import Any from refoss_ha.controller.toggle import ToggleXMix from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import RefossDataUpdateCoordinator -from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .bridge import RefossConfigEntry, RefossDataUpdateCoordinator +from .const import _LOGGER, DISPATCH_DEVICE_DISCOVERED from .entity import RefossEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RefossConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: RefossDataUpdateCoordinator) -> None: """Register the device.""" device = coordinator.device if not isinstance(device, ToggleXMix): @@ -39,7 +36,7 @@ async def async_setup_entry( async_add_entities(new_entities) _LOGGER.debug("Device %s add switch entity success", device.dev_name) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in config_entry.runtime_data.coordinators: init_device(coordinator) config_entry.async_on_unload( diff --git a/homeassistant/components/refoss/util.py b/homeassistant/components/refoss/util.py index 4c44b9537af..62be9fd60bf 100644 --- a/homeassistant/components/refoss/util.py +++ b/homeassistant/components/refoss/util.py @@ -1,7 +1,5 @@ """Refoss helpers functions.""" -from __future__ import annotations - from refoss_ha.discovery import Discovery from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/rehlko/__init__.py b/homeassistant/components/rehlko/__init__.py index d07289d256c..60415eec423 100644 --- a/homeassistant/components/rehlko/__init__.py +++ b/homeassistant/components/rehlko/__init__.py @@ -1,7 +1,5 @@ """The Rehlko integration.""" -from __future__ import annotations - import logging from aiokem import AioKem, AuthenticationError diff --git a/homeassistant/components/rehlko/binary_sensor.py b/homeassistant/components/rehlko/binary_sensor.py index f2353c09088..4ef52654203 100644 --- a/homeassistant/components/rehlko/binary_sensor.py +++ b/homeassistant/components/rehlko/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Rehlko integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/rehlko/config_flow.py b/homeassistant/components/rehlko/config_flow.py index 16f97bb385a..c05a0814338 100644 --- a/homeassistant/components/rehlko/config_flow.py +++ b/homeassistant/components/rehlko/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rehlko integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/rehlko/coordinator.py b/homeassistant/components/rehlko/coordinator.py index f5a268dff74..5462e06b28a 100644 --- a/homeassistant/components/rehlko/coordinator.py +++ b/homeassistant/components/rehlko/coordinator.py @@ -1,7 +1,5 @@ """The Rehlko coordinator.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/rehlko/entity.py b/homeassistant/components/rehlko/entity.py index d1c25742f42..893bf314c86 100644 --- a/homeassistant/components/rehlko/entity.py +++ b/homeassistant/components/rehlko/entity.py @@ -1,7 +1,5 @@ """Base class for Rehlko entities.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py index 6ff45b1a464..a640ea2a194 100644 --- a/homeassistant/components/rehlko/sensor.py +++ b/homeassistant/components/rehlko/sensor.py @@ -1,7 +1,5 @@ """Support for Rehlko sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 6265fffc7b6..4a324cb84dc 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -4,8 +4,6 @@ For more info on the API see: https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API """ -from __future__ import annotations - from contextlib import suppress from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index 593abb7da2c..07b04c32b1c 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -1,7 +1,5 @@ """Store RTM configuration in Home Assistant storage.""" -from __future__ import annotations - import json from pathlib import Path from typing import cast diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index f7d87fbf021..5451c88e2fa 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -1,7 +1,5 @@ """Support to interface with universal remote control devices.""" -from __future__ import annotations - from collections.abc import Iterable from datetime import timedelta from enum import IntFlag @@ -25,7 +23,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) @@ -73,7 +70,6 @@ REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( ) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the remote is on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) diff --git a/homeassistant/components/remote/condition.py b/homeassistant/components/remote/condition.py new file mode 100644 index 00000000000..51788c95fa8 --- /dev/null +++ b/homeassistant/components/remote/condition.py @@ -0,0 +1,17 @@ +"""Provides conditions for remotes.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import DOMAIN + +CONDITIONS: dict[str, type[Condition]] = { + "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), + "is_on": make_entity_state_condition(DOMAIN, STATE_ON), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the remote conditions.""" + return CONDITIONS diff --git a/homeassistant/components/remote/conditions.yaml b/homeassistant/components/remote/conditions.yaml new file mode 100644 index 00000000000..8556406d476 --- /dev/null +++ b/homeassistant/components/remote/conditions.yaml @@ -0,0 +1,19 @@ +.condition_common: &condition_common + target: + entity: + domain: remote + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_off: *condition_common +is_on: *condition_common diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index a0ae707724e..274663f7928 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for remotes.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/remote/device_condition.py b/homeassistant/components/remote/device_condition.py index f34b7f61580..70a5f87b5ef 100644 --- a/homeassistant/components/remote/device_condition.py +++ b/homeassistant/components/remote/device_condition.py @@ -1,7 +1,5 @@ """Provides device conditions for remotes.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index 0f08cb155aa..15306dd81ca 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for remotes.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/remote/icons.json b/homeassistant/components/remote/icons.json index 1560336d7c1..1436e21e2b6 100644 --- a/homeassistant/components/remote/icons.json +++ b/homeassistant/components/remote/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_off": { + "condition": "mdi:remote-off" + }, + "is_on": { + "condition": "mdi:remote" + } + }, "entity_component": { "_": { "default": "mdi:remote", diff --git a/homeassistant/components/remote/reproduce_state.py b/homeassistant/components/remote/reproduce_state.py index 06a04acf0ef..b84982d65c7 100644 --- a/homeassistant/components/remote/reproduce_state.py +++ b/homeassistant/components/remote/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Remote state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py index 5d2dff87909..9fb6d1c2052 100644 --- a/homeassistant/components/remote/significant_change.py +++ b/homeassistant/components/remote/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Remote state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 8cad5e289ac..3603c54df1b 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,6 +1,35 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_off": { + "description": "Tests if one or more remotes are off.", + "fields": { + "behavior": { + "name": "[%key:component::remote::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::condition_for_name%]" + } + }, + "name": "Remote is off" + }, + "is_on": { + "description": "Tests if one or more remotes are on.", + "fields": { + "behavior": { + "name": "[%key:component::remote::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::condition_for_name%]" + } + }, + "name": "Remote is on" + } }, "device_automation": { "action_type": { @@ -30,18 +59,9 @@ } } }, - "selector": { - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "delete_command": { - "description": "Deletes a command or a list of commands from the database.", + "description": "Deletes a command or a list of commands from a remote's database.", "fields": { "command": { "description": "The single command or the list of commands to be deleted.", @@ -52,10 +72,10 @@ "name": "Device" } }, - "name": "Delete command" + "name": "Delete remote command" }, "learn_command": { - "description": "Learns a command or a list of commands from a device.", + "description": "Teaches a remote a command or list of commands from a device.", "fields": { "alternative": { "description": "If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state.", @@ -78,7 +98,7 @@ "name": "Timeout" } }, - "name": "Learn command" + "name": "Learn remote command" }, "send_command": { "description": "Sends a command or a list of commands to a device.", @@ -104,15 +124,15 @@ "name": "Repeats" } }, - "name": "Send command" + "name": "Send remote command" }, "toggle": { "description": "Sends the toggle command.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle via remote" }, "turn_off": { "description": "Sends the turn off command.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off via remote" }, "turn_on": { "description": "Sends the turn on command.", @@ -122,7 +142,7 @@ "name": "Activity" } }, - "name": "[%key:common::action::turn_on%]" + "name": "Turn on via remote" } }, "title": "Remote", @@ -132,6 +152,9 @@ "fields": { "behavior": { "name": "[%key:component::remote::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::trigger_for_name%]" } }, "name": "Remote turned off" @@ -141,6 +164,9 @@ "fields": { "behavior": { "name": "[%key:component::remote::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::trigger_for_name%]" } }, "name": "Remote turned on" diff --git a/homeassistant/components/remote/triggers.yaml b/homeassistant/components/remote/triggers.yaml index 6dadeba1fd2..f7f1e7abdee 100644 --- a/homeassistant/components/remote/triggers.yaml +++ b/homeassistant/components/remote/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: turned_off: *trigger_common turned_on: *trigger_common diff --git a/homeassistant/components/remote_calendar/client.py b/homeassistant/components/remote_calendar/client.py index 927da8731d8..cfa532b7cc0 100644 --- a/homeassistant/components/remote_calendar/client.py +++ b/homeassistant/components/remote_calendar/client.py @@ -9,7 +9,7 @@ async def get_calendar( username: str | None = None, password: str | None = None, ) -> Response: - """Make an HTTP GET request using Home Assistant's async HTTPX client with timeout.""" + """Make an HTTP GET request using Home Assistant's async HTTPX client.""" auth: Auth | None = None if username is not None and password is not None: auth = BasicAuth(username, password) diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 62bc30664b8..be05c661e81 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==13.2.2"] + "requirements": ["ical==13.2.5"] } diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 695bdf36246..885983405d5 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary sensor using RPi GPIO.""" -from __future__ import annotations - from gpiozero import DigitalInputDevice import requests import voluptuous as vol diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index a3e17dc1dbc..fb848de5cf4 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -1,7 +1,5 @@ """Allows to configure a switch using RPi GPIO.""" -from __future__ import annotations - from typing import Any from gpiozero import LED diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index da3769654c4..c8f30aab7a6 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -1,16 +1,16 @@ """Support for Renault devices.""" import aiohttp +from renault_api.exceptions import NotAuthenticatedException from renault_api.gigya.exceptions import GigyaException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from .const import CONF_LOCALE, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS, RenaultConfigurationKeys from .renault_hub import RenaultHub from .services import async_setup_services @@ -28,20 +28,12 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry ) -> bool: """Load a config entry.""" - renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE]) - try: - login_success = await renault_hub.attempt_login( - config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] - ) - except (aiohttp.ClientConnectionError, GigyaException) as exc: - raise ConfigEntryNotReady from exc - - if not login_success: - raise ConfigEntryAuthFailed - + renault_hub = RenaultHub(hass, config_entry.data[RenaultConfigurationKeys.LOCALE]) try: await renault_hub.async_initialise(config_entry) - except aiohttp.ClientError as exc: + except NotAuthenticatedException as exc: + raise ConfigEntryAuthFailed from exc + except (aiohttp.ClientError, GigyaException) as exc: raise ConfigEntryNotReady from exc config_entry.runtime_data = renault_hub diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 4c09ca44601..2df0a930ec8 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Renault binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -81,7 +79,7 @@ def _plugged_in_value_lambda( ) -> bool | None: """Return true if the vehicle is plugged in.""" if (plug_status := self.coordinator.data.get_plug_status()) is not None: - return plug_status == PlugState.PLUGGED + return plug_status is PlugState.PLUGGED if ( charging_status := self.coordinator.data.get_charging_status() diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 6a883f4dc88..2952e8fadd1 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -1,7 +1,5 @@ """Support for Renault button entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index adaa092c6da..30aa982d787 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,10 +1,8 @@ """Config flow to configure Renault component.""" -from __future__ import annotations - from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any import aiohttp from renault_api.const import AVAILABLE_LOCALES @@ -16,21 +14,20 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN +from .const import DOMAIN, RenaultConfigurationKeys from .renault_hub import RenaultHub _LOGGER = logging.getLogger(__name__) USER_SCHEMA = vol.Schema( { - vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + vol.Required(RenaultConfigurationKeys.LOCALE): vol.In(AVAILABLE_LOCALES.keys()), + vol.Required(RenaultConfigurationKeys.USERNAME): str, + vol.Required(RenaultConfigurationKeys.PASSWORD): str, } ) -REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) +REAUTH_SCHEMA = vol.Schema({vol.Required(RenaultConfigurationKeys.PASSWORD): str}) class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): @@ -52,13 +49,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} suggested_values: Mapping[str, Any] | None = None if user_input: - locale = user_input[CONF_LOCALE] + locale = user_input[RenaultConfigurationKeys.LOCALE] self.renault_config.update(user_input) self.renault_config.update(AVAILABLE_LOCALES[locale]) self.renault_hub = RenaultHub(self.hass, locale) try: login_success = await self.renault_hub.attempt_login( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + user_input[RenaultConfigurationKeys.USERNAME], + user_input[RenaultConfigurationKeys.PASSWORD], ) except aiohttp.ClientConnectionError, GigyaException: errors["base"] = "cannot_connect" @@ -67,6 +65,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: if login_success: + if TYPE_CHECKING: + assert self.renault_hub.login_token + self.renault_config[RenaultConfigurationKeys.LOGIN_TOKEN] = ( + self.renault_hub.login_token + ) return await self.async_step_kamereon() errors["base"] = "invalid_credentials" suggested_values = user_input @@ -86,7 +89,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Select Kamereon account.""" if user_input: - await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + await self.async_set_unique_id( + user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] + ) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() self.renault_config.update(user_input) @@ -99,7 +104,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): self.renault_config.update(user_input) return self.async_create_entry( - title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config + title=user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID], + data=self.renault_config, ) accounts = await self.renault_hub.get_account_ids() @@ -107,13 +113,17 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="kamereon_no_account") if len(accounts) == 1: return await self.async_step_kamereon( - user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]} + user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: accounts[0]} ) return self.async_show_form( step_id="kamereon", data_schema=vol.Schema( - {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} + { + vol.Required(RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID): vol.In( + accounts + ) + } ), ) @@ -131,13 +141,23 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input: # Check credentials - self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE]) + self.renault_hub = RenaultHub( + self.hass, reauth_entry.data[RenaultConfigurationKeys.LOCALE] + ) if await self.renault_hub.attempt_login( - reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + reauth_entry.data[RenaultConfigurationKeys.USERNAME], + user_input[RenaultConfigurationKeys.PASSWORD], ): + if TYPE_CHECKING: + assert self.renault_hub.login_token return self.async_update_reload_and_abort( reauth_entry, - data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + data_updates={ + RenaultConfigurationKeys.PASSWORD: user_input[ + RenaultConfigurationKeys.PASSWORD + ], + RenaultConfigurationKeys.LOGIN_TOKEN: self.renault_hub.login_token, + }, ) errors = {"base": "invalid_credentials"} @@ -145,7 +165,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors, - description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + description_placeholders={ + RenaultConfigurationKeys.USERNAME: reauth_entry.data[ + RenaultConfigurationKeys.USERNAME + ] + }, ) async def async_step_reconfigure( diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 446cca10905..229db6bd783 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,11 +1,21 @@ """Constants for the Renault component.""" +from typing import Final + from homeassistant.const import Platform DOMAIN = "renault" -CONF_LOCALE = "locale" -CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" + +class RenaultConfigurationKeys: + """Configuration keys.""" + + KAMEREON_ACCOUNT_ID: Final = "kamereon_account_id" + LOCALE: Final = "locale" + LOGIN_TOKEN: Final = "login_token" + PASSWORD: Final = "password" + USERNAME: Final = "username" + # normal number of allowed calls per hour to the API # for a single car and the 7 coordinator, it is a scan every 7mn diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index 481c27c42db..b2e3ba9bb0e 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -1,7 +1,5 @@ """Proxy to handle account communication with Renault servers.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 795e0ce80b2..9a85b1b0614 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -1,7 +1,5 @@ """Support for Renault device trackers.""" -from __future__ import annotations - from dataclasses import dataclass from renault_api.kamereon.models import KamereonVehicleLocationData diff --git a/homeassistant/components/renault/diagnostics.py b/homeassistant/components/renault/diagnostics.py index 5a8cb41beca..20f179bbd3b 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -1,22 +1,20 @@ """Diagnostics support for Renault.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID +from .const import RenaultConfigurationKeys from .renault_vehicle import RenaultVehicleProxy TO_REDACT = { - CONF_KAMEREON_ACCOUNT_ID, - CONF_PASSWORD, - CONF_USERNAME, + RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID, + RenaultConfigurationKeys.LOGIN_TOKEN, + RenaultConfigurationKeys.PASSWORD, + RenaultConfigurationKeys.USERNAME, "radioCode", "registrationNumber", "vin", diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index d10dd9b9149..21e4988d8fc 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -1,14 +1,10 @@ """Base classes for Renault entities.""" -from __future__ import annotations - from dataclasses import dataclass -from typing import cast from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RenaultDataUpdateCoordinator @@ -54,10 +50,6 @@ class RenaultDataEntity[T: KamereonVehicleDataAttributes]( super().__init__(vehicle.coordinators[description.coordinator]) RenaultEntity.__init__(self, vehicle, description) - def _get_data_attr(self, key: str) -> StateType: - """Return the attribute value from the coordinator data.""" - return cast(StateType, getattr(self.coordinator.data, key)) - @property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index a2f907aaf64..a11c1ad36d2 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.7"] + "requirements": ["renault-api==0.5.11"] } diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py index 4b71f77718b..b4cbc922af0 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -1,7 +1,5 @@ """Support for Renault number entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any @@ -86,6 +84,7 @@ async def _set_charge_limits( entity.coordinator.data.socMin = min_soc entity.coordinator.data.socTarget = target_soc + entity.coordinator.assumed_state = True entity.coordinator.async_set_updated_data(entity.coordinator.data) diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml index 84a7e352cbc..7a165ca5dec 100644 --- a/homeassistant/components/renault/quality_scale.yaml +++ b/homeassistant/components/renault/quality_scale.yaml @@ -52,7 +52,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: done repair-issues: done diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index cd6b43f3662..d9beb1d5301 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -1,12 +1,12 @@ """Proxy to handle account communication with Renault servers.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging +from time import time from typing import TYPE_CHECKING +from renault_api.exceptions import NotAuthenticatedException from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon.models import KamereonVehiclesLink from renault_api.renault_account import RenaultAccount @@ -24,18 +24,16 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -if TYPE_CHECKING: - from . import RenaultConfigEntry - -from time import time - from .const import ( - CONF_KAMEREON_ACCOUNT_ID, COOLING_UPDATES_SECONDS, MAX_CALLS_PER_HOURS, + RenaultConfigurationKeys, ) from .renault_vehicle import COORDINATORS, RenaultVehicleProxy +if TYPE_CHECKING: + from . import RenaultConfigEntry + LOGGER = logging.getLogger(__name__) @@ -70,6 +68,11 @@ class RenaultHub: self._got_throttled_at_time: float | None = None + @property + def login_token(self) -> str | None: + """Return the Gigya login token obtained from a successful login.""" + return self._client.session.login_token + def set_throttled(self) -> None: """We got throttled, we need to adjust the rate limit.""" if self._got_throttled_at_time is None: @@ -98,7 +101,27 @@ class RenaultHub: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" - account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] + # Reuse the stored login token, or fall back to a password login. + if login_token := config_entry.data.get(RenaultConfigurationKeys.LOGIN_TOKEN): + self._client.session.set_login_token(login_token) + elif await self.attempt_login( + config_entry.data[RenaultConfigurationKeys.USERNAME], + config_entry.data[RenaultConfigurationKeys.PASSWORD], + ): + # Persist the login token so the next setup can skip the password. + self._hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + RenaultConfigurationKeys.LOGIN_TOKEN: self.login_token, + }, + ) + else: + raise NotAuthenticatedException + + account_id: str = config_entry.data[ + RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID + ] self._account = await self._client.get_api_account(account_id) vehicle_links = await _get_filtered_vehicles(self._account) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 49b91c5cd38..ea3a6323803 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -1,7 +1,5 @@ """Proxy to handle account communication with Renault servers.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 514378411b5..c19d58f7ae9 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -1,7 +1,5 @@ """Support for Renault sensors.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index d5791e0c6af..3faa217eb34 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,7 +1,5 @@ """Support for Renault sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -88,15 +86,6 @@ class RenaultSensor[T: KamereonVehicleDataAttributes]( return self.entity_description.value_lambda(self) -def _get_charging_power( - entity: RenaultSensor[KamereonVehicleBatteryStatusData], -) -> StateType: - """Return the charging_power of this entity.""" - if (data := entity.coordinator.data.chargingInstantaneousPower) is None: - return None - return data / 1000 - - def _get_charge_state_formatted( entity: RenaultSensor[KamereonVehicleBatteryStatusData], ) -> str | None: @@ -190,9 +179,10 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.KILO_WATT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, - value_lambda=_get_charging_power, + value_lambda=lambda e: e.coordinator.data.chargingInstantaneousPower, translation_key="charging_power", ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index a8811ff231b..0a219b8accb 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -1,8 +1,7 @@ """Support for Renault services.""" -from __future__ import annotations - from datetime import datetime +from enum import StrEnum import logging from typing import TYPE_CHECKING, Any @@ -20,25 +19,30 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) -ATTR_SCHEDULES = "schedules" -ATTR_TEMPERATURE = "temperature" -ATTR_VEHICLE = "vehicle" -ATTR_WHEN = "when" + +class RenaultServiceArgument(StrEnum): + """Service argument names.""" + + SCHEDULES = "schedules" + TEMPERATURE = "temperature" + VEHICLE = "vehicle" + WHEN = "when" + SERVICE_VEHICLE_SCHEMA = vol.Schema( { - vol.Required(ATTR_VEHICLE): cv.string, + vol.Required(RenaultServiceArgument.VEHICLE.value): cv.string, } ) SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( { - vol.Required(ATTR_TEMPERATURE): cv.positive_float, - vol.Optional(ATTR_WHEN): cv.datetime, + vol.Required(RenaultServiceArgument.TEMPERATURE.value): cv.positive_float, + vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime, } ) SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( { - vol.Optional(ATTR_WHEN): cv.datetime, + vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime, } ) SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( @@ -64,7 +68,7 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema( ) SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( { - vol.Required(ATTR_SCHEDULES): vol.All( + vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All( cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA] ), } @@ -91,7 +95,7 @@ SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema( ) SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( { - vol.Required(ATTR_SCHEDULES): vol.All( + vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All( cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA] ), } @@ -109,8 +113,8 @@ async def ac_cancel(service_call: ServiceCall) -> None: async def ac_start(service_call: ServiceCall) -> None: """Start A/C.""" - temperature: float = service_call.data[ATTR_TEMPERATURE] - when: datetime | None = service_call.data.get(ATTR_WHEN) + temperature: float = service_call.data[RenaultServiceArgument.TEMPERATURE] + when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN) proxy = get_vehicle_proxy(service_call) LOGGER.debug("A/C start attempt: %s / %s", temperature, when) @@ -120,7 +124,7 @@ async def ac_start(service_call: ServiceCall) -> None: async def charge_start(service_call: ServiceCall) -> None: """Start Charging with optional delay.""" - when: datetime | None = service_call.data.get(ATTR_WHEN) + when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN) proxy = get_vehicle_proxy(service_call) LOGGER.debug("Charge start attempt, when: %s", when) @@ -130,7 +134,9 @@ async def charge_start(service_call: ServiceCall) -> None: async def charge_set_schedules(service_call: ServiceCall) -> None: """Set charge schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + schedules: list[dict[str, Any]] = service_call.data[ + RenaultServiceArgument.SCHEDULES + ] proxy = get_vehicle_proxy(service_call) charge_schedules = await proxy.get_charging_settings() for schedule in schedules: @@ -149,7 +155,9 @@ async def charge_set_schedules(service_call: ServiceCall) -> None: async def ac_set_schedules(service_call: ServiceCall) -> None: """Set A/C schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + schedules: list[dict[str, Any]] = service_call.data[ + RenaultServiceArgument.SCHEDULES + ] proxy = get_vehicle_proxy(service_call) hvac_schedules = await proxy.get_hvac_settings() @@ -170,7 +178,7 @@ async def ac_set_schedules(service_call: ServiceCall) -> None: def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: """Get vehicle from service_call data.""" device_registry = dr.async_get(service_call.hass) - device_id = service_call.data[ATTR_VEHICLE] + device_id = service_call.data[RenaultServiceArgument.VEHICLE] device_entry = device_registry.async_get(device_id) if device_entry is None: raise ServiceValidationError( diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index b88f9bb036a..1733342f180 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,18 +1,12 @@ """The Renson integration.""" -from __future__ import annotations - -from dataclasses import dataclass - from renson_endura_delta.renson import RensonVentilation -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator, RensonData PLATFORMS = [ Platform.BINARY_SENSOR, @@ -25,15 +19,7 @@ PLATFORMS = [ ] -@dataclass -class RensonData: - """Renson data class.""" - - api: RensonVentilation - coordinator: RensonCoordinator - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RensonConfigEntry) -> bool: """Set up Renson from a config entry.""" api = RensonVentilation(entry.data[CONF_HOST]) @@ -44,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RensonData( + entry.runtime_data = RensonData( api, coordinator, ) @@ -54,9 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RensonConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 60b4f54b85c..ab854dbebb7 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for renson.""" -from __future__ import annotations - from dataclasses import dataclass from renson_endura_delta.field_enum import ( @@ -21,13 +19,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -85,15 +81,13 @@ BINARY_SENSORS: tuple[RensonBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities( RensonBinarySensor(description, api, coordinator) diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 830e5a03a4a..23e7a613565 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -1,7 +1,5 @@ """Renson ventilation unit buttons.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -12,13 +10,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonCoordinator, RensonData -from .const import DOMAIN +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -53,12 +49,12 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson button platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data entities = [ RensonButton(description, data.api, data.coordinator) diff --git a/homeassistant/components/renson/config_flow.py b/homeassistant/components/renson/config_flow.py index 311317bb397..8997d6d99d3 100644 --- a/homeassistant/components/renson/config_flow.py +++ b/homeassistant/components/renson/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Renson integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py index 5d0a20e1c29..a249498aaa3 100644 --- a/homeassistant/components/renson/coordinator.py +++ b/homeassistant/components/renson/coordinator.py @@ -1,8 +1,7 @@ """DataUpdateCoordinator for the renson integration.""" -from __future__ import annotations - import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -15,18 +14,29 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +type RensonConfigEntry = ConfigEntry[RensonData] + + +@dataclass +class RensonData: + """Renson data class.""" + + api: RensonVentilation + coordinator: RensonCoordinator + + _LOGGER = logging.getLogger(__name__) class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for Renson.""" - config_entry: ConfigEntry + config_entry: RensonConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, api: RensonVentilation, ) -> None: """Initialize my coordinator.""" diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index cee991386ea..e637af2be30 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -1,7 +1,5 @@ """Entity class for Renson ventilation unit.""" -from __future__ import annotations - from renson_endura_delta.field_enum import ( DEVICE_NAME_FIELD, FIRMWARE_VERSION_FIELD, diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index c82cad012c3..954d0e54752 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -1,7 +1,5 @@ """Platform to control a Renson ventilation unit.""" -from __future__ import annotations - import logging import math from typing import Any @@ -16,7 +14,6 @@ from renson_endura_delta.renson import Level, RensonVentilation import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,8 +24,7 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -84,15 +80,13 @@ SPEED_RANGE: tuple[float, float] = (1, 4) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson fan platform.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonFan(api, coordinator)]) diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index 67fde1c56dc..fc174fcf98d 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -1,7 +1,5 @@ """Platform to control a Renson ventilation unit.""" -from __future__ import annotations - import logging from renson_endura_delta.field_enum import FILTER_PRESET_FIELD, DataType @@ -12,13 +10,11 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -39,15 +35,13 @@ RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson number platform.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonNumber(RENSON_NUMBER_DESCRIPTION, api, coordinator)]) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index ce7e71b1c0b..bcbfa897896 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -1,7 +1,5 @@ """Sensor data of the Renson ventilation unit.""" -from __future__ import annotations - from dataclasses import dataclass from renson_endura_delta.field_enum import ( @@ -34,7 +32,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -45,9 +42,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonData -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -271,12 +266,12 @@ class RensonSensor(RensonEntity, SensorEntity): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson sensor platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data entities = [ RensonSensor(description, data.api, data.coordinator) for description in SENSORS diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py index 3b73bb3dffe..2d36c1bc133 100644 --- a/homeassistant/components/renson/switch.py +++ b/homeassistant/components/renson/switch.py @@ -1,7 +1,5 @@ """Breeze switch of the Renson ventilation unit.""" -from __future__ import annotations - import logging from typing import Any @@ -9,12 +7,10 @@ from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonCoordinator -from .const import DOMAIN +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -67,14 +63,12 @@ class RensonBreezeSwitch(RensonEntity, SwitchEntity): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonBreezeSwitch(api, coordinator)]) diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py index 0a07fd2ec4f..88ae9e39555 100644 --- a/homeassistant/components/renson/time.py +++ b/homeassistant/components/renson/time.py @@ -1,7 +1,5 @@ """Renson ventilation unit time.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, time @@ -10,14 +8,11 @@ from renson_endura_delta.field_enum import DAYTIME_FIELD, NIGHTTIME_FIELD, Field from renson_endura_delta.renson import RensonVentilation from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonData -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -49,15 +44,14 @@ ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson time platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] - + coordinator = config_entry.runtime_data.coordinator entities = [ - RensonTime(description, data.coordinator) for description in ENTITY_DESCRIPTIONS + RensonTime(description, coordinator) for description in ENTITY_DESCRIPTIONS ] async_add_entities(entities) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a2ea96459b2..f58eeefc318 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -1,7 +1,5 @@ """Reolink integration for HomeAssistant.""" -from __future__ import annotations - from collections.abc import Callable from datetime import UTC, datetime, timedelta import logging @@ -9,7 +7,7 @@ from random import uniform from time import time from typing import Any -from reolink_aio.api import RETRY_ATTEMPTS +from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform @@ -26,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_BC_PORT, CONF_FIRMWARE_CHECK_TIME, @@ -76,6 +75,7 @@ async def async_setup_entry( await host.async_init() except (UserNotAdmin, CredentialsInvalidError, PasswordIncompatible) as err: await host.stop() + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed(err) from err except ( ReolinkException, @@ -103,6 +103,8 @@ async def async_setup_entry( != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) + or host.api.baichuan.connection_type.value + != config_entry.data.get(CONF_BC_CONNECT) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -127,6 +129,7 @@ async def async_setup_entry( CONF_USE_HTTPS: host.api.use_https, CONF_BC_PORT: host.api.baichuan.port, CONF_BC_ONLY: host.api.baichuan_only, + CONF_BC_CONNECT: host.api.baichuan.connection_type.value, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -165,7 +168,7 @@ async def async_setup_entry( hass.config_entries.async_update_entry(config_entry, data=data) # If camera WAN blocked, firmware check fails and takes long, do not prevent setup - now = datetime.now(UTC) + now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow check_time = timedelta(seconds=check_time_sec) delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0) firmware_check_delay = check_time - delta_midnight @@ -207,6 +210,19 @@ async def async_setup_entry( connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)}, ) + if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: + # ensure the camera device is setup before + # the lens sub-devices that use via_device + if host.api.supported(0, "UID"): + camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(0)}" + else: + camera_dev_id = f"{host.unique_id}_ch0" + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, camera_dev_id)}, + via_device=(DOMAIN, host.unique_id), + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -420,8 +436,8 @@ def migrate_entity_ids( device_reg.async_update_device(device.id, new_identifiers=new_identifiers) break - if ch is None or is_chime: - continue # Do not consider the NVR itself or chimes + if ch is None or is_chime or device_uid[1].startswith("lens"): + continue # Do not consider the NVR itself, chimes or lens sub-devices # Check for wrongfully added MAC of the NVR/Hub to the camera # Can be removed in HA 2025.12 @@ -494,7 +510,8 @@ def migrate_entity_ids( id_parts = entity.unique_id.split("_", 2) if len(id_parts) < 3: _LOGGER.warning( - "Reolink channel %s entity has unexpected unique_id format %s, with device id %s", + "Reolink channel %s entity has unexpected" + " unique_id format %s, with device id %s", ch, entity.unique_id, entity.device_id, diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e70a19c09e2..146e4dea3f7 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -1,12 +1,9 @@ """Component providing support for Reolink binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from reolink_aio.api import ( - DUAL_LENS_DUAL_MOTION_MODELS, FACE_DETECTION_TYPE, PACKAGE_DETECTION_TYPE, PERSON_DETECTION_TYPE, @@ -73,6 +70,7 @@ BINARY_PUSH_SENSORS = ( key="motion", cmd_id=33, device_class=BinarySensorDeviceClass.MOTION, + lens_entity=True, value=lambda api, ch: api.motion_detected(ch), supported=lambda api, ch: api.supported(ch, "motion_detection"), ), @@ -80,6 +78,7 @@ BINARY_PUSH_SENSORS = ( key=FACE_DETECTION_TYPE, cmd_id=33, translation_key="face", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE), ), @@ -87,6 +86,7 @@ BINARY_PUSH_SENSORS = ( key=PERSON_DETECTION_TYPE, cmd_id=[33, 600, 696], translation_key="person", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), ), @@ -94,6 +94,7 @@ BINARY_PUSH_SENSORS = ( key=VEHICLE_DETECTION_TYPE, cmd_id=[33, 600, 696], translation_key="vehicle", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE), ), @@ -101,6 +102,7 @@ BINARY_PUSH_SENSORS = ( key="non-motor_vehicle", cmd_id=[600, 696], translation_key="non-motor_vehicle", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"), supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"), ), @@ -108,6 +110,7 @@ BINARY_PUSH_SENSORS = ( key=PET_DETECTION_TYPE, cmd_id=[33, 600, 696], translation_key="pet", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: ( api.ai_supported(ch, PET_DETECTION_TYPE) @@ -118,6 +121,7 @@ BINARY_PUSH_SENSORS = ( key=PET_DETECTION_TYPE, cmd_id=[33, 600, 696], translation_key="animal", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: api.supported(ch, "ai_animal"), ), @@ -125,6 +129,7 @@ BINARY_PUSH_SENSORS = ( key=PACKAGE_DETECTION_TYPE, cmd_id=[33, 600, 696], translation_key="package", + lens_entity=True, value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE), ), @@ -357,13 +362,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt self.entity_description = entity_description super().__init__(reolink_data, channel) - if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: - if entity_description.translation_key is not None: - key = entity_description.translation_key - else: - key = entity_description.key - self._attr_translation_key = f"{key}_lens_{self._channel}" - @property def is_on(self) -> bool: """State of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index a901b8311aa..c9a9529cc69 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -1,7 +1,5 @@ """Component providing support for Reolink button entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 44386434cad..b61e02f393f 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,9 @@ """Component providing support for Reolink IP cameras.""" -from __future__ import annotations - from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_SINGLE_MOTION_MODELS from homeassistant.components.camera import ( Camera, @@ -30,6 +28,8 @@ class ReolinkCameraEntityDescription( """A class that describes camera entities for a camera channel.""" stream: str + # a camera stream always comes from a single lens + lens_entity: bool = True CAMERA_ENTITIES = ( @@ -140,7 +140,7 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): if "snapshots" not in entity_description.stream: self._attr_supported_features = CameraEntityFeature.STREAM - if self._host.api.model in DUAL_LENS_MODELS: + if self._host.api.model in DUAL_LENS_SINGLE_MOTION_MODELS: self._attr_translation_key = ( f"{entity_description.translation_key}_lens_{self._channel}" ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 80d403c6e38..357b255eb91 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Reolink camera component.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -39,6 +37,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, @@ -312,6 +311,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_USE_HTTPS] = host.api.use_https user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_BC_ONLY] = host.api.baichuan_only + user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 458d5e6bcd8..f76d9c4ef18 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -7,6 +7,7 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" CONF_BC_ONLY = "baichuan_only" +CONF_BC_CONNECT = "baichuan_connection" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" diff --git a/homeassistant/components/reolink/coordinator.py b/homeassistant/components/reolink/coordinator.py index 094039d57a3..ded3f639247 100644 --- a/homeassistant/components/reolink/coordinator.py +++ b/homeassistant/components/reolink/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinators for Reolink.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -90,12 +88,15 @@ class ReolinkDeviceCoordinator(ReolinkCoordinator): self._host.credential_errors += 1 if self._host.credential_errors >= NUM_CRED_ERRORS: await self._host.stop() + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed(err) from err + # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed(str(err)) from err except LoginPrivacyModeError: pass # HTTP API is shutdown when privacy mode is active except ReolinkError as err: self._host.credential_errors = 0 + # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed(str(err)) from err self._host.credential_errors = 0 @@ -122,7 +123,7 @@ class ReolinkDeviceCoordinator(ReolinkCoordinator): if ( self._host.api.new_devices - and self.config_entry.state == ConfigEntryState.LOADED + and self.config_entry.state is ConfigEntryState.LOADED ): # There are new cameras/chimes connected, reload to add them. _LOGGER.debug( @@ -152,7 +153,7 @@ class ReolinkFirmwareCoordinator(ReolinkCoordinator): host, f"reolink.{host.api.nvr_name}.firmware", min_timeout=min_timeout, - update_interval=None, # Do not fetch data automatically, resume 24h schedule + update_interval=None, # Do not auto-fetch, resume 24h ) async def _async_update_data(self) -> None: @@ -169,8 +170,10 @@ class ReolinkFirmwareCoordinator(ReolinkCoordinator): ) return + # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( - f"Error checking Reolink firmware update from {self._host.api.nvr_name}, " + "Error checking Reolink firmware update" + f" from {self._host.api.nvr_name}, " "if the camera is blocked from accessing the internet, " "disable the update entity" ) from err diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 912427fa881..84d7bb59a47 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Reolink.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant @@ -17,15 +15,15 @@ async def async_get_config_entry_diagnostics( host = reolink_data.host api = host.api - IPC_cam: dict[int, dict[str, Any]] = {} + ipc_cam: dict[int, dict[str, Any]] = {} for ch in api.channels: - IPC_cam[ch] = {} - IPC_cam[ch]["model"] = api.camera_model(ch) - IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) - IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) - IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + ipc_cam[ch] = {} + ipc_cam[ch]["model"] = api.camera_model(ch) + ipc_cam[ch]["hardware version"] = api.camera_hardware_version(ch) + ipc_cam[ch]["firmware version"] = api.camera_sw_version(ch) + ipc_cam[ch]["encoding main"] = await api.get_encoding(ch) if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch): - IPC_cam[ch]["WiFi signal"] = signal + ipc_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} for chime in api.chime_list: @@ -43,6 +41,7 @@ async def async_get_config_entry_diagnostics( "HTTP(S) port": api.port, "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, + "Baichuan connection": api.baichuan.connection_type.value, "WiFi connection": api.wifi_connection(), "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, @@ -50,10 +49,15 @@ async def async_get_config_entry_diagnostics( "ONVIF enabled": api.onvif_enabled, "event connection": host.event_connection, "stream protocol": api.protocol, + "is NVR": api.is_nvr, + "is Hub": api.is_hub, + "is Battery": api.is_battery, "channels": api.channels, "stream channels": api.stream_channels, - "IPC cams": IPC_cam, + "IPC cams": ipc_cam, "Chimes": chimes, + "Broken cmds": api.broken_cmds, + "Baichuan fallbacks": api.baichuan_cmds, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 6cdef5e4c32..acc6214be15 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,9 @@ """Reolink parent entity class.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host +from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, DUAL_LENS_MODELS, Chime, Host from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -24,6 +22,9 @@ class ReolinkEntityDescription(EntityDescription): cmd_key: str | None = None cmd_id: int | list[int] | None = None always_available: bool = False + # Whether the entity measures a property of a single lens + # of a dual lens camera, instead of the camera as a whole + lens_entity: bool = False @dataclass(frozen=True, kw_only=True) @@ -50,7 +51,8 @@ class ReolinkChimeEntityDescription(ReolinkEntityDescription): class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]): """Parent class for entities that control the Reolink NVR itself, without a channel. - A camera connected directly to HomeAssistant without using a NVR is in the reolink API + A camera connected directly to HomeAssistant without using + a NVR is in the reolink API basically a NVR with a single channel that has the camera connected to that channel. """ @@ -97,6 +99,13 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]): if self.entity_description.always_available: return True + if self._host.api.is_battery: + return ( + self._host.api.baichuan.login_sucess + and not self._host.api.baichuan.privacy_mode() + and super().available + ) + return ( self._host.api.session_active and not self._host.api.baichuan.privacy_mode() @@ -153,7 +162,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]): class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): - """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + """Parent class for Reolink camera entities connected to a NVR channel.""" def __init__( self, @@ -161,12 +170,16 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): channel: int, coordinator: ReolinkCoordinator | None = None, ) -> None: - """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + """Initialize ReolinkChannelCoordinatorEntity.""" super().__init__(reolink_data, coordinator) self._channel = channel if self._host.api.is_nvr and self._host.api.supported(channel, "UID"): - self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" + self._attr_unique_id = ( + f"{self._host.unique_id}" + f"_{self._host.api.camera_uid(channel)}" + f"_{self.entity_description.key}" + ) else: self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{self.entity_description.key}" @@ -207,6 +220,23 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): configuration_url=conf_url, ) + if ( + self.entity_description.lens_entity + and self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS + ): + # Dual lens cameras with separate sensors per lens + # use a sub-device per lens + parent_dev_id = self._dev_id + self._dev_id = f"{self._host.unique_id}_lens{channel}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._dev_id)}, + via_device=(DOMAIN, parent_dev_id), + name=f"{self._host.api.camera_name(dev_ch)} lens {channel}", + model=self._host.api.camera_model(channel), + manufacturer=self._host.api.manufacturer, + configuration_url=self._conf_url, + ) + @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 7b7cc48c1dd..42f6b21c496 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -1,7 +1,5 @@ """Module which encapsulates the NVR/camera API and subscription.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Mapping @@ -13,7 +11,7 @@ import aiohttp from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host from reolink_aio.baichuan import DEFAULT_BC_PORT -from reolink_aio.enums import SubType +from reolink_aio.enums import ConnectionEnum, SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook @@ -38,6 +36,7 @@ from .const import ( BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, BATTERY_WAKE_UPDATE_INTERVAL, + CONF_BC_CONNECT, CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, @@ -79,6 +78,12 @@ class ReolinkHost: self._config_entry = config_entry self._config = config self._unique_id: str = "" + try: + bc_connection = ConnectionEnum( + config.get(CONF_BC_CONNECT, ConnectionEnum.unknown.value) + ) + except ValueError: + bc_connection = ConnectionEnum.unknown def get_aiohttp_session() -> aiohttp.ClientSession: """Return the HA aiohttp session.""" @@ -98,6 +103,7 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), + bc_connection=bc_connection, bc_only=config.get(CONF_BC_ONLY, False), ) @@ -173,6 +179,7 @@ class ReolinkHost: translation_placeholders={"name": self._config_entry.title}, ) + # pylint: disable-next=home-assistant-exception-not-translated raise PasswordIncompatible( "Reolink password contains incompatible special character or " "is too long, please change the password to only contain characters: " @@ -194,9 +201,11 @@ class ReolinkHost: await self._api.get_host_data() if self._api.mac_address is None: + # pylint: disable-next=home-assistant-exception-not-translated raise ReolinkSetupException("Could not get mac address") if not self._api.is_admin: + # pylint: disable-next=home-assistant-exception-not-translated raise UserNotAdmin( f"User '{self._api.username}' has authorization level " f"'{self._api.user_level}', only admin users can change camera settings" @@ -362,14 +371,16 @@ class ReolinkHost: # start long polling if ONVIF push failed immediately if not self._onvif_push_supported and not self._api.baichuan.privacy_mode(): _LOGGER.debug( - "Camera model %s does not support ONVIF push, using ONVIF long polling instead", + "Camera model %s does not support ONVIF push," + " using ONVIF long polling instead", self._api.model, ) try: await self._async_start_long_polling(initial=True) except NotSupportedError: _LOGGER.debug( - "Camera model %s does not support ONVIF long polling, using fast polling instead", + "Camera model %s does not support ONVIF long" + " polling, using fast polling instead", self._api.model, ) self._onvif_long_poll_supported = False @@ -490,14 +501,18 @@ class ReolinkHost: or (now - self.last_wake[channel] > BATTERY_WAKE_UPDATE_INTERVAL) or (now - self.last_all_wake > BATTERY_ALL_WAKE_UPDATE_INTERVAL) ): - # let a waking update coincide with the camera waking up by itself unless it did not wake for BATTERY_WAKE_UPDATE_INTERVAL + # let a waking update coincide with the camera + # waking up by itself unless it did not wake + # for BATTERY_WAKE_UPDATE_INTERVAL wake[channel] = True self.last_wake[channel] = now else: wake[channel] = False # check privacy mode if enabled - if self._api.baichuan.privacy_mode(channel): + if self._api.baichuan.privacy_mode(channel) and ( + not self._api.is_battery or wake[channel] + ): await self._api.baichuan.get_privacy_mode(channel) if all(wake.values()): @@ -677,7 +692,7 @@ class ReolinkHost: self._api.host, sub_type, ) - if sub_type == SubType.push: + if sub_type is SubType.push: await self.subscribe() return @@ -737,6 +752,7 @@ class ReolinkHost: self._base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: self.unregister_webhook() + # pylint: disable-next=home-assistant-exception-not-translated raise ReolinkWebhookException( f"Error registering URL for webhook {event_id}: " "HomeAssistant URL is not available" @@ -832,7 +848,7 @@ class ReolinkHost: async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> None: - """Read the incoming webhook from Reolink for inbound messages and schedule processing.""" + """Read the incoming webhook from Reolink and schedule processing.""" _LOGGER.debug("Webhook '%s' called", webhook_id) data: bytes | None = None try: @@ -863,7 +879,8 @@ class ReolinkHost: raise finally: # We want handle_webhook to return as soon as possible - # so we process the data in the background, this also shields from cancellation + # so we process the data in the background, + # this also shields from cancellation hass.async_create_background_task( self._process_webhook_data(hass, webhook_id, data), "Process Reolink webhook", @@ -883,7 +900,8 @@ class ReolinkHost: if not data: if not await self._api.get_motion_state_all_ch(): _LOGGER.error( - "Could not poll motion state after losing connection during receiving ONVIF event" + "Could not poll motion state after losing" + " connection during receiving ONVIF event" ) return self._signal_write_ha_state() diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 9b83af8b0ea..8e187c2edd7 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -1,7 +1,5 @@ """Component providing support for Reolink light entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 75976ff4ec5..10c48451acf 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.19.1"] + "requirements": ["reolink-aio==0.20.1"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index f716340e06e..4d414675438 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -1,7 +1,5 @@ """Expose Reolink IP camera VODs as media sources.""" -from __future__ import annotations - import datetime as dt import logging @@ -65,6 +63,7 @@ class ReolinkVODMediaSource(MediaSource): if item.identifier is not None: identifier = item.identifier.split("|", 6) if identifier[0] != "FILE": + # pylint: disable-next=home-assistant-exception-not-translated raise Unresolvable(f"Unknown media item '{item.identifier}'.") _, config_entry_id, channel_str, stream_res, filename, start_time, end_time = ( @@ -85,7 +84,7 @@ class ReolinkVODMediaSource(MediaSource): vod_type = get_vod_type() - if vod_type == VodRequestType.NVR_DOWNLOAD: + if vod_type is VodRequestType.NVR_DOWNLOAD: filename = f"{start_time}_{end_time}" if vod_type in { @@ -174,6 +173,7 @@ class ReolinkVODMediaSource(MediaSource): event, ) + # pylint: disable-next=home-assistant-exception-not-translated raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") async def _async_generate_root(self) -> BrowseMediaSource: @@ -207,7 +207,8 @@ class ReolinkVODMediaSource(MediaSource): ch = host.api.channel_for_uid(ch_id) if not host.api.supported(int(ch), "replay") or not host.api.hdd_info: - # playback stream not supported by this camera or no storage installed + # playback stream not supported by this + # camera or no storage installed continue device_name = device.name @@ -244,7 +245,7 @@ class ReolinkVODMediaSource(MediaSource): async def _async_generate_resolution_select( self, config_entry_id: str, channel: int ) -> BrowseMediaSource: - """Allow the user to select the high or low playback resolution, (low loads faster).""" + """Allow the user to select the high or low playback resolution.""" host = get_host(self.hass, config_entry_id) main_enc = await host.api.get_encoding(channel, "main") @@ -441,7 +442,11 @@ class ReolinkVODMediaSource(MediaSource): f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}" ) if host.api.model in DUAL_LENS_MODELS: - title = f"{host.api.camera_name(channel)} lens {channel} {res_name(stream)} {year}/{month}/{day}" + title = ( + f"{host.api.camera_name(channel)} lens" + f" {channel} {res_name(stream)}" + f" {year}/{month}/{day}" + ) if event: title = f"{title} {event.title()}" diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index c53e855d720..a5b99ece4c9 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -1,7 +1,5 @@ """Component providing support for Reolink number entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index ba42e7c069f..1e84689672c 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -1,7 +1,5 @@ """Component providing support for Reolink select entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 0fb81035352..88d1cdbdd09 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -1,7 +1,5 @@ """Component providing support for Reolink sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index d5786261d1f..5867fb41829 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -1,7 +1,5 @@ """Reolink additional services.""" -from __future__ import annotations - from reolink_aio.api import Chime from reolink_aio.enums import ChimeToneEnum import voluptuous as vol @@ -45,7 +43,7 @@ async def _async_play_chime(service_call: ServiceCall) -> None: if ( config_entry is None or device is None - or config_entry.state != ConfigEntryState.LOADED + or config_entry.state is not ConfigEntryState.LOADED ): raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index cfd1f5f82f0..3d5481d194d 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,7 +1,5 @@ """Component providing support for Reolink siren entities.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 979154776a7..8731bfcdcf0 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -52,20 +52,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "animal_lens_0": { - "name": "Animal lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "animal_lens_1": { - "name": "Animal lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "crossline_dog_cat": { "name": "Crossline {zone_name} animal", "state": { @@ -101,20 +87,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "face_lens_0": { - "name": "Face lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "face_lens_1": { - "name": "Face lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "forgotten_item": { "name": "Item forgotten {zone_name}", "state": { @@ -171,20 +143,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "motion_lens_0": { - "name": "Motion lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "motion_lens_1": { - "name": "Motion lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "non-motor_vehicle": { "name": "Bicycle", "state": { @@ -199,20 +157,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "package_lens_0": { - "name": "Package lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "package_lens_1": { - "name": "Package lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "person": { "name": "Person", "state": { @@ -220,20 +164,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "person_lens_0": { - "name": "Person lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "person_lens_1": { - "name": "Person lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "pet": { "name": "Pet", "state": { @@ -241,20 +171,6 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "pet_lens_0": { - "name": "Pet lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "pet_lens_1": { - "name": "Pet lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "sleep": { "name": "Sleep status", "state": { @@ -276,28 +192,8 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, - "vehicle_lens_0": { - "name": "Vehicle lens 0", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, - "vehicle_lens_1": { - "name": "Vehicle lens 1", - "state": { - "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" - } - }, "visitor": { "name": "Visitor" - }, - "visitor_lens_0": { - "name": "Visitor lens 0" - }, - "visitor_lens_1": { - "name": "Visitor lens 1" } }, "button": { diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index d776bfcc203..989eca210c6 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -1,7 +1,5 @@ """Component providing support for Reolink switch entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 7dfdd56f771..3666da08d74 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,7 +1,5 @@ """Update entities for Reolink devices.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -214,12 +212,12 @@ class ReolinkUpdateBaseEntity(CoordinatorEntity[ReolinkCoordinator], UpdateEntit self._installing = False async def _pause_update_coordinator(self) -> None: - """Pause updating the states using the data update coordinator (during reboots).""" + """Pause updating states using the data update coordinator.""" self._reolink_data.device_coordinator.update_interval = None self._reolink_data.device_coordinator.async_set_updated_data(None) async def _resume_update_coordinator(self, *args: Any) -> None: - """Resume updating the states using the data update coordinator (after reboots).""" + """Resume updating states using the data update coordinator.""" self._reolink_data.device_coordinator.update_interval = max( DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM * self._host.api.num_cameras, diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e633cbac64f..ecef195ebc2 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -1,7 +1,5 @@ """Utility functions for the Reolink component.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -53,7 +51,7 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) """Check if an existing entry has a proper connection.""" return ( hasattr(config_entry, "runtime_data") - and config_entry.state == config_entries.ConfigEntryState.LOADED + and config_entry.state is config_entries.ConfigEntryState.LOADED and config_entry.runtime_data.device_coordinator.last_update_success ) @@ -64,6 +62,7 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: config_entry_id ) if config_entry is None: + # pylint: disable-next=home-assistant-exception-not-translated raise Unresolvable( f"Could not find Reolink config entry id '{config_entry_id}'." ) @@ -101,6 +100,8 @@ def get_device_uid_and_ch( elif device_uid[1].startswith("chime"): ch = int(device_uid[1][5:]) is_chime = True + elif device_uid[1].startswith("lens"): + ch = int(device_uid[1][4:]) else: device_uid_part = "_".join(device_uid[1:]) ch = host.api.channel_for_uid(device_uid_part) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 3a160ce3f8a..8aa3ba831ee 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -1,7 +1,5 @@ """Reolink Integration views.""" -from __future__ import annotations - from base64 import urlsafe_b64decode, urlsafe_b64encode from http import HTTPStatus import logging @@ -41,7 +39,11 @@ class PlaybackProxyView(HomeAssistantView): """View to proxy playback video from Reolink.""" requires_auth = True - url = "/api/reolink/video/{config_entry_id}/{channel}/{stream_res}/{vod_type}/{filename}" + url = ( + "/api/reolink/video" + "/{config_entry_id}/{channel}/{stream_res}" + "/{vod_type}/{filename}" + ) name = "api:reolink_playback" def __init__(self, hass: HomeAssistant) -> None: @@ -74,7 +76,10 @@ class PlaybackProxyView(HomeAssistantView): try: host = get_host(self.hass, config_entry_id) except Unresolvable: - err_str = f"Reolink playback proxy could not find config entry id: {config_entry_id}" + err_str = ( + "Reolink playback proxy could not find" + f" config entry id: {config_entry_id}" + ) _LOGGER.warning(err_str) return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) @@ -129,7 +134,10 @@ class PlaybackProxyView(HomeAssistantView): "application/octet-stream", "apolication/octet-stream", ]: - err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" + err_str = ( + "Reolink playback expected video/mp4" + f" but got {reolink_response.content_type}" + ) if ( reolink_response.content_type == "video/x-flv" and vod_type == VodRequestType.PLAYBACK.value diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8ee09c9ed3d..99bd11597f8 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -1,7 +1,5 @@ """The repairs integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -9,13 +7,14 @@ from homeassistant.helpers.typing import ConfigType from . import issue_handler, websocket_api from .const import DOMAIN from .issue_handler import ConfirmRepairFlow, RepairsFlowManager -from .models import RepairsFlow +from .models import RepairsFlow, RepairsFlowResult __all__ = [ "DOMAIN", "ConfirmRepairFlow", "RepairsFlow", "RepairsFlowManager", + "RepairsFlowResult", "repairs_flow_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 63da15b1ede..11f51ba7af3 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -1,7 +1,5 @@ """The repairs integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -15,7 +13,7 @@ from homeassistant.helpers.integration_platform import ( ) from .const import DOMAIN -from .models import RepairsFlow, RepairsProtocol +from .models import RepairsFlow, RepairsFlowResult, RepairsProtocol class ConfirmRepairFlow(RepairsFlow): @@ -23,13 +21,13 @@ class ConfirmRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: return self.async_create_entry(data={}) @@ -46,7 +44,9 @@ class ConfirmRepairFlow(RepairsFlow): ) -class RepairsFlowManager(data_entry_flow.FlowManager): +class RepairsFlowManager( + data_entry_flow.FlowManager[data_entry_flow.FlowContext, RepairsFlowResult, str] +): """Manage repairs flows.""" async def async_create_flow( @@ -80,14 +80,18 @@ class RepairsFlowManager(data_entry_flow.FlowManager): return flow async def async_finish_flow( - self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult - ) -> data_entry_flow.FlowResult: + self, + flow: data_entry_flow.FlowHandler[ + data_entry_flow.FlowContext, RepairsFlowResult, str + ], + result: RepairsFlowResult, + ) -> RepairsFlowResult: """Complete a fix flow. This method is called when a flow step returns FlowResultType.ABORT or FlowResultType.CREATE_ENTRY. """ - if result.get("type") != data_entry_flow.FlowResultType.ABORT: + if result.get("type") is not data_entry_flow.FlowResultType.ABORT: ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) return result diff --git a/homeassistant/components/repairs/models.py b/homeassistant/components/repairs/models.py index afac8813d1e..bdaee67fe1f 100644 --- a/homeassistant/components/repairs/models.py +++ b/homeassistant/components/repairs/models.py @@ -1,14 +1,17 @@ """Models for Repairs.""" -from __future__ import annotations - from typing import Protocol from homeassistant import data_entry_flow from homeassistant.core import HomeAssistant +# Placeholder TypeAlias for future TypedDict to handle next_flow. +RepairsFlowResult = data_entry_flow.FlowResult[data_entry_flow.FlowContext, str] -class RepairsFlow(data_entry_flow.FlowHandler): + +class RepairsFlow( + data_entry_flow.FlowHandler[data_entry_flow.FlowContext, RepairsFlowResult, str] +): """Handle a flow for fixing an issue.""" issue_id: str diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index d09c567bb71..ca9812552d8 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -1,7 +1,5 @@ """The repairs websocket API.""" -from __future__ import annotations - from http import HTTPStatus from typing import Any diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 16c92d6cd37..875b724e1eb 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -1,7 +1,5 @@ """Support for Repetier-Server sensors.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -133,7 +131,7 @@ class RepetierRequiredKeysMixin: @dataclass(frozen=True) -# pylint: disable-next=hass-enforce-class-module +# pylint: disable-next=home-assistant-enforce-class-module class RepetierSensorEntityDescription( SensorEntityDescription, RepetierRequiredKeysMixin ): diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 4cfa0799960..2ed0108ff3c 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring Repetier Server Sensors.""" -from __future__ import annotations - import logging import time diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 30d659c82c4..af64d496c6c 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -1,7 +1,5 @@ """The rest component.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine import contextlib @@ -77,6 +75,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service: ServiceCall) -> None: """Remove all user-defined groups and load new ones from config.""" conf = None + # pylint: disable-next=home-assistant-action-swallowed-exception with contextlib.suppress(HomeAssistantError): conf = await async_integration_yaml_config(hass, DOMAIN) if conf is None: diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 2e73f1b1b82..e6d5b7fa3e2 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,7 +1,5 @@ """Support for RESTful binary sensors.""" -from __future__ import annotations - import logging import ssl from xml.parsers.expat import ExpatError diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 2964ef73d46..b3f35d8802e 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -1,7 +1,5 @@ """Support for RESTful API.""" -from __future__ import annotations - import logging from typing import Any @@ -111,15 +109,19 @@ class RestData: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - # Convert boolean values to lowercase strings for compatibility with aiohttp/yarl + # Convert boolean values to lowercase strings for + # compatibility with aiohttp/yarl if rendered_params: for key, value in rendered_params.items(): if isinstance(value, bool): rendered_params[key] = str(value).lower() elif not isinstance(value, (str, int, float, type(None))): - # For backward compatibility with httpx behavior, convert non-primitive - # types to strings. This maintains compatibility after switching from - # httpx to aiohttp. See https://github.com/home-assistant/core/issues/148153 + # For backward compatibility with httpx + # behavior, convert non-primitive types to + # strings. This maintains compatibility + # after switching from httpx to aiohttp. + # See + # https://github.com/home-assistant/core/issues/148153 _LOGGER.debug( "REST query parameter '%s' has type %s, converting to string", key, @@ -160,7 +162,9 @@ class RestData: except UnicodeDecodeError as ex: self._force_use_set_encoding = True _LOGGER.debug( - "Response charset came back as %s but could not be decoded, continue with configured encoding %s. %s", + "Response charset came back as %s but" + " could not be decoded, continue with" + " configured encoding %s. %s", response.charset, self._encoding, ex, diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index 3695c899371..35c4ac39211 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -1,7 +1,5 @@ """The base entity for the rest component.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index bd94e07636b..a9a2b5ac9df 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rest", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["jsonpath==0.82.2", "xmltodict==1.0.2"] + "requirements": ["jsonpath==0.82.2", "xmltodict==1.0.4"] } diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index ace216e1918..d35c4020b21 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -1,7 +1,5 @@ """RESTful platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 3db44b0e5d2..c09f877eddc 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,7 +1,5 @@ """Support for RESTful API sensors.""" -from __future__ import annotations - import logging import ssl from typing import Any diff --git a/homeassistant/components/rest/strings.json b/homeassistant/components/rest/strings.json index 28d66cbd336..69b10cde15e 100644 --- a/homeassistant/components/rest/strings.json +++ b/homeassistant/components/rest/strings.json @@ -1,4 +1,15 @@ { + "exceptions": { + "error_communicating": { + "message": "Error communicating with {resource}" + }, + "turn_off_failed": { + "message": "Failed to turn off {resource}" + }, + "turn_on_failed": { + "message": "Failed to turn on {resource}" + } + }, "services": { "reload": { "description": "Reloads REST entities from the YAML-configuration.", diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index d5d41f8b0a0..8d9c9927f7a 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -1,7 +1,5 @@ """Support for RESTful switches.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any @@ -29,7 +27,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client @@ -42,6 +40,8 @@ from homeassistant.helpers.trigger_template_entity import ( ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) CONF_BODY_OFF = "body_off" CONF_BODY_ON = "body_on" @@ -165,15 +165,21 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) + except (TimeoutError, httpx.RequestError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_communicating", + translation_placeholders={"resource": self._resource}, + ) from err - if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: - self._attr_is_on = True - else: - _LOGGER.error( - "Can't turn on %s. Is resource/endpoint offline?", self._resource - ) - except TimeoutError, httpx.RequestError: - _LOGGER.error("Error while switching on %s", self._resource) + if not HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="turn_on_failed", + translation_placeholders={"resource": self._resource}, + ) + + self._attr_is_on = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -181,14 +187,21 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: - self._attr_is_on = False - else: - _LOGGER.error( - "Can't turn off %s. Is resource/endpoint offline?", self._resource - ) - except TimeoutError, httpx.RequestError: - _LOGGER.error("Error while switching off %s", self._resource) + except (TimeoutError, httpx.RequestError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_communicating", + translation_placeholders={"resource": self._resource}, + ) from err + + if not HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="turn_off_failed", + translation_placeholders={"resource": self._resource}, + ) + + self._attr_is_on = False async def set_device_state(self, body: Any) -> httpx.Response: """Send a state update to the device.""" @@ -213,9 +226,17 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.get_response(self.hass) except TimeoutError, httpx.TimeoutException: - _LOGGER.exception("Timed out while fetching data") + _LOGGER.exception( + "Timed out while fetching data for %s from %s", + self.entity_id, + self._state_resource, + ) except httpx.RequestError: - _LOGGER.exception("Error while fetching data") + _LOGGER.exception( + "Error fetching data for %s from %s", + self.entity_id, + self._state_resource, + ) if req: self._async_update(req.text) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index bf51fc2692d..92846c85f09 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,7 +1,5 @@ """Support for exposing regular REST commands as services.""" -from __future__ import annotations - from http import HTTPStatus from json.decoder import JSONDecodeError import logging @@ -202,8 +200,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not service.return_response: - # always read the response to avoid closing the connection - # before the server has finished sending it, while avoiding excessive memory usage + # always read the response to avoid closing + # the connection before the server has + # finished sending it, while avoiding + # excessive memory usage async for _ in response.content.iter_chunked(1024): pass @@ -237,7 +237,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return { "content": _content, "status": response.status, - "headers": dict(response.headers), + "headers": { + key: values[0] if len(values) == 1 else values + for key in response.headers + if (values := response.headers.getall(key)) + }, } except TimeoutError as err: diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index d83a242ac71..03e8889f981 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -1,7 +1,5 @@ """Support for Rflink devices.""" -from __future__ import annotations - import asyncio from collections import defaultdict import logging @@ -17,6 +15,7 @@ from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED, + Platform, ) from homeassistant.core import ( CoreState, @@ -27,6 +26,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -34,10 +34,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType +from .binary_sensor import RFLINK_PLATFORM as BINARY_SENSOR_PLATFORM from .const import ( DATA_DEVICE_REGISTER, DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DOMAIN, EVENT_KEY_COMMAND, EVENT_KEY_ID, EVENT_KEY_SENSOR, @@ -45,7 +47,11 @@ from .const import ( SIGNAL_HANDLE_EVENT, TMP_ENTITY, ) +from .cover import RFLINK_PLATFORM as COVER_PLATFORM from .entity import RflinkCommand +from .light import RFLINK_PLATFORM as LIGHT_PLATFORM +from .sensor import RFLINK_PLATFORM as SENSOR_PLATFORM +from .switch import RFLINK_PLATFORM as SWITCH_PLATFORM from .utils import identify_event_type _LOGGER = logging.getLogger(__name__) @@ -62,12 +68,34 @@ CONNECTION_TIMEOUT = 10 RFLINK_GROUP_COMMANDS = ["allon", "alloff"] -DOMAIN = "rflink" - SERVICE_SEND_COMMAND = "send_command" SIGNAL_EVENT = "rflink_event" +BINARY_SENSOR_PS = vol.Schema( + BINARY_SENSOR_PLATFORM, + extra=vol.ALLOW_EXTRA, +) + +COVER_PS = vol.Schema( + COVER_PLATFORM, + extra=vol.ALLOW_EXTRA, +) + +LIGHT_PS = vol.Schema( + LIGHT_PLATFORM, + extra=vol.ALLOW_EXTRA, +) + +SENSOR_PS = vol.Schema( + SENSOR_PLATFORM, + extra=vol.ALLOW_EXTRA, +) + +SWITCH_PS = vol.Schema( + SWITCH_PLATFORM, + extra=vol.ALLOW_EXTRA, +) CONFIG_SCHEMA = vol.Schema( { @@ -85,6 +113,11 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_IGNORE_DEVICES, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(Platform.BINARY_SENSOR.value): BINARY_SENSOR_PS, + vol.Optional(Platform.COVER.value): COVER_PS, + vol.Optional(Platform.LIGHT.value): LIGHT_PS, + vol.Optional(Platform.SENSOR.value): SENSOR_PS, + vol.Optional(Platform.SWITCH.value): SWITCH_PS, } ) }, @@ -95,6 +128,14 @@ SEND_COMMAND_SCHEMA = vol.Schema( {vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(CONF_COMMAND): cv.string} ) +ALLOWED_PLATFORMS = [ + Platform.BINARY_SENSOR.value, + Platform.COVER.value, + Platform.LIGHT.value, + Platform.SENSOR.value, + Platform.SWITCH.value, +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Rflink component.""" @@ -299,4 +340,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Listen to EVENT_LOGGING_CHANGED to manage the RFDEBUG hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + # Load RFLink platforms definitions + for pltfrm in ALLOWED_PLATFORMS: + if pltfrm in config[DOMAIN]: + _LOGGER.debug("Loading Rflink platform '%s'", pltfrm) + hass.async_create_task( + async_load_platform( + hass, pltfrm, DOMAIN, config[DOMAIN][pltfrm], config + ), + eager_start=False, + ) + return True diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 713dc02d6b8..f9618a5eaa0 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -1,13 +1,12 @@ """Support for Rflink binary sensors.""" -from __future__ import annotations - from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, + DOMAIN as PLATFORM_DOMAIN, PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, @@ -27,28 +26,31 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ALIASES from .entity import RflinkDevice +from .utils import create_issue_yaml_migration CONF_OFF_DELAY = "off_delay" DEFAULT_FORCE_UPDATE = False +RFLINK_PLATFORM = { + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional( + CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE + ): cv.boolean, + vol.Optional(CONF_OFF_DELAY): cv.positive_int, + vol.Optional(CONF_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + } +} + PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICES, default={}): { - cv.string: vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional( - CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE - ): cv.boolean, - vol.Optional(CONF_OFF_DELAY): cv.positive_int, - vol.Optional(CONF_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ) - } - }, + RFLINK_PLATFORM, extra=vol.ALLOW_EXTRA, ) @@ -70,7 +72,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Rflink platform.""" - async_add_entities(devices_from_config(config)) + if discovery_info is None: + create_issue_yaml_migration(hass, PLATFORM_DOMAIN) + async_add_entities(devices_from_config(config)) + else: + async_add_entities(devices_from_config(discovery_info)) class RflinkBinarySensor(RflinkDevice, BinarySensorEntity, RestoreEntity): diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index 83eb2915f70..279a4a7c859 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -1,7 +1,5 @@ """Support for Rflink devices.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.helpers import config_validation as cv @@ -20,6 +18,8 @@ DATA_ENTITY_GROUP_LOOKUP = "rflink_entity_group_only_lookup" DATA_ENTITY_LOOKUP = "rflink_entity_lookup" DEFAULT_SIGNAL_REPETITIONS = 1 +DOMAIN = "rflink" + EVENT_KEY_COMMAND = "command" EVENT_KEY_ID = "id" EVENT_KEY_SENSOR = "sensor" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 8b21bc9274d..09d801166b3 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -1,13 +1,12 @@ """Support for Rflink Cover devices.""" -from __future__ import annotations - import logging from typing import Any import voluptuous as vol from homeassistant.components.cover import ( + DOMAIN as PLATFORM_DOMAIN, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, CoverState, @@ -30,6 +29,7 @@ from .const import ( DEVICE_DEFAULTS_SCHEMA, ) from .entity import RflinkCommand +from .utils import create_issue_yaml_migration _LOGGER = logging.getLogger(__name__) @@ -38,32 +38,35 @@ PARALLEL_UPDATES = 0 TYPE_STANDARD = "standard" TYPE_INVERTED = "inverted" -PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) - ): DEVICE_DEFAULTS_SCHEMA, - vol.Optional(CONF_DEVICES, default={}): vol.Schema( - { - cv.string: { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.Any(TYPE_STANDARD, TYPE_INVERTED), - vol.Optional(CONF_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), - vol.Optional(CONF_GROUP, default=True): cv.boolean, - } +RFLINK_PLATFORM = { + vol.Optional( + CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) + ): DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema( + { + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TYPE): vol.Any(TYPE_STANDARD, TYPE_INVERTED), + vol.Optional(CONF_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, } - ), - } + } + ), +} + +PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( + RFLINK_PLATFORM, + extra=vol.ALLOW_EXTRA, ) @@ -124,7 +127,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Rflink cover platform.""" - async_add_entities(devices_from_config(config)) + if discovery_info is None: + create_issue_yaml_migration(hass, PLATFORM_DOMAIN) + async_add_entities(devices_from_config(config)) + else: + async_add_entities(devices_from_config(discovery_info)) class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index fe9c5e6e4f2..40fff4d1a62 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -1,7 +1,5 @@ """Support for Rflink devices.""" -from __future__ import annotations - import asyncio import logging @@ -50,7 +48,7 @@ class RflinkDevice(Entity): nogroup_aliases=None, fire_event=False, signal_repetitions=DEFAULT_SIGNAL_REPETITIONS, - ): + ) -> None: """Initialize the device.""" # Rflink specific attributes for every component type self._initial_event = initial_event diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 24bbf06c049..cbf08aa761c 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -1,7 +1,5 @@ """Support for Rflink lights.""" -from __future__ import annotations - import logging import re from typing import Any @@ -10,6 +8,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, + DOMAIN as PLATFORM_DOMAIN, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, @@ -35,7 +34,11 @@ from .const import ( EVENT_KEY_ID, ) from .entity import SwitchableRflinkDevice -from .utils import brightness_to_rflink, rflink_to_brightness +from .utils import ( + brightness_to_rflink, + create_issue_yaml_migration, + rflink_to_brightness, +) _LOGGER = logging.getLogger(__name__) @@ -46,35 +49,37 @@ TYPE_SWITCHABLE = "switchable" TYPE_HYBRID = "hybrid" TYPE_TOGGLE = "toggle" -PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) - ): DEVICE_DEFAULTS_SCHEMA, - vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): { - cv.string: vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.Any( - TYPE_DIMMABLE, TYPE_SWITCHABLE, TYPE_HYBRID, TYPE_TOGGLE - ), - vol.Optional(CONF_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_FIRE_EVENT): cv.boolean, - vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), - vol.Optional(CONF_GROUP, default=True): cv.boolean, - } - ) - }, +RFLINK_PLATFORM = { + vol.Optional( + CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) + ): DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TYPE): vol.Any( + TYPE_DIMMABLE, TYPE_SWITCHABLE, TYPE_HYBRID, TYPE_TOGGLE + ), + vol.Optional(CONF_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_FIRE_EVENT): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + } + ) }, +} + +PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( + RFLINK_PLATFORM, extra=vol.ALLOW_EXTRA, ) @@ -157,7 +162,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Rflink light platform.""" - async_add_entities(devices_from_config(config)) async def add_new_device(event): """Check if device is known, otherwise add to list of known devices.""" @@ -166,12 +170,27 @@ async def async_setup_platform( entity_type = entity_type_for_device_id(event[EVENT_KEY_ID]) entity_class = entity_class_for_type(entity_type) - device_config = config[CONF_DEVICE_DEFAULTS] device = entity_class(device_id, initial_event=event, **device_config) async_add_entities([device]) - if config[CONF_AUTOMATIC_ADD]: - hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device + def automatic_add_config( + hass: HomeAssistant, + platform_config: ConfigType | DiscoveryInfoType, + ): + """Enables the 'add_new_device' function if configured.""" + if platform_config[CONF_AUTOMATIC_ADD]: + _LOGGER.debug("enabling 'light' automatic add function") + hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device + + if discovery_info is None: + create_issue_yaml_migration(hass, PLATFORM_DOMAIN) + device_config = config[CONF_DEVICE_DEFAULTS] + async_add_entities(devices_from_config(config)) + automatic_add_config(hass, config) + else: + device_config = discovery_info[CONF_DEVICE_DEFAULTS] + async_add_entities(devices_from_config(discovery_info)) + automatic_add_config(hass, discovery_info) class RflinkLight(SwitchableRflinkDevice, LightEntity): diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 97d0b811509..46856a1281a 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -1,13 +1,13 @@ """Support for Rflink sensors.""" -from __future__ import annotations - +import logging from typing import Any from rflink.parser import PACKET_FIELDS, UNITS import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as PLATFORM_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, @@ -53,6 +53,9 @@ from .const import ( TMP_ENTITY, ) from .entity import RflinkDevice +from .utils import create_issue_yaml_migration + +_LOGGER = logging.getLogger(__name__) SENSOR_TYPES = ( # check new descriptors against PACKET_FIELDS & UNITS from rflink.parser @@ -71,7 +74,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=UnitOfPressure.HPA, ), SensorEntityDescription( - # Rflink devices reports ok/low so device class can’t be used + # Rflink devices reports ok/low so device class can't be used # It should be migrated to a binary sensor key="battery", name="Battery", @@ -265,22 +268,24 @@ SENSOR_TYPES = ( SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): { - cv.string: vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_SENSOR_TYPE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ) - }, +RFLINK_PLATFORM = { + vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SENSOR_TYPE): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) }, +} + +PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( + RFLINK_PLATFORM, extra=vol.ALLOW_EXTRA, ) @@ -312,7 +317,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Rflink platform.""" - async_add_entities(devices_from_config(config)) async def add_new_device(event): """Check if device is known, otherwise create device entity.""" @@ -327,8 +331,22 @@ async def async_setup_platform( # Add device entity async_add_entities([device]) - if config[CONF_AUTOMATIC_ADD]: - hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_SENSOR] = add_new_device + def automatic_add_config( + hass: HomeAssistant, + platform_config: ConfigType | DiscoveryInfoType, + ): + """Enables the 'add_new_device' function if configured.""" + if platform_config[CONF_AUTOMATIC_ADD]: + _LOGGER.debug("enabling 'sensor' automatic add function") + hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_SENSOR] = add_new_device + + if discovery_info is None: + create_issue_yaml_migration(hass, PLATFORM_DOMAIN) + async_add_entities(devices_from_config(config)) + automatic_add_config(hass, config) + else: + async_add_entities(devices_from_config(discovery_info)) + automatic_add_config(hass, discovery_info) class RflinkSensor(RflinkDevice, SensorEntity): @@ -356,7 +374,7 @@ class RflinkSensor(RflinkDevice, SensorEntity): """Domain specific event handler.""" self._state = event["value"] - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """Register update callback.""" # Remove temporary bogus entity_id if added diff --git a/homeassistant/components/rflink/strings.json b/homeassistant/components/rflink/strings.json index 008d4607f82..b42b7e2bcd8 100644 --- a/homeassistant/components/rflink/strings.json +++ b/homeassistant/components/rflink/strings.json @@ -1,4 +1,10 @@ { + "issues": { + "yaml_migration": { + "description": "Your current RFLink {platform} YAML configuration needs to be migrated to the new configuration format.", + "title": "Your {platform} RFLink configuration needs to be migrated" + } + }, "services": { "send_command": { "description": "Sends device command through RFLink.", diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index bbbce2b8e9a..af175cdc387 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -1,10 +1,9 @@ """Support for Rflink switches.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as PLATFORM_DOMAIN, PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) @@ -25,34 +24,37 @@ from .const import ( DEVICE_DEFAULTS_SCHEMA, ) from .entity import SwitchableRflinkDevice +from .utils import create_issue_yaml_migration PARALLEL_UPDATES = 0 -PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) - ): DEVICE_DEFAULTS_SCHEMA, - vol.Optional(CONF_DEVICES, default={}): { - cv.string: vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_FIRE_EVENT): cv.boolean, - vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), - vol.Optional(CONF_GROUP, default=True): cv.boolean, - } - ) - }, +RFLINK_PLATFORM = { + vol.Optional( + CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({}) + ): DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_GROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_FIRE_EVENT): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + } + ) }, +} + +PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( + RFLINK_PLATFORM, extra=vol.ALLOW_EXTRA, ) @@ -75,7 +77,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Rflink platform.""" - async_add_entities(devices_from_config(config)) + if discovery_info is None: + create_issue_yaml_migration(hass, PLATFORM_DOMAIN) + async_add_entities(devices_from_config(config)) + else: + async_add_entities(devices_from_config(discovery_info)) class RflinkSwitch(SwitchableRflinkDevice, SwitchEntity): diff --git a/homeassistant/components/rflink/utils.py b/homeassistant/components/rflink/utils.py index 7a05c596773..4fbc646faba 100644 --- a/homeassistant/components/rflink/utils.py +++ b/homeassistant/components/rflink/utils.py @@ -1,6 +1,9 @@ """RFLink integration utils.""" -from .const import EVENT_KEY_COMMAND, EVENT_KEY_SENSOR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, EVENT_KEY_COMMAND, EVENT_KEY_SENSOR def brightness_to_rflink(brightness: int) -> int: @@ -23,3 +26,21 @@ def identify_event_type(event): if EVENT_KEY_SENSOR in event: return EVENT_KEY_SENSOR return "unknown" + + +def create_issue_yaml_migration(hass: HomeAssistant, platform: str) -> None: + """Create a YAML migration repair.""" + async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id=f"{platform}_yaml_migration", + breaks_in_ha_version="2026.12.0", + is_fixable=False, + issue_domain=DOMAIN, + learn_more_url="https://www.home-assistant.io/integrations/rflink/#migrating-from-legacy-configuration-format", + severity=IssueSeverity.WARNING, + translation_key="yaml_migration", + translation_placeholders={ + "platform": platform, + }, + ) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 8692ff40366..90393589263 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,7 +1,5 @@ """Support for RFXtrx devices.""" -from __future__ import annotations - import binascii from collections.abc import Callable, Mapping import copy @@ -270,6 +268,8 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: _create_rfx, config, lambda event: hass.add_job(async_handle_receive, event) ) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object entry.async_on_unload( @@ -288,6 +288,7 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: event = call.data[ATTR_EVENT] rfx_object.transport.send(event) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index a86ad5557b4..f8bc5ef05e4 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -1,7 +1,5 @@ """Support for RFXtrx binary sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 53e14fdddf7..c194a8e18a0 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -1,19 +1,15 @@ """Config flow for RFXCOM RFXtrx integration.""" -from __future__ import annotations - import asyncio from contextlib import suppress import copy import itertools -import os from typing import Any, TypedDict, cast import RFXtrx as rfxtrxmod -import serial -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -556,9 +552,7 @@ class RfxtrxConfigFlow(ConfigFlow, domain=DOMAIN): if user_selection == CONF_MANUAL_PATH: return await self.async_step_setup_serial_manual_path() - dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, user_selection - ) + dev_path = user_selection try: data = await self.async_validate_rfx(device=dev_path) @@ -568,11 +562,12 @@ class RfxtrxConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return self.async_create_entry(title="RFXTRX", data=data) - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = {} for port in ports: list_of_ports[port.device] = ( - f"{port}, s/n: {port.serial_number or 'n/a'}" + f"{port.device} - {port.description or 'n/a'}" + f", s/n: {port.serial_number or 'n/a'}" + (f" - {port.manufacturer}" if port.manufacturer else "") ) list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH @@ -653,17 +648,5 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b return True -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path - - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 07443afb38b..dfcebcdef0d 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -1,7 +1,5 @@ """Support for RFXtrx covers.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index c3f61dee026..a3625b350df 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for RFXCOM RFXtrx.""" -from __future__ import annotations - from collections.abc import Callable import voluptuous as vol @@ -96,6 +94,8 @@ async def async_call_action_from_config( """Execute a device action.""" config = ACTION_SCHEMA(config) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data rfx = hass.data[DOMAIN][DATA_RFXOBJECT] commands, send_fun = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) sub_type = config[CONF_SUBTYPE] diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py index fe9e0da0d52..db1ab785523 100644 --- a/homeassistant/components/rfxtrx/device_trigger.py +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for RFXCOM RFXtrx.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/rfxtrx/diagnostics.py b/homeassistant/components/rfxtrx/diagnostics.py index d8bebfca2ae..7bb85a0241b 100644 --- a/homeassistant/components/rfxtrx/diagnostics.py +++ b/homeassistant/components/rfxtrx/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for RFXCOM RFXtrx.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py index f0cc193023c..9eb006b3cc3 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -1,7 +1,5 @@ """Support for RFXtrx devices.""" -from __future__ import annotations - from collections.abc import Callable from typing import cast @@ -52,7 +50,8 @@ class RfxtrxEntity(RestoreEntity): self._device = device self._event = event self._device_id = device_id - # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to + # If id_string is 213c7f2:1, the group_id is 213c7f2, + # and the device will respond to # group events regardless of their group indices. (self._group_id, _, _) = device_id.id_string.partition(":") @@ -119,5 +118,7 @@ class RfxtrxCommandEntity(RfxtrxEntity): async def _async_send[*_Ts]( self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts ) -> None: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 40d02953aeb..4feac88aeb7 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -1,7 +1,5 @@ """Support for RFXtrx sensors.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 90c0d2eeed7..fa9476781b7 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -1,7 +1,5 @@ """Support for RFXtrx lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index 34df4c26c18..a6958ae49d7 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -3,6 +3,7 @@ "name": "RFXCOM RFXtrx", "codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 6669b1367df..436236878ce 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -1,7 +1,5 @@ """Support for RFXtrx sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime @@ -270,7 +268,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing +# pylint: disable-next=home-assistant-invalid-inheritance # needs fixing class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor. diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 1164dafbfce..485cafa047f 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -1,7 +1,5 @@ """Support for RFXtrx sirens.""" -from __future__ import annotations - from datetime import datetime from typing import Any @@ -89,7 +87,7 @@ async def async_setup_entry( ) -class RfxtrxOffDelayMixin(Entity): # pylint: disable=hass-enforce-class-module +class RfxtrxOffDelayMixin(Entity): # pylint: disable=home-assistant-enforce-class-module """Mixin to support timeouts on data. Many 433 devices only send data when active. They will diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index b3eb63fb2b4..4261fa653ac 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -1,7 +1,5 @@ """Support for RFXtrx switches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rhasspy/__init__.py b/homeassistant/components/rhasspy/__init__.py index d673aace40b..ef4d78d9c14 100644 --- a/homeassistant/components/rhasspy/__init__.py +++ b/homeassistant/components/rhasspy/__init__.py @@ -1,7 +1,5 @@ """The Rhasspy integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/rhasspy/config_flow.py b/homeassistant/components/rhasspy/config_flow.py index ea79f6b8845..7c4cd4ea1bf 100644 --- a/homeassistant/components/rhasspy/config_flow.py +++ b/homeassistant/components/rhasspy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rhasspy integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 84c389e05d6..6bffb23d73e 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -1,7 +1,5 @@ """The Ridwell integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry @@ -9,17 +7,17 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP -from .coordinator import RidwellDataUpdateCoordinator +from .const import LOGGER, SENSOR_TYPE_NEXT_PICKUP +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RidwellConfigEntry) -> bool: """Set up Ridwell from a config entry.""" coordinator = RidwellDataUpdateCoordinator(hass, entry) await coordinator.async_initialize() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(options_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -27,17 +25,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def options_update_listener( + hass: HomeAssistant, entry: RidwellConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RidwellConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index f1c5e6bc427..c0073095fcf 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -1,13 +1,10 @@ """Support for Ridwell calendars.""" -from __future__ import annotations - import datetime from aioridwell.model import PickupCategory, RidwellAccount, RidwellPickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,15 +13,14 @@ from .const import ( CALENDAR_TITLE_ROTATING, CALENDAR_TITLE_STATUS, CONF_CALENDAR_TITLE, - DOMAIN, ) -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity @callback def async_get_calendar_event_from_pickup_event( - pickup_event: RidwellPickupEvent, config_entry: ConfigEntry + pickup_event: RidwellPickupEvent, config_entry: RidwellConfigEntry ) -> CalendarEvent: """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" pickup_items = [] @@ -32,7 +28,7 @@ def async_get_calendar_event_from_pickup_event( calendar_preference = config_entry.options.get(CONF_CALENDAR_TITLE, False) for pickup in pickup_event.pickups: pickup_items.append(f"{pickup.name} (quantity: {pickup.quantity})") - if pickup.category == PickupCategory.ROTATING: + if pickup.category is PickupCategory.ROTATING: rotating_category = pickup.name break @@ -53,7 +49,8 @@ def async_get_calendar_event_from_pickup_event( # Include only a basic title for the event. summary = summary_base else: - # Default to pickup status if no selection is made (e.g., scheduled, skipped, etc). + # Default to pickup status if no selection is made + # (e.g., scheduled, skipped, etc). summary = f"{summary_base} ({pickup_event_state})" return CalendarEvent( @@ -66,11 +63,11 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell calendars based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellCalendar(coordinator, account) diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index de7201c5f9a..c68b1b941b9 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ridwell integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any @@ -9,7 +7,7 @@ from aioridwell import async_get_client from aioridwell.errors import InvalidCredentialsError, RidwellError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv, selector @@ -19,6 +17,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .const import CALENDAR_TITLE_OPTIONS, CONF_CALENDAR_TITLE, DOMAIN, LOGGER +from .coordinator import RidwellConfigEntry STEP_REAUTH_CONFIRM_DATA_SCHEMA = vol.Schema( { @@ -107,7 +106,7 @@ class RidwellConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RidwellConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" try: diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index 336a71bc67f..4a77b53b69c 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -1,7 +1,5 @@ """Define a Ridwell coordinator.""" -from __future__ import annotations - import asyncio from datetime import timedelta from typing import cast @@ -19,6 +17,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER +type RidwellConfigEntry = ConfigEntry[RidwellDataUpdateCoordinator] + UPDATE_INTERVAL = timedelta(hours=1) @@ -27,9 +27,9 @@ class RidwellDataUpdateCoordinator( ): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: RidwellConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: RidwellConfigEntry) -> None: """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 0eff7583311..5ee97c16ecd 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -1,17 +1,13 @@ """Diagnostics support for Ridwell.""" -from __future__ import annotations - import dataclasses from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry CONF_TITLE = "title" @@ -25,17 +21,15 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RidwellConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), "data": [ dataclasses.asdict(event) - for events in coordinator.data.values() + for events in entry.runtime_data.data.values() for event in events ], }, diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index d8323f7aef6..742a2b3558f 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,13 +1,10 @@ """Define a base Ridwell entity.""" -from __future__ import annotations - -from datetime import date - from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator @@ -41,5 +38,5 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): return next( event for event in self.coordinator.data[self._account.account_id] - if event.pickup_date >= date.today() + if event.pickup_date >= dt_util.now().date() ) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 30f97ecaea8..fb4359c0bf7 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -1,7 +1,5 @@ """Support for Ridwell sensors.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import date from typing import Any @@ -13,12 +11,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SENSOR_TYPE_NEXT_PICKUP -from .coordinator import RidwellDataUpdateCoordinator +from .const import SENSOR_TYPE_NEXT_PICKUP +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity ATTR_CATEGORY = "category" @@ -35,11 +32,11 @@ SENSOR_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellSensor(coordinator, account, SENSOR_DESCRIPTION) @@ -79,7 +76,7 @@ class RidwellSensor(RidwellEntity, SensorEntity): else: # Ridwell's API will return distinct objects, even if they have the # same name (e.g. two pickups of Latex Paint will show up as two - # objects) – so, we sum the quantities: + # objects) - so, we sum the quantities: attrs[ATTR_PICKUP_TYPES][pickup.name][ATTR_QUANTITY] += pickup.quantity return attrs diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index e3be9ea5368..712a0550b3f 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -1,20 +1,16 @@ """Support for Ridwell buttons.""" -from __future__ import annotations - from typing import Any from aioridwell.errors import RidwellError from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity SWITCH_DESCRIPTION = SwitchEntityDescription( @@ -25,11 +21,11 @@ SWITCH_DESCRIPTION = SwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellSwitch(coordinator, account, SWITCH_DESCRIPTION) @@ -55,7 +51,7 @@ class RidwellSwitch(RidwellEntity, SwitchEntity): @property def is_on(self) -> bool: """Return True if entity is on.""" - return self.next_pickup_event.state == EventState.SCHEDULED + return self.next_pickup_event.state is EventState.SCHEDULED async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 8e36f3e85e7..89d28dcc4c5 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -1,7 +1,5 @@ """Support for Ring Doorbell/Chimes.""" -from __future__ import annotations - import logging from typing import Any, cast import uuid diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 49051ee5e11..f7e7ccb3860 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,7 +1,5 @@ """Component providing HA sensor support for Ring Door Bell/Chimes.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime @@ -36,7 +34,9 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class RingBinarySensorEntityDescription( - BinarySensorEntityDescription, RingEntityDescription, Generic[RingDeviceT] + BinarySensorEntityDescription, + RingEntityDescription, + Generic[RingDeviceT], # noqa: UP046 ): """Describes Ring binary sensor entity.""" diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index 09e6c0e413a..91c50b50792 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -1,7 +1,5 @@ """Component providing support for Ring buttons.""" -from __future__ import annotations - from ring_doorbell import RingOther from homeassistant.components.button import ButtonEntity, ButtonEntityDescription diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 21ce0bfb2b3..c8d3587d44d 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,7 +1,5 @@ """Component providing support to the Ring Door Bell camera.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -46,7 +44,7 @@ _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) -class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]): +class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]): # noqa: UP046 """Base class for event entity description.""" exists_fn: Callable[[RingDoorBell], bool] @@ -128,8 +126,9 @@ class RingCam(RingEntity[RingDoorBell], Camera): self._device = self._get_coordinator_data().get_video_device( self._device.device_api_id ) + history_data = self._device.last_history - if history_data: + if history_data and self._device.has_subscription: self._last_event = history_data[0] # will call async_update to update the attributes and get the # video url from the api @@ -154,13 +153,16 @@ class RingCam(RingEntity[RingDoorBell], Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - # For live_view cameras, get a fresh snapshot - if self.entity_description.key == "live_view": - return await self._async_get_fresh_snapshot() + if self._video_url is None: + if not self._device.has_subscription: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_subscription", + ) + return None - # For last_recording cameras, use the cached video frame key = (width, height) - if not (image := self._images.get(key)) and self._video_url is not None: + if not (image := self._images.get(key)): image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -173,11 +175,6 @@ class RingCam(RingEntity[RingDoorBell], Camera): return image - @exception_wrap - async def _async_get_fresh_snapshot(self) -> bytes | None: - """Get a fresh snapshot from the camera.""" - return await self._device.async_get_snapshot() - async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 68ac00d69f6..03420cad8c5 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -1,7 +1,5 @@ """The Ring constants.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 413c48c35eb..2117d50c26f 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -1,7 +1,5 @@ """Data coordinators for the ring integration.""" -from __future__ import annotations - from asyncio import TaskGroup from collections.abc import Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py index cecf26a46a7..68789cf75fb 100644 --- a/homeassistant/components/ring/diagnostics.py +++ b/homeassistant/components/ring/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Ring.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index db99a10de74..a0053e28264 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -7,6 +7,7 @@ from ring_doorbell import RingCapability, RingEvent as RingAlert from ring_doorbell.const import KIND_DING, KIND_INTERCOM_UNLOCK, KIND_MOTION from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -23,7 +24,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): +class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): # noqa: UP046 """Base class for event entity description.""" capability: RingCapability @@ -34,7 +35,7 @@ EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( key=KIND_DING, translation_key=KIND_DING, device_class=EventDeviceClass.DOORBELL, - event_types=[KIND_DING], + event_types=[DoorbellEventType.RING], capability=RingCapability.DING, ), RingEventEntityDescription( @@ -100,7 +101,10 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity) @callback def _handle_coordinator_update(self) -> None: if (alert := self._get_coordinator_alert()) and not alert.is_update: - self._async_handle_event(alert.kind) + if alert.kind == KIND_DING: + self._async_handle_event(DoorbellEventType.RING) + else: + self._async_handle_event(alert.kind) super()._handle_coordinator_update() @property diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py index 68b41451bd0..a86a5bebe33 100644 --- a/homeassistant/components/ring/number.py +++ b/homeassistant/components/ring/number.py @@ -43,7 +43,7 @@ async def async_setup_entry( @dataclass(frozen=True, kw_only=True) -class RingNumberEntityDescription(NumberEntityDescription, Generic[RingDeviceT]): +class RingNumberEntityDescription(NumberEntityDescription, Generic[RingDeviceT]): # noqa: UP046 """Describes Ring number entity.""" value_fn: Callable[[RingDeviceT], StateType] diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 2741e9d1b38..06f0fb5a80c 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,7 +1,5 @@ """Component providing HA sensor support for Ring Door Bell/Chimes.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic, cast @@ -137,7 +135,9 @@ def _get_last_event_attrs( @dataclass(frozen=True, kw_only=True) class RingSensorEntityDescription( - SensorEntityDescription, RingEntityDescription, Generic[RingDeviceT] + SensorEntityDescription, + RingEntityDescription, + Generic[RingDeviceT], # noqa: UP046 ): """Describes Ring sensor entity.""" diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7f096c0e643..e8218951555 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -43,7 +43,9 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) class RingSirenEntityDescription( - SirenEntityDescription, RingEntityDescription, Generic[RingDeviceT] + SirenEntityDescription, + RingEntityDescription, + Generic[RingDeviceT], # noqa: UP046 ): """Describes a Ring siren entity.""" diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 09f36d6dd74..e7321b207fb 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -73,7 +73,14 @@ }, "event": { "ding": { - "name": "Ding" + "name": "Ding", + "state_attributes": { + "event_type": { + "state": { + "ring": "[%key:component::event::entity_component::doorbell::state_attributes::event_type::state::ring%]" + } + } + } }, "intercom_unlock": { "name": "Intercom unlock" @@ -151,6 +158,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "no_subscription": { + "message": "Ring Protect subscription required for snapshots" + }, "sdp_m_line_index_required": { "message": "Error negotiating stream for {device}" } diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 5f90ae8a1b5..625cae8b8ba 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -36,7 +36,9 @@ IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != @dataclass(frozen=True, kw_only=True) class RingSwitchEntityDescription( - SwitchEntityDescription, RingEntityDescription, Generic[RingDeviceT] + SwitchEntityDescription, + RingEntityDescription, + Generic[RingDeviceT], # noqa: UP046 ): """Describes a Ring switch entity.""" diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 30d2d77dcb4..9858f11d442 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -1,7 +1,5 @@ """Support for Ripple sensors.""" -from __future__ import annotations - from datetime import timedelta from pyripple import get_balance diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index d65bd5d5abf..beaf5951159 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -1,10 +1,15 @@ """The Risco integration.""" -from __future__ import annotations - +from asyncio import CancelledError import logging -from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError +from pyrisco import ( + CannotConnectError, + OperationError, + RiscoCloud, + RiscoLocal, + UnauthorizedError, +) from pyrisco.common import Partition, System, Zone from homeassistant.config_entries import ConfigEntry @@ -25,16 +30,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_COMMUNICATION_DELAY, CONF_CONCURRENCY, - DATA_COORDINATOR, DEFAULT_CONCURRENCY, DOMAIN, - EVENTS_COORDINATOR, SYSTEM_UPDATE_SIGNAL, TYPE_LOCAL, ) from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator -from .models import LocalData +from .models import CloudData, LocalData, RiscoConfigEntry, RiscoData from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -46,6 +50,8 @@ PLATFORMS = [ Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) +# pyrisco exposes timeout context as message text for this case. +CLOCK_TIMEOUT_ERROR_FRAGMENT = "Timeout in command: CLOCK" def is_local(entry: ConfigEntry) -> bool: @@ -58,7 +64,7 @@ def zone_update_signal(zone_id: int) -> str: return f"risco_zone_update_{zone_id}" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RiscoConfigEntry) -> bool: """Set up Risco from a config entry.""" if is_local(entry): return await _async_setup_local_entry(hass, entry) @@ -66,11 +72,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await _async_setup_cloud_entry(hass, entry) -async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_local_entry( + hass: HomeAssistant, entry: RiscoConfigEntry +) -> bool: data = entry.data concurrency = entry.options.get(CONF_CONCURRENCY, DEFAULT_CONCURRENCY) risco = RiscoLocal( - data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], concurrency=concurrency + data[CONF_HOST], + data[CONF_PORT], + data[CONF_PIN], + communication_delay=data.get(CONF_COMMUNICATION_DELAY, 0), + concurrency=concurrency, ) try: @@ -78,14 +90,26 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b except CannotConnectError as error: raise ConfigEntryNotReady from error except UnauthorizedError: - _LOGGER.exception("Failed to login to Risco cloud") + _LOGGER.exception("Failed to authenticate with local Risco panel") return False async def _error(error: Exception) -> None: - _LOGGER.error("Error in Risco library", exc_info=error) - if isinstance(error, ConnectionResetError) and not hass.is_stopping: - _LOGGER.debug("Disconnected from panel. Reloading integration") - hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + if isinstance(error, OperationError) and CLOCK_TIMEOUT_ERROR_FRAGMENT in str( + error + ): + _LOGGER.warning( + "Risco keep-alive timeout for entry %s (host: %s)", + entry.title, + data.get(CONF_HOST, "unknown"), + ) + else: + _LOGGER.error( + "Error in Risco library", + exc_info=error, + ) + if isinstance(error, ConnectionResetError) and not hass.is_stopping: + _LOGGER.debug("Disconnected from panel. Reloading integration") + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) entry.async_on_unload(risco.add_error_handler(_error)) @@ -120,14 +144,15 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b entry.async_on_unload(entry.add_update_listener(_update_listener)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = local_data + entry.runtime_data = RiscoData(local_data=local_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_cloud_entry( + hass: HomeAssistant, entry: RiscoConfigEntry +) -> bool: data = entry.data risco = RiscoCloud(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN]) try: @@ -143,11 +168,12 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b entry.async_on_unload(entry.add_update_listener(_update_listener)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - EVENTS_COORDINATOR: events_coordinator, - } + entry.runtime_data = RiscoData( + cloud_data=CloudData( + coordinator=coordinator, + events_coordinator=events_coordinator, + ) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await events_coordinator.async_refresh() @@ -155,20 +181,25 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RiscoConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - if is_local(entry): - local_data: LocalData = hass.data[DOMAIN][entry.entry_id] + if unload_ok and (local_data := entry.runtime_data.local_data): + try: await local_data.system.disconnect() - - hass.data[DOMAIN].pop(entry.entry_id) + except CancelledError: + raise + except Exception: + _LOGGER.exception( + "Failed to disconnect from local Risco panel for entry %s (host: %s)", + entry.title, + entry.data.get(CONF_HOST, "unknown"), + ) return unload_ok -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _update_listener(hass: HomeAssistant, entry: RiscoConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index f485c923776..efb1b942f69 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Risco alarms.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any @@ -15,19 +13,16 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local from .const import ( CONF_CODE_ARM_REQUIRED, CONF_CODE_DISARM_REQUIRED, CONF_HA_STATES_TO_RISCO, CONF_RISCO_STATES_TO_HA, - DATA_COORDINATOR, DEFAULT_OPTIONS, DOMAIN, RISCO_ARM, @@ -36,12 +31,15 @@ from .const import ( ) from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudEntity +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) STATES_TO_SUPPORTED_FEATURES = { AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS: ( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ), AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, } @@ -49,13 +47,13 @@ STATES_TO_SUPPORTED_FEATURES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" options = {**DEFAULT_OPTIONS, **config_entry.options} - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: async_add_entities( RiscoLocalAlarm( local_data.system.id, @@ -67,10 +65,8 @@ async def async_setup_entry( ) for partition_id, partition in local_data.system.partitions.items() ) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudAlarm( coordinator, partition_id, config_entry.data[CONF_PIN], options diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index ff61985fef3..da1d7163ef8 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Risco alarm zones.""" -from __future__ import annotations - from collections.abc import Mapping from itertools import chain from typing import Any @@ -15,16 +13,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local -from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL +from .const import DOMAIN, SYSTEM_UPDATE_SIGNAL from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity +from .models import RiscoConfigEntry SYSTEM_ENTITY_DESCRIPTIONS = [ BinarySensorEntityDescription( @@ -72,12 +69,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: zone_entities = ( entity for zone_id, zone in local_data.system.zones.items() @@ -96,10 +93,8 @@ async def async_setup_entry( ) async_add_entities(chain(system_entities, zone_entities)) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudBinarySensor(coordinator, zone_id, zone) for zone_id, zone in coordinator.data.zones.items() diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index f7365d35414..911e24366d8 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Risco integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -10,12 +8,7 @@ from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedErro import voluptuous as vol from homeassistant.components.alarm_control_panel import AlarmControlPanelState -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -26,6 +19,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -34,6 +28,7 @@ from .const import ( CONF_COMMUNICATION_DELAY, CONF_CONCURRENCY, CONF_HA_STATES_TO_RISCO, + CONF_MORE_OPTIONS, CONF_RISCO_STATES_TO_HA, DEFAULT_ADVANCED_OPTIONS, DEFAULT_OPTIONS, @@ -42,6 +37,7 @@ from .const import ( RISCO_STATES, TYPE_LOCAL, ) +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -121,12 +117,12 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Init the config flow.""" - self._reauth_entry: ConfigEntry | None = None + self._reauth_entry: RiscoConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, ) -> RiscoOptionsFlowHandler: """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) @@ -218,12 +214,13 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): class RiscoOptionsFlowHandler(OptionsFlow): """Handle a Risco options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RiscoConfigEntry) -> None: """Initialize.""" self._data = {**DEFAULT_OPTIONS, **config_entry.options} def _options_schema(self) -> vol.Schema: - schema = vol.Schema( + self._data = {**DEFAULT_ADVANCED_OPTIONS, **self._data} + return vol.Schema( { vol.Required( CONF_CODE_ARM_REQUIRED, default=self._data[CONF_CODE_ARM_REQUIRED] @@ -232,27 +229,34 @@ class RiscoOptionsFlowHandler(OptionsFlow): CONF_CODE_DISARM_REQUIRED, default=self._data[CONF_CODE_DISARM_REQUIRED], ): bool, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + # Polling interval is user-configurable, + # which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field + vol.Required( + CONF_SCAN_INTERVAL, + default=self._data[CONF_SCAN_INTERVAL], + ): int, + vol.Required( + CONF_CONCURRENCY, + default=self._data[CONF_CONCURRENCY], + ): int, + } + ), + SectionConfig(collapsed=True), + ), } ) - if self.show_advanced_options: - self._data = {**DEFAULT_ADVANCED_OPTIONS, **self._data} - schema = schema.extend( - { - vol.Required( - CONF_SCAN_INTERVAL, default=self._data[CONF_SCAN_INTERVAL] - ): int, - vol.Required( - CONF_CONCURRENCY, default=self._data[CONF_CONCURRENCY] - ): int, - } - ) - return schema async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) + user_input.update(more_options) self._data = {**self._data, **user_input} return await self.async_step_risco_to_ha() diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 88fae4de7c2..db749ef6e6f 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -24,6 +24,7 @@ CONF_RISCO_STATES_TO_HA = "risco_states_to_ha" CONF_HA_STATES_TO_RISCO = "ha_states_to_risco" CONF_COMMUNICATION_DELAY = "communication_delay" CONF_CONCURRENCY = "concurrency" +CONF_MORE_OPTIONS = "more_options" RISCO_GROUPS = ["A", "B", "C", "D"] RISCO_ARM = "arm" diff --git a/homeassistant/components/risco/coordinator.py b/homeassistant/components/risco/coordinator.py index e7140eb9616..c8ed189fb90 100644 --- a/homeassistant/components/risco/coordinator.py +++ b/homeassistant/components/risco/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Risco integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index f448f60f4d9..4e285cd0d40 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -1,7 +1,5 @@ """A risco entity base class.""" -from __future__ import annotations - from typing import Any from pyrisco import RiscoCloud diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 43d471172d6..c744385f1ab 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.7"] + "requirements": ["pyrisco==0.8.0"] } diff --git a/homeassistant/components/risco/models.py b/homeassistant/components/risco/models.py index 07777839e88..66f7f698fa8 100644 --- a/homeassistant/components/risco/models.py +++ b/homeassistant/components/risco/models.py @@ -2,10 +2,36 @@ from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from pyrisco import RiscoLocal +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .coordinator import ( + RiscoDataUpdateCoordinator, + RiscoEventsDataUpdateCoordinator, + ) + +type RiscoConfigEntry = ConfigEntry[RiscoData] + + +@dataclass +class RiscoData: + """Runtime data for the Risco integration.""" + + local_data: LocalData | None = None + cloud_data: CloudData | None = None + + +@dataclass +class CloudData: + """A data class for cloud data passed to the platforms.""" + + coordinator: RiscoDataUpdateCoordinator + events_coordinator: RiscoEventsDataUpdateCoordinator + @dataclass class LocalData: diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index 93683f1aa50..ae7e0836064 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -1,7 +1,5 @@ """Sensor for Risco Events.""" -from __future__ import annotations - from collections.abc import Collection, Mapping from datetime import datetime from typing import Any @@ -10,17 +8,16 @@ from pyrisco.cloud.event import Event from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import is_local -from .const import DOMAIN, EVENTS_COORDINATOR +from .const import DOMAIN from .coordinator import RiscoEventsDataUpdateCoordinator from .entity import zone_unique_id +from .models import RiscoConfigEntry CATEGORIES = { 2: "Alarm", @@ -45,17 +42,15 @@ EVENT_ATTRIBUTES = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" - if is_local(config_entry): + if not (cloud_data := config_entry.runtime_data.cloud_data): # no events in local comm return - coordinator: RiscoEventsDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][EVENTS_COORDINATOR] + coordinator = cloud_data.events_coordinator sensors = [ RiscoSensor(coordinator, category_id, [], name, config_entry.entry_id) for category_id, name in CATEGORIES.items() diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py index 4ea8f6edd4f..d48621219c3 100644 --- a/homeassistant/components/risco/services.py +++ b/homeassistant/components/risco/services.py @@ -4,26 +4,26 @@ from datetime import datetime import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME, CONF_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN, SERVICE_SET_TIME, TYPE_LOCAL -from .models import LocalData +from .const import DOMAIN, SERVICE_SET_TIME +from .models import RiscoConfigEntry async def async_setup_services(hass: HomeAssistant) -> None: """Create the Risco Services/Actions.""" async def _set_time(service_call: ServiceCall) -> None: - entry = service.async_get_config_entry( + entry: RiscoConfigEntry = service.async_get_config_entry( service_call.hass, DOMAIN, service_call.data[ATTR_CONFIG_ENTRY_ID] ) time = service_call.data.get(ATTR_TIME) # Validate config entry is local (not cloud) - if entry.data.get(CONF_TYPE) != TYPE_LOCAL: + if not (local_data := entry.runtime_data.local_data): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_local_entry", @@ -33,8 +33,6 @@ async def async_setup_services(hass: HomeAssistant) -> None: if time is None: time_to_send = datetime.now() - local_data: LocalData = hass.data[DOMAIN][entry.entry_id] - await local_data.system.set_time(time_to_send) hass.services.async_register( diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 0f00a8d8a58..685e5bff11d 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -91,9 +91,16 @@ "init": { "data": { "code_arm_required": "Require PIN to arm", - "code_disarm_required": "Require PIN to disarm", - "concurrency": "Maximum concurrent requests in Risco local", - "scan_interval": "How often to poll Risco Cloud (in seconds)" + "code_disarm_required": "Require PIN to disarm" + }, + "sections": { + "more_options": { + "data": { + "concurrency": "Maximum concurrent requests in Risco local", + "scan_interval": "How often to poll Risco Cloud (in seconds)" + }, + "name": "More options" + } }, "title": "Configure options" }, diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 547dedd3933..dd31981bf75 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -1,39 +1,33 @@ """Support for bypassing Risco alarm zones.""" -from __future__ import annotations - from typing import Any from pyrisco.common import Zone from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local -from .const import DATA_COORDINATOR, DOMAIN from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity +from .models import RiscoConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco switch.""" - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: async_add_entities( RiscoLocalSwitch(local_data.system.id, zone_id, zone) for zone_id, zone in local_data.system.zones.items() ) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudSwitch(coordinator, zone_id, zone) for zone_id, zone in coordinator.data.zones.items() diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index f2f1fcccfdc..b0e1540bc92 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -13,8 +13,8 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL -from .coordinator import RitualsDataUpdateCoordinator +from .const import ACCOUNT_HASH, UPDATE_INTERVAL +from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,10 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RitualsConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" - # Initiate reauth for old config entries which don't have username / password in the entry data + # Initiate reauth for old config entries which don't have + # username / password in the entry data if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data: raise ConfigEntryAuthFailed("Missing credentials") @@ -87,19 +88,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RitualsConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @callback diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 97e9c8418d1..209ba4861b2 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Rituals Perfume Genie binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -12,15 +10,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry from .entity import DiffuserEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -43,13 +41,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RitualsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators = config_entry.runtime_data async_add_entities( RitualsBinarySensorEntity(coordinator, description) diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index ee7e57c0fd8..e7b57174847 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rituals Perfume Genie integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index 8513c994320..ea047e25f6b 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -15,11 +15,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type RitualsConfigEntry = ConfigEntry[dict[str, RitualsDataUpdateCoordinator]] + class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + """Manage fetching Rituals Perfume Genie device data.""" - config_entry: ConfigEntry + config_entry: RitualsConfigEntry def __init__( self, @@ -43,13 +45,15 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from Rituals, with one silent re-auth on 401. - If silent re-auth also fails, raise ConfigEntryAuthFailed to trigger reauth flow. + If silent re-auth also fails, raise ConfigEntryAuthFailed + to trigger reauth flow. Other HTTP/network errors are wrapped in UpdateFailed so HA can retry. """ try: await self.diffuser.update_data() except (AuthenticationException, ClientResponseError) as err: - # Treat 401/403 like AuthenticationException → one silent re-auth, single retry + # Treat 401/403 like AuthenticationException: + # one silent re-auth, single retry if isinstance(err, ClientResponseError) and (status := err.status) not in ( 401, 403, diff --git a/homeassistant/components/rituals_perfume_genie/diagnostics.py b/homeassistant/components/rituals_perfume_genie/diagnostics.py index bcc61a01ad6..b98e5d3fcd8 100644 --- a/homeassistant/components/rituals_perfume_genie/diagnostics.py +++ b/homeassistant/components/rituals_perfume_genie/diagnostics.py @@ -1,15 +1,11 @@ """Diagnostics support for Rituals Perfume Genie.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry TO_REDACT = { "hublot", @@ -18,15 +14,12 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RitualsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] return { "diffusers": [ async_redact_data(coordinator.diffuser.data, TO_REDACT) - for coordinator in coordinators.values() + for coordinator in entry.runtime_data.values() ] } diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 35dbf639dd0..075df410cf0 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,7 +1,5 @@ """Base class for Rituals Perfume Genie diffuser entity.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 98e833ff9bd..f902e8e0d58 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -1,7 +1,5 @@ """Support for Rituals Perfume Genie numbers.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -9,14 +7,14 @@ from typing import Any from pyrituals import Diffuser from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry from .entity import DiffuserEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RitualsNumberEntityDescription(NumberEntityDescription): @@ -40,13 +38,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RitualsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators = config_entry.runtime_data async_add_entities( RitualsNumberEntity(coordinator, description) for coordinator in coordinators.values() diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 0636888c3d2..379a908c19f 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -1,22 +1,20 @@ """Support for Rituals Perfume Genie numbers.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from pyrituals import Diffuser from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator from .entity import DiffuserEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RitualsSelectEntityDescription(SelectEntityDescription): @@ -43,13 +41,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RitualsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators = config_entry.runtime_data async_add_entities( RitualsSelectEntity(coordinator, description) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 3921fd0b6c2..2daeeae05dc 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -1,7 +1,5 @@ """Support for Rituals Perfume Genie sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -12,15 +10,15 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry from .entity import DiffuserEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RitualsSensorEntityDescription(SensorEntityDescription): @@ -59,13 +57,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RitualsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators = config_entry.runtime_data async_add_entities( RitualsSensorEntity(coordinator, description) diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index c5331b49078..ac080b59f81 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -1,7 +1,5 @@ """Support for Rituals Perfume Genie switches.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -9,14 +7,14 @@ from typing import Any from pyrituals import Diffuser from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator from .entity import DiffuserEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RitualsSwitchEntityDescription(SwitchEntityDescription): @@ -41,13 +39,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RitualsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser switch.""" - coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators = config_entry.runtime_data async_add_entities( RitualsSwitchEntity(coordinator, description) diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index b85a731bac0..e00ded0b555 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -1,7 +1,5 @@ """Support for departure information for Rhein-Main public transport.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 8cffd29357d..3f90360968c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -1,7 +1,5 @@ """The Roborock component.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine from datetime import timedelta @@ -98,7 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( - "Invalid credentials", translation_domain=DOMAIN, translation_key="invalid_credentials", ) from err @@ -120,7 +117,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> except RoborockException as err: _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( - "Failed to get Roborock home data", translation_domain=DOMAIN, translation_key="home_data_fail", ) from err @@ -145,11 +141,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> **get_device_info(device), ) - enabled_devices = [ - device for device in devices if not _is_device_disabled(device_registry, device) - ] + enabled_devices = [] + disabled_devices = [] + for device in devices: + if _is_device_disabled(device_registry, device): + disabled_devices.append(device) + else: + enabled_devices.append(device) _LOGGER.debug("%d of %d devices are enabled", len(enabled_devices), len(devices)) + # Close connections for disabled devices to prevent their background + # reconnect loops from triggering MQTT session restarts that would + # disrupt coordinator setup for the enabled devices. + if disabled_devices: + close_results = await asyncio.gather( + *[device.close() for device in disabled_devices], + return_exceptions=True, + ) + for device, close_result in zip(disabled_devices, close_results, strict=True): + if isinstance(close_result, Exception): + _LOGGER.debug( + "Failed to close disabled Roborock device %s: %s", + device.duid, + close_result, + ) + coordinators = await asyncio.gather( *build_setup_functions(hass, entry, enabled_devices, user_data), return_exceptions=True, @@ -179,7 +195,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> and enabled_devices ): raise ConfigEntryNotReady( - "No devices were able to successfully setup", translation_domain=DOMAIN, translation_key="no_coordinators", ) @@ -307,7 +322,8 @@ def build_setup_functions( ) else: _LOGGER.warning( - "Not adding device %s because its protocol version %s or category %s is not supported", + "Not adding device %s because its protocol version" + " %s or category %s is not supported", device.duid, device.device_info.pv, device.product.category.name, diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index b79128e809c..1c117e5c097 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Roborock sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -159,7 +157,8 @@ async def async_setup_entry( ) for coordinator in config_entry.runtime_data.v1 for description in BINARY_SENSOR_DESCRIPTIONS - # Note: Currently coordinator.data is always available on startup but won't be in the future + # Note: Currently coordinator.data is always available + # on startup but won't be in the future if ( coordinator.data is not None and description.value_fn(coordinator.data) is not None diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index dfeaba0026c..0273cb89b4d 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -1,13 +1,13 @@ """Support for Roborock button.""" -from __future__ import annotations - import asyncio +from collections.abc import Callable from dataclasses import dataclass import itertools import logging from typing import Any +from roborock.device_features import is_wash_n_fill_dock from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol @@ -43,6 +43,13 @@ class RoborockButtonDescription(ButtonEntityDescription): """Describes a Roborock button entity.""" attribute: ConsumableAttribute + is_dock_entity: bool = False + is_supported: Callable[[RoborockDataUpdateCoordinator], bool] = lambda _: True + + +def _supports_dock_consumables(coordinator: RoborockDataUpdateCoordinator) -> bool: + dock_type = coordinator.properties_api.status.dock_type + return dock_type is not None and is_wash_n_fill_dock(dock_type) CONSUMABLE_BUTTON_DESCRIPTIONS = [ @@ -74,6 +81,24 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), + RoborockButtonDescription( + key="reset_dock_strainer_consumable", + translation_key="reset_dock_strainer_consumable", + attribute=ConsumableAttribute.STRAINER_WORK_TIME, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + is_dock_entity=True, + is_supported=_supports_dock_consumables, + ), + RoborockButtonDescription( + key="reset_dock_cleaning_brush_consumable", + translation_key="reset_dock_cleaning_brush_consumable", + attribute=ConsumableAttribute.CLEANING_BRUSH_WORK_TIME, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + is_dock_entity=True, + is_supported=_supports_dock_consumables, + ), ] @@ -128,8 +153,9 @@ async def async_setup_entry( description, ) for coordinator in config_entry.runtime_data.v1 - for description in CONSUMABLE_BUTTON_DESCRIPTIONS if isinstance(coordinator, RoborockDataUpdateCoordinator) + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if description.is_supported(coordinator) ), ( RoborockRoutineButtonEntity( @@ -176,9 +202,14 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity): entity_description: RoborockButtonDescription, ) -> None: """Create a button entity.""" + device_info = ( + coordinator.dock_device_info + if entity_description.is_dock_entity + else coordinator.device_info + ) super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, + device_info, api=coordinator.properties_api.command, ) self.entity_description = entity_description diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 4061564c3bd..6eac5a2a5cd 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -1,11 +1,10 @@ """Config flow for Roborock.""" -from __future__ import annotations - from collections.abc import Mapping from copy import deepcopy import logging from typing import Any +from urllib.parse import urlparse from roborock.data import UserData from roborock.exceptions import ( @@ -25,7 +24,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_REGION, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -33,6 +32,9 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -40,7 +42,7 @@ from . import RoborockConfigEntry from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, - CONF_REGION, + CONF_ROBOROCK_SERVER_URL, CONF_SHOW_BACKGROUND, CONF_SHOW_ROOMS, CONF_SHOW_WALLS, @@ -48,6 +50,8 @@ from .const import ( DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, + REGION_AUTO, + REGION_CUSTOM, REGION_OPTIONS, ) @@ -76,8 +80,10 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): region = user_input[CONF_REGION] self._username = username _LOGGER.debug("Requesting code for Roborock account") + if region == REGION_CUSTOM: + return await self.async_step_custom_url() base_url = None - if region != "auto": + if region != REGION_AUTO: base_url = f"https://{region}iot.roborock.com" self._client = RoborockApiClient( username, @@ -93,7 +99,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_USERNAME): str, - vol.Required(CONF_REGION, default="auto"): SelectSelector( + vol.Required(CONF_REGION, default=REGION_AUTO): SelectSelector( SelectSelectorConfig( options=REGION_OPTIONS, mode=SelectSelectorMode.DROPDOWN, @@ -105,6 +111,44 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle custom server URL entry.""" + errors: dict[str, str] = {} + assert self._username + if user_input is not None: + url = user_input[CONF_ROBOROCK_SERVER_URL].strip() + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + errors[CONF_ROBOROCK_SERVER_URL] = "invalid_url_format" + else: + self._client = RoborockApiClient( + self._username, + base_url=url, + session=async_get_clientsession(self.hass), + ) + errors = await self._request_code() + if not errors: + return await self.async_step_code() + + return self.async_show_form( + step_id="custom_url", + data_schema=vol.Schema( + { + vol.Required( + CONF_ROBOROCK_SERVER_URL, + default=( + user_input[CONF_ROBOROCK_SERVER_URL] + if user_input is not None + else "https://usiot.roborock.com" + ), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)), + } + ), + errors=errors, + ) + async def _request_code(self) -> dict: assert self._client errors: dict[str, str] = {} diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 1ed0df695b8..89994809b8a 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -13,8 +13,21 @@ CONF_USER_DATA = "user_data" CONF_SHOW_BACKGROUND = "show_background" CONF_SHOW_WALLS = "show_walls" CONF_SHOW_ROOMS = "show_rooms" -CONF_REGION = "region" -REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"] +CONF_ROBOROCK_SERVER_URL = "roborock_server_url" +REGION_AUTO = "auto" +REGION_CUSTOM = "custom" +REGION_US = "us" +REGION_EU = "eu" +REGION_RU = "ru" +REGION_CN = "cn" +REGION_OPTIONS = [ + REGION_AUTO, + REGION_US, + REGION_EU, + REGION_RU, + REGION_CN, + REGION_CUSTOM, +] # Option Flow steps DRAWABLES = "drawables" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 645cbbea0c3..622831afdfc 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -1,11 +1,9 @@ """Roborock Coordinator.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any, TypeVar +from typing import Any from propcache.api import cached_property from roborock import B01Props @@ -166,7 +164,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="map_failure", - translation_placeholders={"error": str(err)}, ) from err else: # Force a map refresh on first setup @@ -357,10 +354,9 @@ async def _refresh_traits(traits: list[Any]) -> None: ) from ex -_V = TypeVar("_V", bound=RoborockDyadDataProtocol | RoborockZeoProtocol) - - -class RoborockDataUpdateCoordinatorA01(DataUpdateCoordinator[dict[_V, StateType]]): +class RoborockDataUpdateCoordinatorA01[ + _V: RoborockDyadDataProtocol | RoborockZeoProtocol +](DataUpdateCoordinator[dict[_V, StateType]]): """Class to manage fetching data from the API for A01 devices.""" config_entry: RoborockConfigEntry @@ -550,6 +546,7 @@ class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01): RoborockB01Props.WIND, RoborockB01Props.WATER, RoborockB01Props.MODE, + RoborockB01Props.CLEAN_PATH_PREFERENCE, RoborockB01Props.QUANTITY, ] diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index 642dd254c21..ebed19c8f85 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -1,7 +1,5 @@ """Support for the Airzone diagnostics.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index e5c1c6e2081..71018ee9e14 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -24,6 +24,12 @@ "reset_air_filter_consumable": { "default": "mdi:air-filter" }, + "reset_dock_cleaning_brush_consumable": { + "default": "mdi:brush" + }, + "reset_dock_strainer_consumable": { + "default": "mdi:filter" + }, "reset_main_brush_consumable": { "default": "mdi:brush" }, diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 49f08024092..9b8fa4f56f7 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.5.1", + "python-roborock==5.14.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 749a49518e8..ecb76ff75c0 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -74,7 +74,7 @@ async def async_setup_entry( class RoborockNumberEntity(RoborockEntityV1, NumberEntity): - """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + """A class to set options on a Roborock vacuum with fixed options.""" entity_description: RoborockNumberDescription diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index f14e191b589..66656c4f8da 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -62,7 +62,7 @@ rules: status: exempt comment: There are no noisy entities. entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: todo reconfiguration-flow: todo repair-issues: done diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 0ff27d8145f..4bfa32765d5 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -8,6 +8,7 @@ from typing import Any from roborock import B01Props, CleanTypeMapping from roborock.data import ( + CleanPathPreferenceMapping, RoborockDockDustCollectionModeCode, RoborockEnum, WaterLevelMapping, @@ -20,6 +21,7 @@ from roborock.data import ( ZeoSpin, ZeoTemperature, ) +from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait @@ -37,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MAP_SLEEP from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -44,6 +47,7 @@ from .coordinator import ( from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, ) @@ -63,7 +67,7 @@ class RoborockSelectDescription(SelectEntityDescription): """Function to get the current value of the select entity.""" options_lambda: Callable[[PropertiesApi], list[str] | None] - """Function to get all options of the select entity or returns None if not supported.""" + """Get all options or return None if not supported.""" parameter_lambda: Callable[[str, PropertiesApi], list[int]] """Function to get the parameters for the api command.""" @@ -83,7 +87,7 @@ class RoborockB01SelectDescription(SelectEntityDescription): """Function to get the current value of the select entity.""" options_lambda: Callable[[Q7PropertiesApi], list[str] | None] - """Function to get all options of the select entity or returns None if not supported.""" + """Get all options or return None if not supported.""" @dataclass(frozen=True, kw_only=True) @@ -115,6 +119,16 @@ B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [ options_lambda=lambda _: list(CleanTypeMapping.keys()), entity_category=EntityCategory.CONFIG, ), + RoborockB01SelectDescription( + key="cleaning_route", + translation_key="cleaning_route", + api_fn=lambda api, value: api.set_clean_path_preference( + CleanPathPreferenceMapping.from_value(value) + ), + value_fn=lambda data: data.clean_path_preference_name, + options_lambda=lambda _: list(CleanPathPreferenceMapping.keys()), + entity_category=EntityCategory.CONFIG, + ), ] @@ -266,6 +280,10 @@ async def async_setup_entry( for description in A01_SELECT_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) + async_add_entities( + RoborockQ10CleanModeSelectEntity(coordinator) + for coordinator in config_entry.runtime_data.b01_q10 + ) class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): @@ -308,7 +326,7 @@ class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): - """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + """A class to set options on a Roborock vacuum with fixed options.""" entity_description: RoborockSelectDescription @@ -466,3 +484,59 @@ class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity): self.entity_description.key, ) return str(current_value) + + +class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): + """Select entity for Q10 cleaning mode.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "cleaning_mode" + coordinator: RoborockB01Q10UpdateCoordinator + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + ) -> None: + """Create a select entity for Q10 cleaning mode.""" + super().__init__( + f"cleaning_mode_{coordinator.duid_slug}", + coordinator, + ) + + async def async_added_to_hass(self) -> None: + """Register trait listener for push-based status updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.api.status.add_update_listener(self.async_write_ha_state) + ) + + @property + def options(self) -> list[str]: + """Return available cleaning modes.""" + return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN] + + @property + def current_option(self) -> str | None: + """Get the current cleaning mode.""" + clean_mode = self.coordinator.api.status.clean_mode + if clean_mode is None or clean_mode == YXCleanType.UNKNOWN: + return None + return clean_mode.value + + async def async_select_option(self, option: str) -> None: + """Set the cleaning mode.""" + try: + mode = YXCleanType.from_value(option) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="select_option_failed", + ) from err + try: + await self.coordinator.api.vacuum.set_clean_mode(mode) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "cleaning_mode"}, + ) from err diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 467aa47bcb1..049f07cce22 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -1,7 +1,5 @@ """Support for Roborock sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import datetime @@ -257,6 +255,7 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( key="mop_clean_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.status.rdt, translation_key="mop_drying_remaining_time", @@ -537,7 +536,8 @@ async def async_setup_entry( ) for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS - # Note: Currently coordinator.data is always available on startup but won't be in the future + # Note: Currently coordinator.data is always available + # on startup but won't be in the future if ( coordinator.data is not None and description.value_fn(coordinator.data) is not None diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 2e688c64064..f301f3b50c4 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -10,6 +10,7 @@ "invalid_email": "There is no account associated with the email you entered, please try again.", "invalid_email_format": "There is an issue with the formatting of your email - please try again.", "invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.", + "invalid_url_format": "The URL must start with http:// or https:// and include a valid host.", "too_frequent_code_requests": "You have attempted to request too many codes. Try again later.", "unknown": "[%key:common::config_flow::error::unknown%]", "unknown_roborock": "There was an unknown Roborock exception - please check your logs.", @@ -25,6 +26,15 @@ }, "description": "Type the verification code sent to your email" }, + "custom_url": { + "data": { + "roborock_server_url": "Roborock Server URL" + }, + "data_description": { + "roborock_server_url": "The URL of the Roborock server." + }, + "description": "Enter the Roborock server URL to connect to." + }, "reauth_confirm": { "description": "The Roborock integration needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]" @@ -93,6 +103,12 @@ "reset_air_filter_consumable": { "name": "Reset air filter consumable" }, + "reset_dock_cleaning_brush_consumable": { + "name": "Reset cleaning brush consumable" + }, + "reset_dock_strainer_consumable": { + "name": "Reset strainer consumable" + }, "reset_main_brush_consumable": { "name": "Reset main brush consumable" }, @@ -123,6 +139,13 @@ "vacuum": "Vacuum only" } }, + "cleaning_route": { + "name": "Cleaning route", + "state": { + "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]", + "deep": "[%key:component::roborock::entity::select::mop_mode::state::deep%]" + } + }, "detergent_type": { "name": "Detergent type", "state": { @@ -751,6 +774,7 @@ "options": { "auto": "Auto", "cn": "CN", + "custom": "Manual", "eu": "EU", "ru": "RU", "us": "US" diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 27f901740ec..d6615538112 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -1,7 +1,5 @@ """Support for Roborock switch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -123,7 +121,7 @@ async def async_setup_entry( class RoborockSwitch(RoborockEntityV1, SwitchEntity): - """A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" + """A class to toggle Roborock device functionality with a coordinator.""" entity_description: RoborockSwitchDescription diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 39d224b5dad..2126b46cb0d 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -139,7 +139,7 @@ async def async_setup_entry( class RoborockTimeEntity(RoborockEntityV1, TimeEntity): - """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + """A class to set time options on a Roborock vacuum.""" entity_description: RoborockTimeDescription diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 68259aa15d7..9584bbe36a1 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -43,12 +43,12 @@ _LOGGER = logging.getLogger(__name__) STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" - RoborockStateCode.attaching_the_mop: VacuumActivity.DOCKED, # "Attaching the mop" - RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, # "Charger disconnected" + RoborockStateCode.attaching_the_mop: VacuumActivity.DOCKED, + RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, RoborockStateCode.idle: VacuumActivity.IDLE, # "Idle" - RoborockStateCode.remote_control_active: VacuumActivity.CLEANING, # "Remote control active" + RoborockStateCode.remote_control_active: VacuumActivity.CLEANING, RoborockStateCode.cleaning: VacuumActivity.CLEANING, # "Cleaning" - RoborockStateCode.detaching_the_mop: VacuumActivity.DOCKED, # "Detaching the mop" + RoborockStateCode.detaching_the_mop: VacuumActivity.DOCKED, RoborockStateCode.returning_home: VacuumActivity.RETURNING, # "Returning home" RoborockStateCode.manual_mode: VacuumActivity.CLEANING, # "Manual mode" RoborockStateCode.charging: VacuumActivity.DOCKED, # "Charging" @@ -62,9 +62,9 @@ STATE_CODE_TO_STATE = { RoborockStateCode.going_to_target: VacuumActivity.CLEANING, # "Going to target" RoborockStateCode.zoned_cleaning: VacuumActivity.CLEANING, # "Zoned cleaning" RoborockStateCode.segment_cleaning: VacuumActivity.CLEANING, # "Segment cleaning" - RoborockStateCode.emptying_the_bin: VacuumActivity.DOCKED, # "Emptying the bin" on s7+ - RoborockStateCode.washing_the_mop: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV - RoborockStateCode.going_to_wash_the_mop: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.emptying_the_bin: VacuumActivity.DOCKED, + RoborockStateCode.washing_the_mop: VacuumActivity.DOCKED, + RoborockStateCode.going_to_wash_the_mop: VacuumActivity.RETURNING, RoborockStateCode.charging_complete: VacuumActivity.DOCKED, # "Charging complete" RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } @@ -240,14 +240,16 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): translation_domain=DOMAIN, translation_key="update_options_failed", ) - await self.send( - RoborockCommand.SET_CUSTOM_MODE, - [ - {v: k for k, v in self._status_trait.fan_speed_mapping.items()}[ - fan_speed - ] - ], - ) + code_mapping = {v: k for k, v in self._status_trait.fan_speed_mapping.items()} + if (fan_speed_code := code_mapping.get(fan_speed)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) + await self.send(RoborockCommand.SET_CUSTOM_MODE, [fan_speed_code]) async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Send vacuum to a specific target point.""" @@ -458,9 +460,17 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity): async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set vacuum fan speed.""" try: - await self.coordinator.api.set_fan_speed( - SCWindMapping.from_value(fan_speed) - ) + fan_speed_code = SCWindMapping.from_value(fan_speed) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) from err + try: + await self.coordinator.api.set_fan_speed(fan_speed_code) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index 96eda4b5609..0238ea33ee5 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -1,7 +1,5 @@ """Rocket.Chat notification service.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any @@ -54,7 +52,8 @@ def get_service( _LOGGER.warning("Unable to connect to Rocket.Chat server at %s", url) except RocketAuthenticationException: _LOGGER.warning( - "Rocket.Chat authentication failed for user %s. Please check your username/password", + "Rocket.Chat authentication failed for user %s." + " Please check your username/password", username, ) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 06223acf450..f788a8c074e 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -1,7 +1,5 @@ """Support for Roku.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 31250898055..64540f60010 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Roku binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 80fcd0c8901..830d6ede87d 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -1,7 +1,5 @@ """Support for media browsing.""" -from __future__ import annotations - from collections.abc import Callable from functools import partial diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index b28648589c9..cf19608b8ac 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Roku.""" -from __future__ import annotations - import logging from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index e3c20d8351f..e3157889535 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Roku.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py index 86e7a7ac1c9..08fdb7bcd31 100644 --- a/homeassistant/components/roku/diagnostics.py +++ b/homeassistant/components/roku/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Roku.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index 1321e3806d1..27f156ce9b3 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -1,7 +1,5 @@ """Base Entity for Roku.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index ad8bee63b6f..671fd6cd2c6 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -1,7 +1,5 @@ """Helpers for Roku.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index fe74d69c90d..7431b649d8b 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -1,7 +1,5 @@ """Support for the Roku media player.""" -from __future__ import annotations - import datetime as dt import logging import mimetypes @@ -343,7 +341,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: - """Play media from a URL or file, launch an application, or tune to a channel.""" + """Play media from a URL or file, launch an app, or tune to a channel.""" extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} original_media_type: str = media_type original_media_id: str = media_id diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index cc3689c9df3..344c9f38709 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -1,7 +1,5 @@ """Support for the Roku remote.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 062e1258ea2..16a8496d6df 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -1,7 +1,5 @@ """Support for Roku selects.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index a61a9be6a73..7297e109c93 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -1,7 +1,5 @@ """Support for Roku sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/roku/services.py b/homeassistant/components/roku/services.py index 83ec9c0cbfb..15f6d97c28b 100644 --- a/homeassistant/components/roku/services.py +++ b/homeassistant/components/roku/services.py @@ -1,7 +1,5 @@ """Support for the Roku media player.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py index be227645122..a067100bc18 100644 --- a/homeassistant/components/romy/__init__.py +++ b/homeassistant/components/romy/__init__.py @@ -2,15 +2,14 @@ import romy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER, PLATFORMS -from .coordinator import RomyVacuumCoordinator +from .const import LOGGER, PLATFORMS +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: RomyConfigEntry) -> bool: """Initialize the ROMY platform via config entry.""" new_romy = await romy.create_romy( @@ -20,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = RomyVacuumCoordinator(hass, config_entry, new_romy) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -29,14 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RomyConfigEntry) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: RomyConfigEntry) -> None: """Handle options update.""" LOGGER.debug("update_listener") await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py index 599c0fe023e..f454efacdbc 100644 --- a/homeassistant/components/romy/binary_sensor.py +++ b/homeassistant/components/romy/binary_sensor.py @@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RomyVacuumCoordinator +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity BINARY_SENSORS: list[BinarySensorEntityDescription] = [ @@ -38,12 +36,12 @@ BINARY_SENSORS: list[BinarySensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RomyBinarySensor(coordinator, entity_description) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index 48558cd98c7..00f4c278948 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ROMY integration.""" -from __future__ import annotations - import romy import voluptuous as vol @@ -108,7 +106,9 @@ class RomyConfigFlow(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": f"{self.robot_name_given_by_user} ({self.host} / {unique_id})" + "name": ( + f"{self.robot_name_given_by_user} ({self.host} / {unique_id})" + ) }, "configuration_url": f"http://{self.host}:{new_discovered_romy.port}", } diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index de5352191d7..f72b388c3ca 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -8,14 +8,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +type RomyConfigEntry = ConfigEntry[RomyVacuumCoordinator] + class RomyVacuumCoordinator(DataUpdateCoordinator[None]): """ROMY Vacuum Coordinator.""" - config_entry: ConfigEntry + config_entry: RomyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, romy: RomyRobot + self, hass: HomeAssistant, config_entry: RomyConfigEntry, romy: RomyRobot ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index 85bf0df8f64..8318924c28a 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -6,7 +6,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -18,8 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RomyVacuumCoordinator +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity SENSORS: list[SensorEntityDescription] = [ @@ -76,12 +74,12 @@ SENSORS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RomySensor(coordinator, entity_description) diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 0e9dd13ffe1..e959ea32453 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -11,12 +11,11 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import RomyVacuumCoordinator +from .const import LOGGER +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity FAN_SPEED_NONE = "default" @@ -50,13 +49,11 @@ SUPPORT_ROMY_ROBOT = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([RomyVacuumEntity(coordinator)]) + async_add_entities([RomyVacuumEntity(config_entry.runtime_data)]) class RomyVacuumEntity(RomyEntity, StateVacuumEntity): diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index f811a2afe03..e8adc9d787a 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -9,7 +9,6 @@ from typing import Any from roombapy import Roomba, RoombaConnectionError, RoombaFactory from homeassistant import exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DELAY, CONF_HOST, @@ -19,13 +18,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import CONF_BLID, CONF_CONTINUOUS, DOMAIN, PLATFORMS, ROOMBA_SESSION -from .models import RoombaData +from .const import CONF_BLID, CONF_CONTINUOUS, PLATFORMS, ROOMBA_SESSION +from .models import RoombaConfigEntry, RoombaData _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> bool: """Set the config entry up.""" # Set up roomba platforms with config entry @@ -62,8 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba) ) - domain_data = RoombaData(roomba, config_entry.data[CONF_BLID]) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = domain_data + config_entry.runtime_data = RoombaData(roomba, config_entry.data[CONF_BLID]) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -108,20 +108,22 @@ async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> No await hass.async_add_executor_job(roomba.disconnect) -async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] - await async_disconnect_or_timeout(hass, roomba=domain_data.roomba) - hass.data[DOMAIN].pop(config_entry.entry_id) + await async_disconnect_or_timeout(hass, roomba=config_entry.runtime_data.roomba) return unload_ok diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index ba362914b6d..b4c5765f53a 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -1,23 +1,21 @@ """Roomba binary sensor entities.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import roomba_reported_state -from .const import DOMAIN from .entity import IRobotEntity -from .models import RoombaData +from .models import RoombaConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid status = roomba_reported_state(roomba).get("bin", {}) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index b7d259e3131..6e082b236ec 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure roomba component.""" -from __future__ import annotations - import asyncio from functools import partial from typing import Any @@ -11,12 +9,7 @@ from roombapy.discovery import RoombaDiscovery from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -31,6 +24,7 @@ from .const import ( DOMAIN, ROOMBA_SESSION, ) +from .models import RoombaConfigEntry ROOMBA_DISCOVERY_LOCK = "roomba_discovery_lock" ALL_ATTEMPTS = 2 @@ -90,7 +84,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, ) -> RoombaOptionsFlowHandler: """Get the options flow for this handler.""" return RoombaOptionsFlowHandler() @@ -340,7 +334,7 @@ def _async_get_roomba_discovery() -> RoombaDiscovery: @callback def _async_blid_from_hostname(hostname: str) -> str: """Extract the blid from the hostname.""" - return hostname.split("-")[1].split(".")[0].upper() + return hostname.split("-")[1].split(".", maxsplit=1)[0].upper() async def _async_discover_roombas( diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 71ebab3ae43..3f4559f2188 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -1,7 +1,5 @@ """Base class for iRobot devices.""" -from __future__ import annotations - from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +29,11 @@ class IRobotEntity(Entity): model=self.vacuum_state.get("sku"), name=str(self.vacuum_state.get("name")), sw_version=self.vacuum_state.get("softwareVer"), - hw_version=self.vacuum_state.get("hardwareRev"), + hw_version=( + str(hw_rev) + if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None + else None + ), ) if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 1ded2f6a9ce..787889cdcf7 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba and Braava", - "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Orhideous"], + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/roomba/models.py b/homeassistant/components/roomba/models.py index 350495cae7b..e98dfdd9d9d 100644 --- a/homeassistant/components/roomba/models.py +++ b/homeassistant/components/roomba/models.py @@ -1,11 +1,13 @@ """The roomba integration models.""" -from __future__ import annotations - from dataclasses import dataclass from roombapy import Roomba +from homeassistant.config_entries import ConfigEntry + +type RoombaConfigEntry = ConfigEntry[RoombaData] + @dataclass class RoombaData: diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 67c33698ff1..6aa05b8af30 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,15 +11,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN from .entity import IRobotEntity, roomba_reported_state -from .models import RoombaData +from .models import RoombaConfigEntry @dataclass(frozen=True, kw_only=True) @@ -142,11 +140,11 @@ SENSORS: list[RoombaSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 6abc1d52398..a7f7551b687 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -1,7 +1,5 @@ """Support for Wi-Fi enabled iRobot Roombas.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -12,16 +10,14 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state -from .const import DOMAIN from .entity import IRobotEntity -from .models import RoombaData +from .models import RoombaConfigEntry SUPPORT_IROBOT = ( VacuumEntityFeature.PAUSE @@ -87,11 +83,11 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid @@ -308,13 +304,13 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): else: _LOGGER.error("No such fan speed available: %s", fan_speed) return + # The set_preference method does only accept string values - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "carpetBoost", str(carpet_boost) - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "vacHigh", str(high_perf) - ) + def _set_fan_speed_preferences() -> None: + self.vacuum.set_preference("carpetBoost", str(carpet_boost)) + self.vacuum.set_preference("vacHigh", str(high_perf)) + + await self.hass.async_add_executor_job(_set_fan_speed_preferences) class BraavaJet(IRobotVacuum): @@ -358,6 +354,7 @@ class BraavaJet(IRobotVacuum): spray = int(split[1]) if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: behavior = behavior.capitalize() + # pylint: disable-next=home-assistant-action-swallowed-exception except IndexError: _LOGGER.error( "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", @@ -389,14 +386,14 @@ class BraavaJet(IRobotVacuum): overlap = OVERLAP_DEEP else: overlap = OVERLAP_EXTENDED - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "rankOverlap", overlap - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, - "padWetness", - {"disposable": spray, "reusable": spray}, - ) + + def _set_mop_preferences() -> None: + self.vacuum.set_preference("rankOverlap", overlap) + self.vacuum.set_preference( + "padWetness", {"disposable": spray, "reusable": spray} + ) + + await self.hass.async_add_executor_job(_set_mop_preferences) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index 462437df449..4b5226bf260 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -10,6 +10,8 @@ from .const import CONF_ROON_NAME, DOMAIN from .server import RoonServer from .services import async_setup_services +type RoonConfigEntry = ConfigEntry[RoonServer] + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] @@ -20,10 +22,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RoonConfigEntry) -> bool: """Set up a roonserver from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - # fallback to using host for compatibility with older configs name = entry.data.get(CONF_ROON_NAME, entry.data[CONF_HOST]) @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await roonserver.async_setup(): return False - hass.data[DOMAIN][entry.entry_id] = roonserver + entry.runtime_data = roonserver device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -47,10 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RoonConfigEntry) -> bool: """Unload a config entry.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - roonserver = hass.data[DOMAIN].pop(entry.entry_id) - return await roonserver.async_reset() + return await entry.runtime_data.async_reset() diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index 3421cbf646c..d6919c04e23 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -76,7 +76,9 @@ class RoonHub: apis = [RoonApi(ROON_APPINFO, None, host, port, blocking_init=False)] while secs <= TIMEOUT: - # Roon can discover multiple devices - not all of which are proper servers, so try and authenticate with them all. + # Roon can discover multiple devices - not all of + # which are proper servers, so try and + # authenticate with them all. # The user will only enable one - so look for a valid token auth_api = [api for api in apis if api.token is not None] diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index b2a491c8d28..c18a67613b5 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -4,12 +4,12 @@ import logging from typing import cast from homeassistant.components.event import EventDeviceClass, EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import RoonConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -17,11 +17,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon Event from Config Entry.""" - roon_server = hass.data[DOMAIN][config_entry.entry_id] + roon_server = config_entry.runtime_data event_entities = set() @callback diff --git a/homeassistant/components/roon/media_browser.py b/homeassistant/components/roon/media_browser.py index 13b2d9594e8..c897c6bc1cf 100644 --- a/homeassistant/components/roon/media_browser.py +++ b/homeassistant/components/roon/media_browser.py @@ -103,7 +103,8 @@ def library_payload(roon_server, zone_id, media_content_id): "count": ITEM_LIMIT, } - # Roon starts browsing for a zone where it left off - so start from the top unless otherwise specified + # Roon starts browsing for a zone where it left off + # so start from the top unless otherwise specified if media_content_id is None or media_content_id == "Explore": opts["pop_all"] = True content_id = "Explore" diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 804fb0244b5..a325d8b0ca2 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -1,7 +1,5 @@ """MediaPlayer platform for Roon integration.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -15,7 +13,6 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -27,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import convert from homeassistant.util.dt import utcnow +from . import RoonConfigEntry from .const import DOMAIN from .media_browser import browse_media @@ -45,11 +43,11 @@ REPEAT_MODE_MAPPING_TO_ROON = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon MediaPlayer from Config Entry.""" - roon_server = hass.data[DOMAIN][config_entry.entry_id] + roon_server = config_entry.runtime_data media_players = set() @callback @@ -209,7 +207,7 @@ class RoonDevice(MediaPlayerEntity): return volume def _parse_now_playing(self, player_data): - """Parse now playing data to determine title, artist, position, duration and artwork.""" + """Parse now playing data for title, artist, position, etc.""" now_playing = { "title": None, "artist": None, diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index 3f2e541b125..dc79a165483 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -170,6 +170,7 @@ class RoonServer: new_dict["zone_name"] = zone["display_name"] new_dict["display_name"] = output["display_name"] new_dict["last_changed"] = utcnow() - # we don't use the zone_id or output_id for now as unique id as I've seen cases were it changes for some reason + # we don't use the zone_id or output_id for now as + # unique id as I've seen cases where it changes new_dict["dev_id"] = f"roon_{self.roon_id}_{output['display_name']}" return new_dict diff --git a/homeassistant/components/roon/services.py b/homeassistant/components/roon/services.py index 28167d94918..885e99d5040 100644 --- a/homeassistant/components/roon/services.py +++ b/homeassistant/components/roon/services.py @@ -1,7 +1,5 @@ """MediaPlayer platform for Roon integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 2c9824d0628..68d5eb736a4 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -1,7 +1,5 @@ """Update the IP addresses of your Route53 DNS records.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py index 1cbeeab4c4e..2d436f3c978 100644 --- a/homeassistant/components/route_b_smart_meter/config_flow.py +++ b/homeassistant/components/route_b_smart_meter/config_flow.py @@ -4,11 +4,13 @@ import logging from typing import Any from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol -from homeassistant.components.usb import get_serial_by_id, human_readable_device_name +from homeassistant.components.usb import ( + USBDevice, + async_scan_serial_ports, + human_readable_device_name, +) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD from homeassistant.core import callback @@ -25,14 +27,14 @@ def _validate_input(device: str, id: str, password: str) -> None: pass -def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str: +def _human_readable_device_name(port: UsbServiceInfo | USBDevice) -> str: return human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - str(port.vid) if port.vid else None, - str(port.pid) if port.pid else None, + port.vid, + port.pid, ) @@ -45,11 +47,9 @@ class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _get_discovered_device_id_and_name( - self, device_options: dict[str, ListPortInfo] + self, device_options: dict[str, USBDevice] ) -> tuple[str | None, str | None]: - discovered_device_id = ( - get_serial_by_id(self.device.device) if self.device else None - ) + discovered_device_id = self.device.device if self.device else None discovered_device = ( device_options.get(discovered_device_id) if discovered_device_id else None ) @@ -60,10 +60,10 @@ class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): ) return discovered_device_id, discovered_device_name - async def _get_usb_devices(self) -> dict[str, ListPortInfo]: + async def _get_usb_devices(self) -> dict[str, USBDevice]: """Return a list of available USB devices.""" - devices = await self.hass.async_add_executor_job(comports) - return {get_serial_by_id(port.device): port for port in devices} + devices = await async_scan_serial_ports(self.hass) + return {port.device: port for port in devices if isinstance(port, USBDevice)} async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json index 6364dbb18d4..36ff3ed6a20 100644 --- a/homeassistant/components/route_b_smart_meter/manifest.json +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -13,5 +13,5 @@ "momonga.sk_wrapper_logger" ], "quality_scale": "bronze", - "requirements": ["pyserial==3.5", "momonga==0.3.0"] + "requirements": ["momonga==0.3.0"] } diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py index ecde0578772..caee5713a43 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -1,23 +1,20 @@ """The rova component.""" -from __future__ import annotations - from requests.exceptions import ConnectTimeout, HTTPError from rova.rova import Rova -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX, CONF_ZIP_CODE, DOMAIN -from .coordinator import RovaCoordinator +from .coordinator import RovaConfigEntry, RovaCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RovaConfigEntry) -> bool: """Set up ROVA from a config entry.""" api = Rova( @@ -50,15 +47,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RovaConfigEntry) -> bool: """Unload ROVA config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index a48048d32c3..4240d4f3a46 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -11,16 +11,18 @@ from homeassistant.util.dt import get_time_zone from .const import DOMAIN, LOGGER +type RovaConfigEntry = ConfigEntry[RovaCoordinator] + EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" - config_entry: ConfigEntry + config_entry: RovaConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: Rova + self, hass: HomeAssistant, config_entry: RovaConfigEntry, api: Rova ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 59f9f28f8f5..4fa1d03d331 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -1,7 +1,5 @@ """Support for Rova garbage calendar.""" -from __future__ import annotations - from datetime import datetime from homeassistant.components.sensor import ( @@ -9,14 +7,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RovaCoordinator +from .coordinator import RovaConfigEntry, RovaCoordinator ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=rova"} @@ -42,11 +39,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RovaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Rova entry.""" - coordinator: RovaCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data assert entry.unique_id unique_id = entry.unique_id diff --git a/homeassistant/components/rpi_power/binary_sensor.py b/homeassistant/components/rpi_power/binary_sensor.py index 1424148f554..02f5d84fb7d 100644 --- a/homeassistant/components/rpi_power/binary_sensor.py +++ b/homeassistant/components/rpi_power/binary_sensor.py @@ -1,4 +1,4 @@ -"""A sensor platform which detects underruns and capped status from the official Raspberry Pi Kernel. +"""Detect underruns and capped status from the Raspberry Pi Kernel. Minimal Kernel needed is 4.14+ """ diff --git a/homeassistant/components/rpi_power/config_flow.py b/homeassistant/components/rpi_power/config_flow.py index 0151a92856d..5e264086b17 100644 --- a/homeassistant/components/rpi_power/config_flow.py +++ b/homeassistant/components/rpi_power/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Raspberry Pi Power Supply Checker.""" -from __future__ import annotations - from collections.abc import Awaitable from typing import Any diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 98d0e1bf790..67c283fef5f 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -1,7 +1,5 @@ """Support to export sensor values via RSS feed.""" -from __future__ import annotations - from html import escape from aiohttp import web @@ -81,7 +79,8 @@ class RssView(HomeAssistantView): response += '\n' response += " \n" if self._title is not None: - response += f" {escape(self._title.async_render(parse_result=False))}\n" + rendered = escape(self._title.async_render(parse_result=False)) + response += f" {rendered}\n" else: response += " Home Assistant\n" diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 367542ca8c2..8a7d0460718 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the rtorrent BitTorrent client API.""" -from __future__ import annotations - import logging from typing import cast import xmlrpc.client diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 8e9219985ce..6aad1cf3734 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -5,7 +5,6 @@ import logging from aioruckus import AjaxSession from aioruckus.exceptions import AuthenticationError, SchemaError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -18,18 +17,18 @@ from .const import ( API_AP_MODEL, API_SYS_SYSINFO, API_SYS_SYSINFO_VERSION, - COORDINATOR, DOMAIN, MANUFACTURER, PLATFORMS, - UNDO_UPDATE_LISTENERS, ) -from .coordinator import RuckusDataUpdateCoordinator +from .coordinator import RuckusDataUpdateCoordinator, RuckusUnleashedConfigEntry _LOGGER = logging.getLogger(__package__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RuckusUnleashedConfigEntry +) -> bool: """Set up Ruckus from a config entry.""" ruckus = AjaxSession.async_create( @@ -50,10 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - system_info = await ruckus.api.get_system_info() + try: + system_info = await ruckus.api.get_system_info() + aps = await ruckus.api.get_aps() + except (ConnectionError, SchemaError) as err: + await ruckus.close() + raise ConfigEntryNotReady from err registry = dr.async_get(hass) - aps = await ruckus.api.get_aps() for access_point in aps: _LOGGER.debug("AP [%s] %s", access_point[API_AP_MAC], entry.entry_id) registry.async_get_or_create( @@ -69,25 +72,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - UNDO_UPDATE_LISTENERS: [], - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RuckusUnleashedConfigEntry +) -> bool: """Unload a config entry.""" - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: - listener() - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 0743b19bdaf..56d0d1e8d22 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -2,22 +2,34 @@ from collections.abc import Mapping import logging +import operator from typing import Any from aioruckus import AjaxSession, SystemStat from aioruckus.exceptions import AuthenticationError, SchemaError import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import ( + API_CLIENT_HOSTNAME, API_MESH_NAME, API_SYS_SYSINFO, API_SYS_SYSINFO_SERIAL, + CONF_MAC_FILTER, DOMAIN, + KEY_SYS_CLIENTS, KEY_SYS_SERIAL, KEY_SYS_TITLE, ) @@ -63,6 +75,15 @@ class RuckusConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ruckus.""" VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> RuckusOptionsFlowHandler: + """Get the options flow for this handler.""" + return RuckusOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -86,12 +107,10 @@ class RuckusConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=info[KEY_SYS_TITLE], data=user_input ) - reauth_entry = self._get_reauth_entry() - if info[KEY_SYS_SERIAL] == reauth_entry.unique_id: - return self.async_update_reload_and_abort( - reauth_entry, data=user_input - ) - errors["base"] = "invalid_host" + self._abort_if_unique_id_mismatch(reason="invalid_host") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=user_input + ) data_schema = DATA_SCHEMA if self.source == SOURCE_REAUTH: @@ -109,6 +128,59 @@ class RuckusConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() +class RuckusOptionsFlowHandler(OptionsFlowWithReload): + """Handle Ruckus options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + new_filter: list[str] = user_input.get(CONF_MAC_FILTER, []) + + # Remove entities for devices no longer in the allow-list + if new_filter: + entity_registry = er.async_get(self.hass) + for reg_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + if ( + reg_entry.domain == DEVICE_TRACKER_DOMAIN + and reg_entry.unique_id not in new_filter + ): + entity_registry.async_remove(reg_entry.entity_id) + + return self.async_create_entry(data={CONF_MAC_FILTER: new_filter}) + + coordinator = self.config_entry.runtime_data + current_filter: list[str] = self.config_entry.options.get(CONF_MAC_FILTER, []) + + # Build client dict from active clients + clients: dict[str, str] = { + mac: f"{client[API_CLIENT_HOSTNAME]} ({mac})" + for mac, client in coordinator.data[KEY_SYS_CLIENTS].items() + } + + # Preserve previously selected but now-offline clients + clients |= { + mac: f"Unknown ({mac})" for mac in current_filter if mac not in clients + } + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_MAC_FILTER, + default=current_filter, + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), + } + ), + ) + + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/ruckus_unleashed/const.py b/homeassistant/components/ruckus_unleashed/const.py index 1aae3041e73..7262792b96f 100644 --- a/homeassistant/components/ruckus_unleashed/const.py +++ b/homeassistant/components/ruckus_unleashed/const.py @@ -6,6 +6,8 @@ DOMAIN = "ruckus_unleashed" PLATFORMS = [Platform.DEVICE_TRACKER] SCAN_INTERVAL = 30 +CONF_MAC_FILTER = "mac_filter" + MANUFACTURER = "Ruckus" COORDINATOR = "coordinator" diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 7ffaab2e977..860d035bed6 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -13,16 +13,21 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL +type RuckusUnleashedConfigEntry = ConfigEntry[RuckusDataUpdateCoordinator] + _LOGGER = logging.getLogger(__package__) class RuckusDataUpdateCoordinator(DataUpdateCoordinator): """Coordinator to manage data from Ruckus client.""" - config_entry: ConfigEntry + config_entry: RuckusUnleashedConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ruckus: AjaxSession + self, + hass: HomeAssistant, + config_entry: RuckusUnleashedConfigEntry, + ruckus: AjaxSession, ) -> None: """Initialize global Ruckus data updater.""" self.ruckus = ruckus @@ -41,6 +46,11 @@ class RuckusDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.debug("fetched %d active clients", len(clients)) return {client[API_CLIENT_MAC]: client for client in clients} + async def async_shutdown(self) -> None: + """Close the Ruckus session on shutdown.""" + await super().async_shutdown() + await self.ruckus.close() + async def _async_update_data(self) -> dict: """Fetch Ruckus data.""" try: diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 890148ec25c..3400f479557 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -1,56 +1,48 @@ """Support for Ruckus devices.""" -from __future__ import annotations - import logging from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - API_CLIENT_HOSTNAME, - API_CLIENT_IP, - COORDINATOR, - DOMAIN, - KEY_SYS_CLIENTS, - UNDO_UPDATE_LISTENERS, -) -from .coordinator import RuckusDataUpdateCoordinator +from .const import API_CLIENT_HOSTNAME, API_CLIENT_IP, CONF_MAC_FILTER, KEY_SYS_CLIENTS +from .coordinator import RuckusDataUpdateCoordinator, RuckusUnleashedConfigEntry _LOGGER = logging.getLogger(__package__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RuckusUnleashedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Ruckus component.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator = entry.runtime_data tracked: set[str] = set() + mac_filter: set[str] = set(entry.options.get(CONF_MAC_FILTER, [])) + @callback def router_update(): """Update the values of the router.""" - add_new_entities(coordinator, async_add_entities, tracked) + add_new_entities(coordinator, async_add_entities, tracked, mac_filter) router_update() - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS].append( - coordinator.async_add_listener(router_update) - ) + entry.async_on_unload(coordinator.async_add_listener(router_update)) registry = er.async_get(hass) - restore_entities(registry, coordinator, entry, async_add_entities, tracked) + restore_entities( + registry, coordinator, entry, async_add_entities, tracked, mac_filter + ) @callback -def add_new_entities(coordinator, async_add_entities, tracked): +def add_new_entities(coordinator, async_add_entities, tracked, mac_filter): """Add new tracker entities from the router.""" new_tracked = [] @@ -58,6 +50,9 @@ def add_new_entities(coordinator, async_add_entities, tracked): if mac in tracked: continue + if mac_filter and mac not in mac_filter: + continue + device = coordinator.data[KEY_SYS_CLIENTS][mac] _LOGGER.debug("adding new device: [%s] %s", mac, device[API_CLIENT_HOSTNAME]) new_tracked.append(RuckusDevice(coordinator, mac, device[API_CLIENT_HOSTNAME])) @@ -70,17 +65,19 @@ def add_new_entities(coordinator, async_add_entities, tracked): def restore_entities( registry: er.EntityRegistry, coordinator: RuckusDataUpdateCoordinator, - entry: ConfigEntry, + entry: RuckusUnleashedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], + mac_filter: set[str], ) -> None: """Restore clients that are not a part of active clients list.""" missing: list[RuckusDevice] = [] for entity in registry.entities.get_entries_for_config_entry_id(entry.entry_id): if ( - entity.platform == DOMAIN + entity.platform == entry.domain and entity.unique_id not in coordinator.data[KEY_SYS_CLIENTS] + and (not mac_filter or entity.unique_id in mac_filter) ): missing.append( RuckusDevice(coordinator, entity.unique_id, entity.original_name) diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 068c8610dfc..29b9e8278f0 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -2,12 +2,12 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -22,5 +22,17 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "mac_filter": "Clients to track" + }, + "data_description": { + "mac_filter": "Select specific clients to track. If none are selected, all clients will be tracked." + } + } + } } } diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index ddaa83632df..e328372f242 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -2,34 +2,45 @@ import logging -from aiorussound import RussoundClient, RussoundTcpConnectionHandler -from aiorussound.models import CallbackType +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.connection import ( + RussoundConnectionHandler, + RussoundSerialConnectionHandler, +) +from aiorussound.rio import RussoundRIOClient +from aiorussound.rio.models import CallbackType from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import CONF_BAUDRATE, DOMAIN, RUSSOUND_RIO_EXCEPTIONS, TYPE_TCP -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SWITCH] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -type RussoundConfigEntry = ConfigEntry[RussoundClient] +type RussoundConfigEntry = ConfigEntry[RussoundRIOClient] async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool: """Set up a config entry.""" - - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + handler: RussoundConnectionHandler + if entry.data[CONF_TYPE] == TYPE_TCP: + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + handler = RussoundTcpConnectionHandler(host, port) + else: + device = entry.data[CONF_DEVICE] + baudrate = entry.data[CONF_BAUDRATE] + handler = RussoundSerialConnectionHandler(device, baudrate) + client = RussoundRIOClient(handler) async def _connection_update_callback( - _client: RussoundClient, _callback_type: CallbackType + _client: RussoundRIOClient, _callback_type: CallbackType ) -> None: """Call when the device is notified of changes.""" if _callback_type == CallbackType.CONNECTION: @@ -48,8 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> translation_domain=DOMAIN, translation_key="entry_cannot_connect", translation_placeholders={ - "host": host, - "port": port, + "host": host or device, + "port": port or baudrate, }, ) from err entry.runtime_data = client @@ -98,3 +109,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> await entry.runtime_data.disconnect() return unload_ok + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: RussoundConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + ( + hass.config_entries.async_update_entry( + config_entry, + data={ + CONF_TYPE: TYPE_TCP, + **config_entry.data, + }, + version=2, + ), + ) + + _LOGGER.debug( + "Migration to configuration version %s successful", config_entry.version + ) + + return True diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index edf542b5de2..605f6f6df29 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -1,11 +1,15 @@ """Config flow to configure russound_rio component.""" -from __future__ import annotations - +from contextlib import suppress import logging from typing import Any -from aiorussound import RussoundClient, RussoundTcpConnectionHandler +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.connection import ( + RussoundConnectionHandler, + RussoundSerialConnectionHandler, +) +from aiorussound.rio import Controller, RussoundRIOClient import voluptuous as vol from homeassistant.config_entries import ( @@ -13,31 +17,104 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SerialPortSelector, +) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import ( + CONF_BAUDRATE, + DEFAULT_BAUDRATE, + DEFAULT_PORT, + DOMAIN, + RUSSOUND_RIO_EXCEPTIONS, + TYPE_SERIAL, + TYPE_TCP, +) -DATA_SCHEMA = vol.Schema( +TRANSPORT_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=9621): cv.port, + vol.Required(CONF_TYPE, default=TYPE_TCP): SelectSelector( + SelectSelectorConfig( + options=[TYPE_TCP, TYPE_SERIAL], + translation_key="connection_type", + ) + ), } ) +TCP_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + +SERIAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE): SerialPortSelector(), + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + } +) + + _LOGGER = logging.getLogger(__name__) +async def _async_validate_connection( + connection_handler: RussoundConnectionHandler, +) -> Controller | None: + """Validate a Russound connection and return the controller.""" + client = RussoundRIOClient(connection_handler) + try: + await client.connect() + controller = client.controllers[1] + except RUSSOUND_RIO_EXCEPTIONS: + return None + finally: + with suppress(*RUSSOUND_RIO_EXCEPTIONS): + await client.disconnect() + return controller + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Russound RIO configuration flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" self.data: dict[str, Any] = {} + async def _async_finish_manual_setup( + self, controller: Controller, data: dict[str, Any] + ) -> ConfigFlowResult: + """Finish manual setup or reconfigure after validation.""" + await self.async_set_unique_id( + controller.mac_address, + raise_on_progress=False, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + entry = self._get_reconfigure_entry() + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=controller.controller_type, + data=data, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -45,16 +122,16 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.data[CONF_HOST] = host = discovery_info.host self.data[CONF_PORT] = port = discovery_info.port or 9621 - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) - try: - await client.connect() - controller = client.controllers[1] - await client.disconnect() - except RUSSOUND_RIO_EXCEPTIONS: + controller = await _async_validate_connection( + RussoundTcpConnectionHandler(host, port) + ) + if not controller: return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(controller.mac_address) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._abort_if_unique_id_configured( + updates={CONF_TYPE: TYPE_TCP, CONF_HOST: host, CONF_PORT: port} + ) self.data[CONF_NAME] = controller.controller_type @@ -70,7 +147,11 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=self.data[CONF_NAME], - data={CONF_HOST: self.data[CONF_HOST], CONF_PORT: self.data[CONF_PORT]}, + data={ + CONF_TYPE: TYPE_TCP, + CONF_HOST: self.data[CONF_HOST], + CONF_PORT: self.data[CONF_PORT], + }, ) self._set_confirm_only() @@ -84,47 +165,71 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" + """Handle a flow initiated by the user.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=TRANSPORT_SCHEMA, + ) + + self.data[CONF_TYPE] = user_input[CONF_TYPE] + if user_input[CONF_TYPE] == TYPE_TCP: + return await self.async_step_tcp() + return await self.async_step_serial() + + async def async_step_tcp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle TCP configuration.""" errors: dict[str, str] = {} if user_input is not None: host = user_input[CONF_HOST] port = user_input[CONF_PORT] - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) - try: - await client.connect() - controller = client.controllers[1] - await client.disconnect() - except RUSSOUND_RIO_EXCEPTIONS: - _LOGGER.exception("Could not connect to Russound RIO") + controller = await _async_validate_connection( + RussoundTcpConnectionHandler(host, port) + ) + if controller is None: + _LOGGER.exception("Could not connect to Russound RIO over TCP") errors["base"] = "cannot_connect" else: - await self.async_set_unique_id( - controller.mac_address, raise_on_progress=False - ) - if self.source == SOURCE_RECONFIGURE: - self._abort_if_unique_id_mismatch(reason="wrong_device") - return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), - data_updates=user_input, - ) - self._abort_if_unique_id_configured() - data = {CONF_HOST: host, CONF_PORT: port} - return self.async_create_entry( - title=controller.controller_type, data=data - ) + data = {CONF_TYPE: TYPE_TCP, CONF_HOST: host, CONF_PORT: port} + return await self._async_finish_manual_setup(controller, data) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="tcp", data_schema=TCP_SCHEMA, errors=errors + ) + + async def async_step_serial( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle serial configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + device = user_input[CONF_DEVICE] + baudrate = user_input[CONF_BAUDRATE] + + controller = await _async_validate_connection( + RussoundSerialConnectionHandler(device, baudrate) + ) + if controller is None: + _LOGGER.exception("Could not connect to Russound RIO over serial") + errors["base"] = "cannot_connect" + else: + data = { + CONF_TYPE: TYPE_SERIAL, + CONF_DEVICE: device, + CONF_BAUDRATE: baudrate, + } + return await self._async_finish_manual_setup(controller, data) + + return self.async_show_form( + step_id="serial", data_schema=SERIAL_SCHEMA, errors=errors ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" - if not user_input: - return self.async_show_form( - step_id="reconfigure", - data_schema=DATA_SCHEMA, - ) return await self.async_step_user(user_input) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 7a8c0bb4fbc..cbe875a524d 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -16,3 +16,9 @@ RUSSOUND_RIO_EXCEPTIONS = ( TimeoutError, asyncio.CancelledError, ) + +CONF_BAUDRATE = "baudrate" +TYPE_TCP = "tcp" +TYPE_SERIAL = "serial" +DEFAULT_BAUDRATE = 19200 +DEFAULT_PORT = 9621 diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 1fe6a7876d1..3a5a6051250 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,9 +4,9 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller, RussoundClient -from aiorussound.models import CallbackType -from aiorussound.rio import ZoneControlSurface +from aiorussound.rio import RussoundRIOClient +from aiorussound.rio.client import Controller, ZoneControlSurface +from aiorussound.rio.models import CallbackType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -82,7 +82,7 @@ class RussoundBaseEntity(Entity): return self._controller.zones[self._zone_id] async def _state_update_callback( - self, _client: RussoundClient, _callback_type: CallbackType + self, _client: RussoundRIOClient, _callback_type: CallbackType ) -> None: """Call when the device is notified of changes.""" if _callback_type == CallbackType.CONNECTION: diff --git a/homeassistant/components/russound_rio/icons.json b/homeassistant/components/russound_rio/icons.json index 7d4ddc4cf98..e7cf42dc584 100644 --- a/homeassistant/components/russound_rio/icons.json +++ b/homeassistant/components/russound_rio/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "party_mode": { + "default": "mdi:party-popper" + } + }, "switch": { "loudness": { "default": "mdi:volume-high", diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 4b55b542a72..64cf366ca6e 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -1,6 +1,7 @@ { "domain": "russound_rio", "name": "Russound RIO", + "after_dependencies": ["usb"], "codeowners": ["@noahhusby"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/russound_rio", @@ -8,6 +9,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.10.0"], + "requirements": ["aiorussound==5.0.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/media_browser.py b/homeassistant/components/russound_rio/media_browser.py index 49cd8dae9c4..a174b7319f1 100644 --- a/homeassistant/components/russound_rio/media_browser.py +++ b/homeassistant/components/russound_rio/media_browser.py @@ -1,7 +1,7 @@ """Support for Russound media browsing.""" -from aiorussound import RussoundClient, Zone from aiorussound.const import FeatureFlag +from aiorussound.rio import RussoundRIOClient, Zone from aiorussound.util import is_feature_supported from homeassistant.components.media_player import BrowseMedia, MediaClass @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant async def async_browse_media( hass: HomeAssistant, - client: RussoundClient, + client: RussoundRIOClient, media_content_id: str | None, media_content_type: str | None, zone: Zone, @@ -80,7 +80,7 @@ async def _presets_payload(presets_by_zone: dict[int, dict[int, str]]) -> Browse def _find_presets_by_zone( - client: RussoundClient, zone: Zone + client: RussoundRIOClient, zone: Zone ) -> dict[int, dict[int, str]]: """Returns a dict by {source_id: {preset_id: preset_name}}.""" assert client.rio_version diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index a09c663a983..7abc5c050b1 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -1,15 +1,13 @@ """Support for Russound multizone controllers using RIO Protocol.""" -from __future__ import annotations - import asyncio import datetime as dt import logging from typing import TYPE_CHECKING, Any -from aiorussound import Controller from aiorussound.const import FeatureFlag -from aiorussound.models import PlayStatus, Source +from aiorussound.rio import Controller, Source +from aiorussound.rio.models import PlayStatus from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( diff --git a/homeassistant/components/russound_rio/number.py b/homeassistant/components/russound_rio/number.py index ae13815fa0a..4027a49964b 100644 --- a/homeassistant/components/russound_rio/number.py +++ b/homeassistant/components/russound_rio/number.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from aiorussound.rio import Controller, ZoneControlSurface +from aiorussound.rio.client import Controller, ZoneControlSurface from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/russound_rio/select.py b/homeassistant/components/russound_rio/select.py new file mode 100644 index 00000000000..486a0cd06f7 --- /dev/null +++ b/homeassistant/components/russound_rio/select.py @@ -0,0 +1,85 @@ +"""Support for Russound RIO select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from aiorussound.rio.client import Controller, ZoneControlSurface +from aiorussound.rio.models import PartyMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RussoundConfigEntry +from .entity import RussoundBaseEntity, command + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RussoundZoneSelectEntityDescription(SelectEntityDescription): + """Describes Russound RIO select entity.""" + + value_fn: Callable[[ZoneControlSurface], str | None] + set_value_fn: Callable[[ZoneControlSurface, str], Awaitable[None]] + + +CONTROL_ENTITIES: tuple[RussoundZoneSelectEntityDescription, ...] = ( + RussoundZoneSelectEntityDescription( + key="party_mode", + translation_key="party_mode", + options=[ + PartyMode.OFF.value.lower(), + PartyMode.ON.value.lower(), + PartyMode.MASTER.value.lower(), + ], + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.party_mode.lower() if zone.party_mode else None, + set_value_fn=lambda zone, value: zone.set_party_mode(PartyMode(value.upper())), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RussoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Russound RIO select entities based on a config entry.""" + client = entry.runtime_data + async_add_entities( + RussoundSelectEntity(controller, zone_id, description) + for controller in client.controllers.values() + for zone_id in controller.zones + for description in CONTROL_ENTITIES + ) + + +class RussoundSelectEntity(RussoundBaseEntity, SelectEntity): + """Defines a Russound RIO select entity.""" + + entity_description: RussoundZoneSelectEntityDescription + + def __init__( + self, + controller: Controller, + zone_id: int, + description: RussoundZoneSelectEntityDescription, + ) -> None: + """Initialize Russound RIO select.""" + super().__init__(controller, zone_id) + self.entity_description = description + self._attr_unique_id = ( + f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" + ) + + @property + def current_option(self) -> str | None: + """Return the state of the select.""" + return self.entity_description.value_fn(self._zone) + + @command + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_value_fn(self._zone, option) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index 7fdc3cdc7af..c2910e51e1a 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -22,12 +22,22 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "[%key:component::russound_rio::config::step::user::data_description::host%]", - "port": "[%key:component::russound_rio::config::step::user::data_description::port%]" + "host": "[%key:component::russound_rio::config::step::tcp::data_description::host%]", + "port": "[%key:component::russound_rio::config::step::tcp::data_description::port%]" }, "description": "Reconfigure your Russound controller." }, - "user": { + "serial": { + "data": { + "baudrate": "Baud rate", + "device": "Device" + }, + "data_description": { + "baudrate": "The communication speed of the serial connection.", + "device": "Choose the serial port connected to your device." + } + }, + "tcp": { "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]", @@ -37,6 +47,15 @@ "host": "The IP address of the Russound controller.", "port": "The port of the Russound controller." } + }, + "user": { + "data": { + "type": "Connection type" + }, + "data_description": { + "type": "Select how your Russound controller is connected." + }, + "description": "Choose how your controller is connected. All Russound RIO devices support connection over TCP/IP. Some older controllers can connected using USB-to-serial controllers for stability if the serial port has been configured for Russound RIO." } } }, @@ -55,6 +74,16 @@ "name": "Turn-on volume" } }, + "select": { + "party_mode": { + "name": "Party mode", + "state": { + "master": "Leader", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } + }, "switch": { "loudness": { "name": "Loudness" @@ -77,5 +106,13 @@ "unsupported_media_type": { "message": "Unsupported media type for Russound zone: {media_type}" } + }, + "selector": { + "connection_type": { + "options": { + "serial": "Serial/USB", + "tcp": "TCP/IP" + } + } } } diff --git a/homeassistant/components/russound_rio/switch.py b/homeassistant/components/russound_rio/switch.py index 20ee82ebb5b..7e545d4d7bc 100644 --- a/homeassistant/components/russound_rio/switch.py +++ b/homeassistant/components/russound_rio/switch.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from aiorussound.rio import Controller, ZoneControlSurface +from aiorussound.rio.client import Controller, ZoneControlSurface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 58925b4b1ff..53fb8d46713 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@noahhusby"], "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", - "loggers": ["russound"], + "loggers": ["aiorussound"], "quality_scale": "legacy", - "requirements": ["russound==0.2.0"] + "requirements": ["aiorussound==5.0.1"] } diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index 48808930d9f..2ea85324018 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -1,11 +1,15 @@ """Support for interfacing with Russound via RNET Protocol.""" -from __future__ import annotations - +import asyncio +from collections.abc import Callable, Coroutine +import contextlib import logging import math +from typing import Any -from russound import russound +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.exceptions import CommandError +from aiorussound.rnet.client import RussoundRNETClient import voluptuous as vol from homeassistant.components.media_player import ( @@ -14,8 +18,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -25,6 +35,13 @@ _LOGGER = logging.getLogger(__name__) CONF_ZONES = "zones" CONF_SOURCES = "sources" +RNET_EXCEPTIONS = ( + CommandError, + ConnectionRefusedError, + TimeoutError, + asyncio.IncompleteReadError, + OSError, +) ZONE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) @@ -40,33 +57,45 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( } ) +# Max volume level on RNET devices +_MAX_VOLUME = 50 -def setup_platform( + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Russound RNET platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + host = config[CONF_HOST] + port = config[CONF_PORT] - if host is None or port is None: - _LOGGER.error("Invalid config. Expected %s and %s", CONF_HOST, CONF_PORT) - return + client = RussoundRNETClient(RussoundTcpConnectionHandler(host, port)) + try: + await client.connect() + except RNET_EXCEPTIONS as err: + raise PlatformNotReady( + f"Could not connect to Russound RNET at {host}:{port}" + ) from err - russ = russound.Russound(host, port) - russ.connect() + sources = [source[CONF_NAME] for source in config[CONF_SOURCES]] + lock = asyncio.Lock() - sources = [source["name"] for source in config[CONF_SOURCES]] + async def _async_disconnect(*_: Any) -> None: + """Disconnect the RNET client on HA shutdown.""" + with contextlib.suppress(*RNET_EXCEPTIONS): + await client.disconnect() - if russ.is_connected(): - for zone_id, extra in config[CONF_ZONES].items(): - add_entities( - [RussoundRNETDevice(hass, russ, sources, zone_id, extra)], True - ) - else: - _LOGGER.error("Not connected to %s:%s", host, port) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect) + + async_add_entities( + [ + RussoundRNETDevice(client, lock, sources, zone_id, extra) + for zone_id, extra in config[CONF_ZONES].items() + ], + True, + ) class RussoundRNETDevice(MediaPlayerEntity): @@ -80,75 +109,123 @@ class RussoundRNETDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, hass, russ, sources, zone_id, extra): + def __init__( + self, + client: RussoundRNETClient, + lock: asyncio.Lock, + sources: list[str], + zone_id: int, + extra: dict[str, str], + ) -> None: """Initialise the Russound RNET device.""" - self._attr_name = extra["name"] - self._russ = russ + self._attr_name = extra[CONF_NAME] + self._client = client + self._lock = lock self._attr_source_list = sources - # Each controller has a maximum of 6 zones, every increment of 6 zones - # maps to an additional controller for easier backward compatibility - self._controller_id = str(math.ceil(zone_id / 6)) - # Each zone resets to 1-6 per controller + self._controller_id = math.ceil(zone_id / 6) self._zone_id = (zone_id - 1) % 6 + 1 - def update(self) -> None: + async def _async_ensure_connected(self) -> None: + """Ensure the client is connected, reconnecting if needed.""" + if not self._client.is_connected: + _LOGGER.debug("Reconnecting RNET client") + await self._client.connect() + + async def _async_run_with_retry( + self, command: Callable[[], Coroutine[Any, Any, Any]] + ) -> None: + """Run a command with reconnect retry on failure.""" + async with self._lock: + try: + await self._async_ensure_connected() + await command() + except RNET_EXCEPTIONS: + with contextlib.suppress(*RNET_EXCEPTIONS): + await self._client.disconnect() + try: + await self._async_ensure_connected() + await command() + except RNET_EXCEPTIONS: + _LOGGER.error( + "Command failed for zone %s on controller %s after retry", + self._zone_id, + self._controller_id, + ) + + async def async_update(self) -> None: """Retrieve latest state.""" - # Updated this function to make a single call to get_zone_info, so that - # with a single call we can get On/Off, Volume and Source, reducing the - # amount of traffic and speeding up the update process. - try: - ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) - except BrokenPipeError: - _LOGGER.error("Broken Pipe Error, trying to reconnect to Russound RNET") - self._russ.connect() - ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) + async with self._lock: + try: + await self._async_ensure_connected() + info = await self._client.get_all_zone_info( + self._controller_id, self._zone_id + ) + except RNET_EXCEPTIONS: + with contextlib.suppress(*RNET_EXCEPTIONS): + await self._client.disconnect() + try: + await self._async_ensure_connected() + info = await self._client.get_all_zone_info( + self._controller_id, self._zone_id + ) + except RNET_EXCEPTIONS: + _LOGGER.error( + "Could not update zone %s on controller %s", + self._zone_id, + self._controller_id, + ) + self._attr_available = False + return - _LOGGER.debug("ret= %s", ret) - if ret is not None: - _LOGGER.debug( - "Updating status for RNET zone %s on controller %s", - self._zone_id, - self._controller_id, + self._attr_available = True + self._attr_state = MediaPlayerState.ON if info.power else MediaPlayerState.OFF + self._attr_volume_level = info.volume / _MAX_VOLUME + # info.source is 1-based; source_list is 0-based + index = info.source - 1 + if self.source_list and 0 <= index < len(self.source_list): + self._attr_source = self.source_list[index] + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level. Volume has a range (0..1).""" + device_volume = max(0, min(_MAX_VOLUME, int(volume * _MAX_VOLUME))) + await self._async_run_with_retry( + lambda: self._client.set_volume( + self._controller_id, self._zone_id, device_volume ) - if ret[0] == 0: - self._attr_state = MediaPlayerState.OFF - else: - self._attr_state = MediaPlayerState.ON - self._attr_volume_level = ret[2] * 2 / 100.0 - # Returns 0 based index for source. - index = ret[1] - # Possibility exists that user has defined list of all sources. - # If a source is set externally that is beyond the defined list then - # an exception will be thrown. - # In this case return and unknown source (None) - if self.source_list and 0 <= index < len(self.source_list): - self._attr_source = self.source_list[index] - else: - _LOGGER.error("Could not update status for zone %s", self._zone_id) + ) - def set_volume_level(self, volume: float) -> None: - """Set volume level. Volume has a range (0..1). - - Translate this to a range of (0..100) as expected - by _russ.set_volume() - """ - self._russ.set_volume(self._controller_id, self._zone_id, volume * 100) - - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn the media player on.""" - self._russ.set_power(self._controller_id, self._zone_id, "1") + await self._async_run_with_retry( + lambda: self._client.set_zone_power( + self._controller_id, self._zone_id, True + ) + ) - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off media player.""" - self._russ.set_power(self._controller_id, self._zone_id, "0") + await self._async_run_with_retry( + lambda: self._client.set_zone_power( + self._controller_id, self._zone_id, False + ) + ) - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" - self._russ.toggle_mute(self._controller_id, self._zone_id) - def select_source(self, source: str) -> None: + async def _mute_if_needed() -> None: + if self.is_volume_muted != mute: + await self._client.toggle_mute(self._controller_id, self._zone_id) + + await self._async_run_with_retry(_mute_if_needed) + + async def async_select_source(self, source: str) -> None: """Set the input source.""" if self.source_list and source in self.source_list: - index = self.source_list.index(source) - # 0 based value for source - self._russ.set_source(self._controller_id, self._zone_id, index) + # source_list is 0-based; RNET source is 1-based + index = self.source_list.index(source) + 1 + await self._async_run_with_retry( + lambda: self._client.select_source( + self._controller_id, self._zone_id, index + ) + ) diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml index b82ef6f4643..8d15f1c2e94 100644 --- a/homeassistant/components/russound_rnet/quality_scale.yaml +++ b/homeassistant/components/russound_rnet/quality_scale.yaml @@ -9,10 +9,7 @@ rules: common-modules: todo config-flow-test-coverage: todo config-flow: todo - dependency-transparency: - status: todo - comment: | - CI pipeline for publishing is not on GH repo. + dependency-transparency: done docs-actions: status: exempt comment: | @@ -87,7 +84,7 @@ rules: This integration is not a hub and only represents a single device. # Platinum - async-dependency: todo + async-dependency: done inject-websession: status: exempt comment: | diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index da93a89a9f3..94ebf6fbcf6 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -1,7 +1,5 @@ """The Ruuvi Gateway integration.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index bdd1e21d491..8a0eaebe215 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -1,7 +1,5 @@ """Bluetooth support for Ruuvi Gateway.""" -from __future__ import annotations - import logging import time diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index 05ca93de9f2..d50c71280af 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ruuvi Gateway integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ruuvi_gateway/coordinator.py b/homeassistant/components/ruuvi_gateway/coordinator.py index 0c42cd0cb38..2786ac70270 100644 --- a/homeassistant/components/ruuvi_gateway/coordinator.py +++ b/homeassistant/components/ruuvi_gateway/coordinator.py @@ -1,7 +1,5 @@ """Update coordinator for Ruuvi Gateway.""" -from __future__ import annotations - import logging from aioruuvigateway.api import get_gateway_history_data @@ -17,7 +15,7 @@ from .const import SCAN_INTERVAL class RuuviGatewayUpdateCoordinator(DataUpdateCoordinator[list[TagData]]): - """Polls the gateway for data and returns a list of TagData objects that have changed since the last poll.""" + """Poll the gateway for data and return changed TagData objects.""" config_entry: ConfigEntry diff --git a/homeassistant/components/ruuvi_gateway/models.py b/homeassistant/components/ruuvi_gateway/models.py index 3717ffdb25a..201668e9a6c 100644 --- a/homeassistant/components/ruuvi_gateway/models.py +++ b/homeassistant/components/ruuvi_gateway/models.py @@ -1,7 +1,5 @@ """Models for Ruuvi Gateway integration.""" -from __future__ import annotations - import dataclasses from .bluetooth import RuuviGatewayScanner diff --git a/homeassistant/components/ruuvi_gateway/schemata.py b/homeassistant/components/ruuvi_gateway/schemata.py index 4662e07acbf..5a7d7bb2fb7 100644 --- a/homeassistant/components/ruuvi_gateway/schemata.py +++ b/homeassistant/components/ruuvi_gateway/schemata.py @@ -1,7 +1,5 @@ """Schemata for ruuvi_gateway.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_TOKEN diff --git a/homeassistant/components/ruuvitag_ble/__init__.py b/homeassistant/components/ruuvitag_ble/__init__.py index bf741a99a13..01634bfce88 100644 --- a/homeassistant/components/ruuvitag_ble/__init__.py +++ b/homeassistant/components/ruuvitag_ble/__init__.py @@ -1,7 +1,5 @@ """The ruuvitag_ble integration.""" -from __future__ import annotations - import logging from ruuvitag_ble import RuuvitagBluetoothDeviceData diff --git a/homeassistant/components/ruuvitag_ble/config_flow.py b/homeassistant/components/ruuvitag_ble/config_flow.py index 1d71eaf28c0..456ed9c5d28 100644 --- a/homeassistant/components/ruuvitag_ble/config_flow.py +++ b/homeassistant/components/ruuvitag_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for ruuvitag_ble.""" -from __future__ import annotations - from typing import Any from ruuvitag_ble import RuuvitagBluetoothDeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -72,6 +71,7 @@ class RuuvitagConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 0b359a570eb..42f553f6d3f 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -1,7 +1,5 @@ """Support for RuuviTag sensors.""" -from __future__ import annotations - from sensor_state_data import ( DeviceKey, SensorDeviceClass as SSDSensorDeviceClass, @@ -198,6 +196,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ruuvi BLE sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] @@ -207,7 +207,9 @@ async def async_setup_entry( RuuvitagBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class RuuvitagBluetoothSensorEntity( diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index 20d208cca69..69251608d09 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -1,25 +1,21 @@ """The Read Your Meter Pro integration.""" -from __future__ import annotations - import logging from pyrympro import CannotConnectError, RymPro, UnauthorizedError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import RymProDataUpdateCoordinator +from .coordinator import RymProConfigEntry, RymProDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bool: """Set up Read Your Meter Pro from a config entry.""" data = entry.data rympro = RymPro(async_get_clientsession(hass)) @@ -41,17 +37,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rympro/config_flow.py b/homeassistant/components/rympro/config_flow.py index 1d5d8a9e79d..74844cf30d8 100644 --- a/homeassistant/components/rympro/config_flow.py +++ b/homeassistant/components/rympro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Read Your Meter Pro integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 6b49a065d35..3366622fbc7 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -1,7 +1,5 @@ """The Read Your Meter Pro integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -13,6 +11,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN +type RymProConfigEntry = ConfigEntry[RymProDataUpdateCoordinator] + SCAN_INTERVAL = 60 * 60 _LOGGER = logging.getLogger(__name__) @@ -21,10 +21,10 @@ _LOGGER = logging.getLogger(__name__) class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - config_entry: ConfigEntry + config_entry: RymProConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + self, hass: HomeAssistant, config_entry: RymProConfigEntry, rympro: RymPro ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 66ed41a4ce9..09f31c59944 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -1,7 +1,5 @@ """Sensor for RymPro meters.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( @@ -10,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -18,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RymProDataUpdateCoordinator +from .coordinator import RymProConfigEntry, RymProDataUpdateCoordinator @dataclass(kw_only=True, frozen=True) @@ -61,11 +58,11 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RymProConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" - coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RymProSensor(coordinator, meter_id, description, config_entry.entry_id) for meter_id, meter in coordinator.data.items() diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 4241f39778c..f2d687280db 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,7 +1,5 @@ """Support for monitoring an SABnzbd NZB client.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py index 59ef17237e2..73eced14a23 100644 --- a/homeassistant/components/sabnzbd/binary_sensor.py +++ b/homeassistant/components/sabnzbd/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for SABnzbd.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index c7d299c825b..b90f148523d 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for SabNzbd.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py index 8f6a606430e..57396eeb783 100644 --- a/homeassistant/components/sabnzbd/number.py +++ b/homeassistant/components/sabnzbd/number.py @@ -1,7 +1,5 @@ """Number entities for the SABnzbd integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 5e871b4bf40..5d10e2dc0b9 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring an SABnzbd NZB client.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 89b6658c418..8eea41a45a2 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -1,7 +1,5 @@ """SAJ solar inverter interface.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from datetime import date, datetime import logging @@ -36,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -125,7 +124,7 @@ async def async_setup_platform( # Sensors with live values like "temperature" or "current_power" # will also be reset to None. if not success and ( - (sensor.per_day_basis and date.today() > sensor.date_updated) + (sensor.per_day_basis and dt_util.now().date() > sensor.date_updated) or (not sensor.per_day_basis and not sensor.per_total_basis) ): state_unknown = True diff --git a/homeassistant/components/samsung_infrared/__init__.py b/homeassistant/components/samsung_infrared/__init__.py new file mode 100644 index 00000000000..0d46c49a687 --- /dev/null +++ b/homeassistant/components/samsung_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Samsung IR Remote integration for Home Assistant.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Samsung IR from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Samsung IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/samsung_infrared/button.py b/homeassistant/components/samsung_infrared/button.py new file mode 100644 index 00000000000..c2e152e3478 --- /dev/null +++ b/homeassistant/components/samsung_infrared/button.py @@ -0,0 +1,176 @@ +"""Button platform for Samsung IR integration.""" + +from dataclasses import dataclass + +from infrared_protocols.codes.samsung.tv import SamsungTVCode + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType +from .entity import SamsungIrEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SamsungIrButtonEntityDescription(ButtonEntityDescription): + """Describes Samsung IR button entity.""" + + command_code: SamsungTVCode + + +TV_BUTTON_DESCRIPTIONS: tuple[SamsungIrButtonEntityDescription, ...] = ( + SamsungIrButtonEntityDescription( + key="power", translation_key="power", command_code=SamsungTVCode.POWER + ), + SamsungIrButtonEntityDescription( + key="source", translation_key="source", command_code=SamsungTVCode.SOURCE + ), + SamsungIrButtonEntityDescription( + key="settings", translation_key="settings", command_code=SamsungTVCode.SETTINGS + ), + SamsungIrButtonEntityDescription( + key="info", translation_key="info", command_code=SamsungTVCode.INFO + ), + SamsungIrButtonEntityDescription( + key="exit", translation_key="exit", command_code=SamsungTVCode.EXIT + ), + SamsungIrButtonEntityDescription( + key="return", translation_key="return", command_code=SamsungTVCode.RETURN + ), + SamsungIrButtonEntityDescription( + key="home", translation_key="home", command_code=SamsungTVCode.HOME + ), + SamsungIrButtonEntityDescription( + key="red", translation_key="red", command_code=SamsungTVCode.RED + ), + SamsungIrButtonEntityDescription( + key="green", translation_key="green", command_code=SamsungTVCode.GREEN + ), + SamsungIrButtonEntityDescription( + key="yellow", translation_key="yellow", command_code=SamsungTVCode.YELLOW + ), + SamsungIrButtonEntityDescription( + key="blue", translation_key="blue", command_code=SamsungTVCode.BLUE + ), + SamsungIrButtonEntityDescription( + key="up", translation_key="up", command_code=SamsungTVCode.NAV_UP + ), + SamsungIrButtonEntityDescription( + key="down", translation_key="down", command_code=SamsungTVCode.NAV_DOWN + ), + SamsungIrButtonEntityDescription( + key="left", translation_key="left", command_code=SamsungTVCode.NAV_LEFT + ), + SamsungIrButtonEntityDescription( + key="right", translation_key="right", command_code=SamsungTVCode.NAV_RIGHT + ), + SamsungIrButtonEntityDescription( + key="ok", translation_key="ok", command_code=SamsungTVCode.OK + ), + SamsungIrButtonEntityDescription( + key="previous_channel", + translation_key="previous_channel", + command_code=SamsungTVCode.PREVIOUS_CHANNEL, + ), + SamsungIrButtonEntityDescription( + key="num_0", translation_key="num_0", command_code=SamsungTVCode.NUM_0 + ), + SamsungIrButtonEntityDescription( + key="num_1", translation_key="num_1", command_code=SamsungTVCode.NUM_1 + ), + SamsungIrButtonEntityDescription( + key="num_2", translation_key="num_2", command_code=SamsungTVCode.NUM_2 + ), + SamsungIrButtonEntityDescription( + key="num_3", translation_key="num_3", command_code=SamsungTVCode.NUM_3 + ), + SamsungIrButtonEntityDescription( + key="num_4", translation_key="num_4", command_code=SamsungTVCode.NUM_4 + ), + SamsungIrButtonEntityDescription( + key="num_5", translation_key="num_5", command_code=SamsungTVCode.NUM_5 + ), + SamsungIrButtonEntityDescription( + key="num_6", translation_key="num_6", command_code=SamsungTVCode.NUM_6 + ), + SamsungIrButtonEntityDescription( + key="num_7", translation_key="num_7", command_code=SamsungTVCode.NUM_7 + ), + SamsungIrButtonEntityDescription( + key="num_8", translation_key="num_8", command_code=SamsungTVCode.NUM_8 + ), + SamsungIrButtonEntityDescription( + key="num_9", translation_key="num_9", command_code=SamsungTVCode.NUM_9 + ), + SamsungIrButtonEntityDescription( + key="fast_forward", + translation_key="fast_forward", + command_code=SamsungTVCode.FAST_FORWARD, + ), + SamsungIrButtonEntityDescription( + key="rewind", translation_key="rewind", command_code=SamsungTVCode.REWIND + ), + SamsungIrButtonEntityDescription( + key="record", translation_key="record", command_code=SamsungTVCode.RECORD + ), + SamsungIrButtonEntityDescription( + key="tools", translation_key="tools", command_code=SamsungTVCode.TOOLS + ), + SamsungIrButtonEntityDescription( + key="browser", translation_key="browser", command_code=SamsungTVCode.BROWSER + ), + SamsungIrButtonEntityDescription( + key="ad_subtitle", + translation_key="ad_subtitle", + command_code=SamsungTVCode.AD_SUBTITLE, + ), + SamsungIrButtonEntityDescription( + key="e_manual", + translation_key="e_manual", + command_code=SamsungTVCode.E_MANUAL, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Samsung IR buttons from config entry.""" + infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + device_type = entry.data[CONF_DEVICE_TYPE] + if device_type != SamsungDeviceType.TV: + return + async_add_entities( + [ + SamsungIrButton(entry, infrared_emitter_entity_id, description) + for description in TV_BUTTON_DESCRIPTIONS + ] + ) + + +class SamsungIrButton(SamsungIrEntity, InfraredEmitterConsumerEntity, ButtonEntity): + """Samsung IR button entity.""" + + entity_description: SamsungIrButtonEntityDescription + + def __init__( + self, + entry: ConfigEntry, + infrared_emitter_entity_id: str, + description: SamsungIrButtonEntityDescription, + ) -> None: + """Initialize Samsung IR button.""" + super().__init__(entry, unique_id_suffix=description.key) + self._infrared_emitter_entity_id = infrared_emitter_entity_id + self.entity_description = description + + async def async_press(self) -> None: + """Press the button.""" + await self._send_command(self.entity_description.command_code.to_command()) diff --git a/homeassistant/components/samsung_infrared/config_flow.py b/homeassistant/components/samsung_infrared/config_flow.py new file mode 100644 index 00000000000..0e5232e46de --- /dev/null +++ b/homeassistant/components/samsung_infrared/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for Samsung IR integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, + SamsungDeviceType, +) + +DEVICE_TYPE_NAMES: dict[SamsungDeviceType, str] = { + SamsungDeviceType.TV: "TV", +} + + +class SamsungIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Samsung IR.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID] + device_type = user_input[CONF_DEVICE_TYPE] + + await self.async_set_unique_id( + f"samsung_infrared_{device_type}_{entity_id}" + ) + self._abort_if_unique_id_configured() + + # Get entity name for the title + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(entity_id) + entity_name = ( + entry.name or entry.original_name or entity_id if entry else entity_id + ) + device_type_name = DEVICE_TYPE_NAMES[SamsungDeviceType(device_type)] + title = f"Samsung {device_type_name} via {entity_name}" + + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[ + device_type.value for device_type in SamsungDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/samsung_infrared/const.py b/homeassistant/components/samsung_infrared/const.py new file mode 100644 index 00000000000..94bf2937874 --- /dev/null +++ b/homeassistant/components/samsung_infrared/const.py @@ -0,0 +1,13 @@ +"""Constants for the Samsung IR integration.""" + +from enum import StrEnum + +DOMAIN = "samsung_infrared" +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class SamsungDeviceType(StrEnum): + """Samsung device types.""" + + TV = "tv" diff --git a/homeassistant/components/samsung_infrared/entity.py b/homeassistant/components/samsung_infrared/entity.py new file mode 100644 index 00000000000..92eb814416d --- /dev/null +++ b/homeassistant/components/samsung_infrared/entity.py @@ -0,0 +1,22 @@ +"""Common entity for Samsung IR integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class SamsungIrEntity(Entity): + """Samsung IR base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, unique_id_suffix: str) -> None: + """Initialize Samsung IR entity.""" + self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name="Samsung TV", + manufacturer="Samsung", + ) diff --git a/homeassistant/components/samsung_infrared/manifest.json b/homeassistant/components/samsung_infrared/manifest.json new file mode 100644 index 00000000000..b61bf7d5906 --- /dev/null +++ b/homeassistant/components/samsung_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "samsung_infrared", + "name": "Samsung Infrared", + "codeowners": ["@lmaertin"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/samsung_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/samsung_infrared/media_player.py b/homeassistant/components/samsung_infrared/media_player.py new file mode 100644 index 00000000000..fa2c22d826e --- /dev/null +++ b/homeassistant/components/samsung_infrared/media_player.py @@ -0,0 +1,131 @@ +"""Media player platform for Samsung IR integration.""" + +from infrared_protocols.codes.samsung.tv import SamsungTVCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, + SamsungDeviceType, +) +from .entity import SamsungIrEntity + +PARALLEL_UPDATES = 1 + +SOURCE_MAP: dict[str, SamsungTVCode] = { + "tv": SamsungTVCode.TV, + "hdmi_1": SamsungTVCode.HDMI_1, + "hdmi_2": SamsungTVCode.HDMI_2, + "hdmi_3": SamsungTVCode.HDMI_3, + "hdmi_4": SamsungTVCode.HDMI_4, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Samsung IR media player from config entry.""" + infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + device_type = entry.data[CONF_DEVICE_TYPE] + if device_type == SamsungDeviceType.TV: + async_add_entities([SamsungIrTvMediaPlayer(entry, infrared_emitter_entity_id)]) + + +class SamsungIrTvMediaPlayer( + SamsungIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity +): + """Samsung IR media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + _attr_source_list = list(SOURCE_MAP.keys()) + _attr_source = None + _attr_state = MediaPlayerState.ON + _attr_translation_key = "samsung_ir_tv" + + def __init__(self, entry: ConfigEntry, infrared_emitter_entity_id: str) -> None: + """Initialize Samsung IR media player.""" + super().__init__(entry, unique_id_suffix="media_player") + self._infrared_emitter_entity_id = infrared_emitter_entity_id + + async def async_turn_on(self) -> None: + """Turn on the TV.""" + await self._send_command(SamsungTVCode.POWER_ON.to_command()) + + async def async_turn_off(self) -> None: + """Turn off the TV.""" + await self._send_command(SamsungTVCode.POWER_OFF.to_command()) + + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self._send_command(SamsungTVCode.VOLUME_UP.to_command()) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self._send_command(SamsungTVCode.VOLUME_DOWN.to_command()) + + async def async_mute_volume(self, mute: bool) -> None: + """Send mute command.""" + await self._send_command(SamsungTVCode.MUTE.to_command()) + + async def async_media_next_track(self) -> None: + """Send channel up command.""" + await self._send_command(SamsungTVCode.CHANNEL_UP.to_command()) + + async def async_media_previous_track(self) -> None: + """Send channel down command.""" + await self._send_command(SamsungTVCode.CHANNEL_DOWN.to_command()) + + async def async_media_play(self) -> None: + """Send play command.""" + await self._send_command(SamsungTVCode.PLAY.to_command()) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self._send_command(SamsungTVCode.PAUSE.to_command()) + + async def async_media_stop(self) -> None: + """Send stop command.""" + await self._send_command(SamsungTVCode.STOP.to_command()) + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + if (code := SOURCE_MAP.get(source)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_source", + translation_placeholders={ + "invalid_source": source, + "valid_sources": ", ".join(self._attr_source_list), + }, + ) + await self._send_command(code.to_command()) + self._attr_source = source + self.async_write_ha_state() diff --git a/homeassistant/components/samsung_infrared/quality_scale.yaml b/homeassistant/components/samsung_infrared/quality_scale.yaml new file mode 100644 index 00000000000..d06ec6ee6e7 --- /dev/null +++ b/homeassistant/components/samsung_infrared/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: done + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration has no external dependencies. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/samsung_infrared/strings.json b/homeassistant/components/samsung_infrared/strings.json new file mode 100644 index 00000000000..464876d4122 --- /dev/null +++ b/homeassistant/components/samsung_infrared/strings.json @@ -0,0 +1,155 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured with this transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "device_type": "Device type", + "infrared_emitter_entity_id": "Infrared transmitter" + }, + "data_description": { + "device_type": "The type of device to control.", + "infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands." + }, + "description": "Select the device type and the infrared transmitter entity to use for controlling your device.", + "title": "Set up Infrared Remote" + } + } + }, + "entity": { + "button": { + "ad_subtitle": { + "name": "AD/Subtitle" + }, + "blue": { + "name": "Blue" + }, + "browser": { + "name": "Browser" + }, + "down": { + "name": "[%key:common::entity::button::down::name%]" + }, + "e_manual": { + "name": "E-Manual" + }, + "exit": { + "name": "[%key:common::entity::button::exit::name%]" + }, + "fast_forward": { + "name": "Fast forward" + }, + "green": { + "name": "Green" + }, + "home": { + "name": "[%key:common::entity::button::home::name%]" + }, + "info": { + "name": "[%key:common::entity::button::info::name%]" + }, + "left": { + "name": "[%key:common::entity::button::left::name%]" + }, + "num_0": { + "name": "[%key:common::entity::button::num_0::name%]" + }, + "num_1": { + "name": "[%key:common::entity::button::num_1::name%]" + }, + "num_2": { + "name": "[%key:common::entity::button::num_2::name%]" + }, + "num_3": { + "name": "[%key:common::entity::button::num_3::name%]" + }, + "num_4": { + "name": "[%key:common::entity::button::num_4::name%]" + }, + "num_5": { + "name": "[%key:common::entity::button::num_5::name%]" + }, + "num_6": { + "name": "[%key:common::entity::button::num_6::name%]" + }, + "num_7": { + "name": "[%key:common::entity::button::num_7::name%]" + }, + "num_8": { + "name": "[%key:common::entity::button::num_8::name%]" + }, + "num_9": { + "name": "[%key:common::entity::button::num_9::name%]" + }, + "ok": { + "name": "[%key:common::entity::button::ok::name%]" + }, + "power": { + "name": "[%key:common::entity::button::power::name%]" + }, + "previous_channel": { + "name": "Previous channel" + }, + "record": { + "name": "Record" + }, + "red": { + "name": "Red" + }, + "return": { + "name": "Return" + }, + "rewind": { + "name": "Rewind" + }, + "right": { + "name": "[%key:common::entity::button::right::name%]" + }, + "settings": { + "name": "Settings" + }, + "source": { + "name": "Source" + }, + "tools": { + "name": "Tools" + }, + "up": { + "name": "[%key:common::entity::button::up::name%]" + }, + "yellow": { + "name": "Yellow" + } + }, + "media_player": { + "samsung_ir_tv": { + "state_attributes": { + "source": { + "state": { + "hdmi_1": "HDMI 1", + "hdmi_2": "HDMI 2", + "hdmi_3": "HDMI 3", + "hdmi_4": "HDMI 4", + "tv": "[%key:common::generic::tv%]" + } + } + } + } + } + }, + "exceptions": { + "invalid_source": { + "message": "Cannot select input source {invalid_source} for media player. Valid sources: {valid_sources}." + } + }, + "selector": { + "device_type": { + "options": { + "tv": "TV" + } + } + } +} diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index f7af5efc899..c1126c552f4 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -1,7 +1,5 @@ """The Samsung TV integration.""" -from __future__ import annotations - from collections.abc import Coroutine, Mapping from functools import partial from typing import Any @@ -241,6 +239,10 @@ async def async_migrate_entry( version = config_entry.version minor_version = config_entry.minor_version + if version > 2: + # This means the user has downgraded from a future version + return False + LOGGER.debug("Migrating from version %s.%s", version, minor_version) # 1 -> 2: Unique ID format changed, so delete and re-import: diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0c856be4a81..6168f8d2f69 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -1,7 +1,5 @@ """samsungctl and samsungtvws bridge classes.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from asyncio.exceptions import TimeoutError as AsyncioTimeoutError diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 938b719c802..3c0a4979d79 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Samsung TV.""" -from __future__ import annotations - from collections.abc import Mapping from functools import partial import socket @@ -444,7 +442,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return None LOGGER.debug("Updating existing config entry with %s", entry_kw_args) self.hass.config_entries.async_update_entry(entry, **entry_kw_args) - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: # If its loaded it already has a reload listener in place # and we do not want to trigger multiple reloads self.hass.async_create_task( @@ -539,6 +537,12 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) + if "Soundbar" in discovery_info.name: + LOGGER.debug( + "Ignoring Samsung Soundbar found via Zeroconf: %s", discovery_info + ) + return self.async_abort(reason="not_supported") + self._mac = format_mac(discovery_info.properties["deviceid"]) self._host = discovery_info.host self._async_start_discovery_with_mac_address() diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 9b09436be88..ac587c9ae92 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the SamsungTV integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from datetime import timedelta from typing import Any diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py index 749276b61c4..253469900a0 100644 --- a/homeassistant/components/samsungtv/device_trigger.py +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for control of Samsung TV.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( @@ -45,7 +43,11 @@ async def async_validate_trigger_config( device = async_get_device_entry_by_device_id(hass, device_id) async_get_client_by_device_entry(hass, device) except ValueError as err: - raise InvalidDeviceAutomationConfig(err) from err + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"device_id": device_id}, + ) from err return config diff --git a/homeassistant/components/samsungtv/diagnostics.py b/homeassistant/components/samsungtv/diagnostics.py index 667d23ba631..6924b97d293 100644 --- a/homeassistant/components/samsungtv/diagnostics.py +++ b/homeassistant/components/samsungtv/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for SamsungTV.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 82157a7365b..8c980f27964 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -1,7 +1,5 @@ """Base SamsungTV Entity.""" -from __future__ import annotations - from typing import Any from wakeonlan import send_magic_packet @@ -39,7 +37,8 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) config_entry = coordinator.config_entry self._mac: str | None = config_entry.data.get(CONF_MAC) self._host: str | None = config_entry.data.get(CONF_HOST) - # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber + # Fallback for legacy models that doesn't have a API + # to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( manufacturer=config_entry.data.get(CONF_MANUFACTURER), diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index b4075b8117f..6a16ef5a0e3 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Samsung TV.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 76a758f6a43..d1a3a01ded8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -1,7 +1,7 @@ { "domain": "samsungtv", "name": "Samsung Smart TV", - "codeowners": ["@chemelli74", "@epenet"], + "codeowners": ["@chemelli74"], "config_flow": true, "dependencies": ["ssdp"], "dhcp": [ @@ -38,8 +38,8 @@ "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==3.1.0", + "samsungtvws[async,encrypted]==3.0.5", + "wakeonlan==3.3.0", "async-upnp-client==0.46.2" ], "ssdp": [ diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 56eb0abd9f5..b9cac3f844b 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -1,7 +1,5 @@ """Support for interface with an Samsung TV.""" -from __future__ import annotations - import asyncio from collections.abc import Sequence from typing import Any @@ -368,9 +366,12 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): # media_id should only be a channel number try: cv.positive_int(media_id) - except vol.Invalid: + except vol.Invalid as err: LOGGER.error("Media ID must be positive integer") - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="media_id_invalid", + ) from err await self._async_send_keys( keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"] diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index ec2e8c45963..e2db2fd9f43 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -1,7 +1,5 @@ """Support for the SamsungTV remote.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 180e412a4db..752fe441f2b 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -12,13 +12,13 @@ }, "error": { "auth_missing": "[%key:component::samsungtv::config::abort::auth_missing%]", - "invalid_host": "Host is invalid, please try again.", - "invalid_pin": "PIN is invalid, please try again." + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_pin": "The PIN is invalid. Please try again." }, "flow_title": "{device}", "step": { "confirm": { - "description": "Do you want to set up {device}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization." + "description": "Do you want to set up {device}? If you have never connected Home Assistant before, you should see a popup on your TV asking for authorization." }, "encrypted_pairing": { "data": { @@ -62,7 +62,7 @@ "host": "The hostname or IP address of your TV.", "name": "The name of your TV. This will be used to identify the device in Home Assistant." }, - "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authorization." + "description": "Enter your Samsung TV information. If you have never connected Home Assistant before, you should see a popup on your TV asking for authorization." } } }, @@ -81,6 +81,12 @@ "error_set_volume": { "message": "Unable to set volume level on {host}: {error}" }, + "invalid_device": { + "message": "Device with ID {device_id} is invalid or not found." + }, + "media_id_invalid": { + "message": "Media ID must be a positive integer." + }, "service_unsupported": { "message": "Entity {entity} does not support this action." }, diff --git a/homeassistant/components/samsungtv/trigger.py b/homeassistant/components/samsungtv/trigger.py index dc32617b583..a9baadf0a9d 100644 --- a/homeassistant/components/samsungtv/trigger.py +++ b/homeassistant/components/samsungtv/trigger.py @@ -1,7 +1,5 @@ """Samsung TV trigger dispatcher.""" -from __future__ import annotations - from typing import cast from homeassistant.const import CONF_PLATFORM diff --git a/homeassistant/components/samsungtv/triggers/turn_on.py b/homeassistant/components/samsungtv/triggers/turn_on.py index 6bcb9365b67..5cc5b386646 100644 --- a/homeassistant/components/samsungtv/triggers/turn_on.py +++ b/homeassistant/components/samsungtv/triggers/turn_on.py @@ -1,7 +1,5 @@ """Samsung TV device turn on trigger.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index 60cc5b56f2e..59984601768 100644 --- a/homeassistant/components/sanix/__init__.py +++ b/homeassistant/components/sanix/__init__.py @@ -2,17 +2,16 @@ from sanix import Sanix -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import CONF_SERIAL_NUMBER, DOMAIN -from .coordinator import SanixCoordinator +from .const import CONF_SERIAL_NUMBER +from .coordinator import SanixConfigEntry, SanixCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SanixConfigEntry) -> bool: """Set up Sanix from a config entry.""" serial_no = entry.data[CONF_SERIAL_NUMBER] @@ -22,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = SanixCoordinator(hass, entry, sanix_api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SanixConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py index 64d28fa9191..804421dfe59 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -15,14 +15,16 @@ from .const import MANUFACTURER _LOGGER = logging.getLogger(__name__) +type SanixConfigEntry = ConfigEntry[SanixCoordinator] + class SanixCoordinator(DataUpdateCoordinator[Measurement]): """Sanix coordinator.""" - config_entry: ConfigEntry + config_entry: SanixConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, sanix_api: Sanix + self, hass: HomeAssistant, config_entry: SanixConfigEntry, sanix_api: Sanix ) -> None: """Initialize coordinator.""" super().__init__( diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py index d2a1aecb099..81531f111a9 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -20,7 +20,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -28,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import SanixCoordinator +from .coordinator import SanixConfigEntry, SanixCoordinator @dataclass(frozen=True, kw_only=True) @@ -83,11 +82,11 @@ SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SanixConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sanix Sensor entities based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SanixSensorEntity(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index 4c695a26561..dc07df405da 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -9,6 +9,8 @@ from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_e from .client import SatelClient from .const import ( + CONF_ENABLE_TEMPERATURE_SENSOR, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -24,12 +26,18 @@ from .coordinator import ( SatelIntegraData, SatelIntegraOutputsCoordinator, SatelIntegraPartitionsCoordinator, + SatelIntegraTemperaturesCoordinator, SatelIntegraZonesCoordinator, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -42,18 +50,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo coordinator_zones = SatelIntegraZonesCoordinator(hass, entry, client) coordinator_outputs = SatelIntegraOutputsCoordinator(hass, entry, client) coordinator_partitions = SatelIntegraPartitionsCoordinator(hass, entry, client) + coordinator_temperatures = SatelIntegraTemperaturesCoordinator(hass, entry, client) + + for coordinator in ( + coordinator_zones, + coordinator_outputs, + coordinator_partitions, + coordinator_temperatures, + ): + coordinator.setup() await client.async_connect( coordinator_zones.zones_update_callback, coordinator_outputs.outputs_update_callback, coordinator_partitions.partitions_update_callback, ) + await coordinator_temperatures.async_config_entry_first_refresh() entry.runtime_data = SatelIntegraData( client=client, coordinator_zones=coordinator_zones, coordinator_outputs=coordinator_outputs, coordinator_partitions=coordinator_partitions, + coordinator_temperatures=coordinator_temperatures, ) async def async_close_connection(event: Event) -> None: @@ -116,7 +135,8 @@ async def async_migrate_entry( SUBENTRY_TYPE_SWITCHABLE_OUTPUT: CONF_SWITCHABLE_OUTPUT_NUMBER, } - new_title = f"{subentry.title} ({subentry.data[property_map[subentry.subentry_type]]})" + prop = property_map[subentry.subentry_type] + new_title = f"{subentry.title} ({subentry.data[prop]})" hass.config_entries.async_update_subentry( config_entry, subentry, title=new_title @@ -124,7 +144,8 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(config_entry, minor_version=2) - # 2.1 Migrate all entity unique IDs to replace "satel" prefix with config entry ID, allows multiple entries to be configured + # 2.1 Migrate all entity unique IDs to replace "satel" prefix + # with config entry ID, allows multiple entries to be configured if config_entry.version == 1: @callback @@ -139,6 +160,25 @@ async def async_migrate_entry( await async_migrate_entries(hass, config_entry.entry_id, migrate_unique_id) hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1) + # 2.2 Added encryption key to config entry data + if config_entry.version == 2 and config_entry.minor_version < 2: + new_data = {**config_entry.data, CONF_ENCRYPTION_KEY: None} + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2 + ) + + # 2.3 Added temperature sensor option to zone subentries + if config_entry.version == 2 and config_entry.minor_version < 3: + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_ZONE): + hass.config_entries.async_update_subentry( + config_entry, + subentry, + data={CONF_ENABLE_TEMPERATURE_SENSOR: False} | subentry.data, + ) + + hass.config_entries.async_update_entry(config_entry, minor_version=3) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 36258155a51..1e0d9248f7c 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Satel Integra alarm, using ETHM module.""" -from __future__ import annotations - import asyncio import logging @@ -35,6 +33,8 @@ ALARM_STATE_MAP = { _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -45,12 +45,7 @@ async def async_setup_entry( runtime_data = config_entry.runtime_data - partition_subentries = filter( - lambda entry: entry.subentry_type == SUBENTRY_TYPE_PARTITION, - config_entry.subentries.values(), - ) - - for subentry in partition_subentries: + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_PARTITION): partition_num: int = subentry.data[CONF_PARTITION_NUMBER] arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE] @@ -78,6 +73,7 @@ class SatelIntegraAlarmPanel( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_name = None def __init__( self, @@ -105,13 +101,8 @@ class SatelIntegraAlarmPanel( self._attr_alarm_state = self._read_alarm_state() self.async_write_ha_state() - def _read_alarm_state(self) -> AlarmControlPanelState | None: + def _read_alarm_state(self) -> AlarmControlPanelState: """Read current status of the alarm and translate it into HA status.""" - - if not self._controller.connected: - _LOGGER.debug("Alarm panel not connected") - return None - for satel_state, ha_state in ALARM_STATE_MAP.items(): if ( satel_state in self.coordinator.data diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 567fecb132d..b04a3734832 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Satel Integra zone states- represented as binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -20,6 +18,8 @@ from .const import ( from .coordinator import SatelConfigEntry, SatelIntegraBaseCoordinator from .entity import SatelIntegraEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -30,12 +30,7 @@ async def async_setup_entry( runtime_data = config_entry.runtime_data - zone_subentries = filter( - lambda entry: entry.subentry_type == SUBENTRY_TYPE_ZONE, - config_entry.subentries.values(), - ) - - for subentry in zone_subentries: + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_ZONE): zone_num: int = subentry.data[CONF_ZONE_NUMBER] zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE] @@ -52,14 +47,9 @@ async def async_setup_entry( config_subentry_id=subentry.subentry_id, ) - output_subentries = filter( - lambda entry: entry.subentry_type == SUBENTRY_TYPE_OUTPUT, - config_entry.subentries.values(), - ) - - for subentry in output_subentries: + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_OUTPUT): output_num: int = subentry.data[CONF_OUTPUT_NUMBER] - ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE] + output_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE] async_add_entities( [ @@ -68,7 +58,7 @@ async def async_setup_entry( config_entry.entry_id, subentry, output_num, - ouput_type, + output_type, ) ], config_subentry_id=subentry.subentry_id, @@ -80,6 +70,8 @@ class SatelIntegraBinarySensor[_CoordinatorT: SatelIntegraBaseCoordinator]( ): """Base binary sensor for Satel Integra.""" + _attr_name = None + def __init__( self, coordinator: _CoordinatorT, diff --git a/homeassistant/components/satel_integra/client.py b/homeassistant/components/satel_integra/client.py index db66d8af6fa..1a78e11ee0b 100644 --- a/homeassistant/components/satel_integra/client.py +++ b/homeassistant/components/satel_integra/client.py @@ -3,17 +3,24 @@ from collections.abc import Callable from satel_integra import AsyncSatel +from satel_integra.exceptions import ( + SatelConnectFailedError, + SatelConnectionInitializationError, + SatelPanelBusyError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import ( + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, CONF_ZONE_NUMBER, + DOMAIN, SUBENTRY_TYPE_OUTPUT, SUBENTRY_TYPE_PARTITION, SUBENTRY_TYPE_SWITCHABLE_OUTPUT, @@ -34,34 +41,40 @@ class SatelClient: host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - # Make sure we initialize the Satel controller with the configured entries to monitor + # Make sure we initialize the Satel controller + # with the configured entries to monitor partitions = [ subentry.data[CONF_PARTITION_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_PARTITION + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PARTITION) ] zones = [ subentry.data[CONF_ZONE_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_ZONE + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_ZONE) ] outputs = [ subentry.data[CONF_OUTPUT_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_OUTPUT) ] switchable_outputs = [ subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT + for subentry in entry.get_subentries_of_type( + SUBENTRY_TYPE_SWITCHABLE_OUTPUT + ) ] monitored_outputs = outputs + switchable_outputs - self.controller = AsyncSatel(host, port, zones, monitored_outputs, partitions) + self.controller = AsyncSatel( + host, + port, + zones, + monitored_outputs, + partitions, + integration_key=entry.data[CONF_ENCRYPTION_KEY], + ) async def async_connect( self, @@ -70,9 +83,23 @@ class SatelClient: partitions_update_callback: Callable[[], None], ) -> None: """Start controller connection.""" - result = await self.controller.connect() - if not result: - raise ConfigEntryNotReady("Controller failed to connect") + try: + await self.controller.connect(raise_exceptions=True) + except SatelConnectFailedError as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from ex + except SatelPanelBusyError as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="panel_busy", + ) from ex + except SatelConnectionInitializationError as ex: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="connection_initialization_failed", + ) from ex self.controller.register_callbacks( alarm_status_callback=partitions_update_callback, diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index 59c91ec5f8d..fe20a510560 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -1,11 +1,15 @@ """Config flow for Satel Integra.""" - -from __future__ import annotations +# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas import logging from typing import Any from satel_integra import AsyncSatel +from satel_integra.exceptions import ( + SatelConnectFailedError, + SatelConnectionInitializationError, + SatelPanelBusyError, +) import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -24,6 +28,8 @@ from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_ARM_HOME_MODE, + CONF_ENABLE_TEMPERATURE_SENSOR, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -45,6 +51,9 @@ CONNECTION_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ENCRYPTION_KEY): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), } ) @@ -79,9 +88,42 @@ ZONE_AND_OUTPUT_SCHEMA = vol.Schema( } ) +ZONE_SCHEMA = ZONE_AND_OUTPUT_SCHEMA.extend( + { + vol.Required(CONF_ENABLE_TEMPERATURE_SENSOR, default=False): ( + selector.BooleanSelector() + ), + } +) + + SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) +async def _async_validate_zone_temperature_sensor( + entry: SatelConfigEntry, zone_number: int +) -> dict[str, str]: + """Validate that temperature reading can be fetched for the zone.""" + errors: dict[str, str] = {} + + try: + temperature = await entry.runtime_data.client.controller.read_temperature( + zone_number + ) + + if temperature is None: + errors[CONF_ENABLE_TEMPERATURE_SENSOR] = "zone_does_not_report_temperature" + + except Exception: + _LOGGER.exception( + "Unexpected error while validating temperature sensor support for zone %s", + zone_number, + ) + errors["base"] = "unknown" + + return errors + + class SatelConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Satel Integra config flow.""" @@ -91,7 +133,7 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): self.connection_data: dict[str, Any] = {} VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 3 @staticmethod @callback @@ -123,15 +165,20 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]): + errors = await self.test_connection( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input.get(CONF_ENCRYPTION_KEY), + ) + + if not errors: self.connection_data = { CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), } return await self.async_step_code() - errors["base"] = "cannot_connect" - return self.async_show_form( step_id="user", data_schema=CONNECTION_SCHEMA, @@ -164,23 +211,28 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + # Normalize user_input to include None for missing optional encryption key + normalized_input = {CONF_ENCRYPTION_KEY: None, **user_input} + if ( reconfigure_entry.state is not ConfigEntryState.LOADED - or reconfigure_entry.data != user_input + or reconfigure_entry.data != normalized_input ): - if not await self.test_connection( - user_input[CONF_HOST], user_input[CONF_PORT] - ): - errors["base"] = "cannot_connect" + errors = await self.test_connection( + normalized_input[CONF_HOST], + normalized_input[CONF_PORT], + normalized_input.get(CONF_ENCRYPTION_KEY), + ) if not errors: return self.async_update_reload_and_abort( reconfigure_entry, data_updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: normalized_input[CONF_HOST], + CONF_PORT: normalized_input[CONF_PORT], + CONF_ENCRYPTION_KEY: normalized_input.get(CONF_ENCRYPTION_KEY), }, - title=user_input[CONF_HOST], + title=normalized_input[CONF_HOST], ) suggested_values: dict[str, Any] = { @@ -196,22 +248,33 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def test_connection(self, host: str, port: int) -> bool: + async def test_connection( + self, host: str, port: int, integration_key: str | None = None + ) -> dict[str, str]: """Test a connection to the Satel alarm.""" - controller = AsyncSatel(host, port) + errors: dict[str, str] = {} + controller = AsyncSatel(host, port, integration_key=integration_key) try: - return await controller.connect(check_busy=False) + await controller.connect(raise_exceptions=True) + except SatelPanelBusyError: + errors["base"] = "panel_busy" + except SatelConnectionInitializationError: + errors["base"] = "connection_initialization_failed" + except SatelConnectFailedError: + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception( "Unexpected error during connection test to %s:%s", host, port, ) - return False + errors["base"] = "unknown" finally: await controller.close() + return errors + class SatelOptionsFlow(OptionsFlow): """Handle Satel options flow.""" @@ -249,7 +312,9 @@ class PartitionSubentryFlowHandler(ConfigSubentryFlow): if not errors: return self.async_create_entry( - title=f"{user_input[CONF_NAME]} ({user_input[CONF_PARTITION_NUMBER]})", + title=( + f"{user_input[CONF_NAME]} ({user_input[CONF_PARTITION_NUMBER]})" + ), data=user_input, unique_id=unique_id, ) @@ -276,7 +341,10 @@ class PartitionSubentryFlowHandler(ConfigSubentryFlow): return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_PARTITION_NUMBER]})", + title=( + f"{user_input[CONF_NAME]}" + f" ({subconfig_entry.data[CONF_PARTITION_NUMBER]})" + ), data_updates=user_input, ) @@ -308,6 +376,15 @@ class ZoneSubentryFlowHandler(ConfigSubentryFlow): if existing_subentry.unique_id == unique_id: errors[CONF_ZONE_NUMBER] = "already_configured" + if not errors and user_input.get(CONF_ENABLE_TEMPERATURE_SENSOR, False): + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors = await _async_validate_zone_temperature_sensor( + self._get_entry(), + user_input[CONF_ZONE_NUMBER], + ) + if not errors: return self.async_create_entry( title=f"{user_input[CONF_NAME]} ({user_input[CONF_ZONE_NUMBER]})", @@ -318,13 +395,16 @@ class ZoneSubentryFlowHandler(ConfigSubentryFlow): return self.async_show_form( step_id="user", errors=errors, - data_schema=vol.Schema( - { - vol.Required(CONF_ZONE_NUMBER): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } - ).extend(ZONE_AND_OUTPUT_SCHEMA.schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ZONE_NUMBER): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ).extend(ZONE_SCHEMA.schema), + user_input or {}, + ), ) async def async_step_reconfigure( @@ -332,19 +412,41 @@ class ZoneSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Reconfigure existing zone.""" subconfig_entry = self._get_reconfigure_subentry() + errors: dict[str, str] = {} if user_input is not None: - return self.async_update_and_abort( - self._get_entry(), - subconfig_entry, - title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_ZONE_NUMBER]})", - data_updates=user_input, - ) + if user_input.get( + CONF_ENABLE_TEMPERATURE_SENSOR, False + ) and not subconfig_entry.data.get(CONF_ENABLE_TEMPERATURE_SENSOR, False): + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors = await _async_validate_zone_temperature_sensor( + self._get_entry(), + subconfig_entry.data[CONF_ZONE_NUMBER], + ) + + if not errors: + return self.async_update_and_abort( + self._get_entry(), + subconfig_entry, + title=( + f"{user_input[CONF_NAME]}" + f" ({subconfig_entry.data[CONF_ZONE_NUMBER]})" + ), + data_updates=user_input, + ) + + suggested_values: dict[str, Any] = { + **subconfig_entry.data, + **(user_input or {}), + } return self.async_show_form( step_id="reconfigure", + errors=errors, data_schema=self.add_suggested_values_to_schema( - ZONE_AND_OUTPUT_SCHEMA, subconfig_entry.data + ZONE_SCHEMA, suggested_values ), description_placeholders={ CONF_ZONE_NUMBER: subconfig_entry.data[CONF_ZONE_NUMBER] @@ -397,7 +499,10 @@ class OutputSubentryFlowHandler(ConfigSubentryFlow): return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_OUTPUT_NUMBER]})", + title=( + f"{user_input[CONF_NAME]}" + f" ({subconfig_entry.data[CONF_OUTPUT_NUMBER]})" + ), data_updates=user_input, ) @@ -422,7 +527,10 @@ class SwitchableOutputSubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} if user_input is not None: - unique_id = f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]}" + unique_id = ( + f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}" + f"_{user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]}" + ) for existing_subentry in self._get_entry().subentries.values(): if existing_subentry.unique_id == unique_id: @@ -430,7 +538,10 @@ class SwitchableOutputSubentryFlowHandler(ConfigSubentryFlow): if not errors: return self.async_create_entry( - title=f"{user_input[CONF_NAME]} ({user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]})", + title=( + f"{user_input[CONF_NAME]}" + f" ({user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]})" + ), data=user_input, unique_id=unique_id, ) @@ -457,7 +568,10 @@ class SwitchableOutputSubentryFlowHandler(ConfigSubentryFlow): return self.async_update_and_abort( self._get_entry(), subconfig_entry, - title=f"{user_input[CONF_NAME]} ({subconfig_entry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]})", + title=( + f"{user_input[CONF_NAME]}" + f" ({subconfig_entry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]})" + ), data_updates=user_input, ) diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 33e9c7a9572..929b8f27d09 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -17,3 +17,6 @@ CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number" CONF_ARM_HOME_MODE = "arm_home_mode" CONF_ZONE_TYPE = "type" +CONF_ENCRYPTION_KEY = "encryption_key" + +CONF_ENABLE_TEMPERATURE_SENSOR = "enable_temperature_sensor" diff --git a/homeassistant/components/satel_integra/coordinator.py b/homeassistant/components/satel_integra/coordinator.py index 19101ba3ec4..3767f5db247 100644 --- a/homeassistant/components/satel_integra/coordinator.py +++ b/homeassistant/components/satel_integra/coordinator.py @@ -1,8 +1,7 @@ """Coordinator for Satel Integra.""" -from __future__ import annotations - from dataclasses import dataclass +from datetime import timedelta import logging from satel_integra import AlarmState @@ -10,13 +9,15 @@ from satel_integra import AlarmState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .client import SatelClient +from .const import CONF_ENABLE_TEMPERATURE_SENSOR, CONF_ZONE_NUMBER, SUBENTRY_TYPE_ZONE _LOGGER = logging.getLogger(__name__) PARTITION_UPDATE_DEBOUNCE_DELAY = 0.15 +TEMPERATURE_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5) @dataclass @@ -27,6 +28,7 @@ class SatelIntegraData: coordinator_zones: SatelIntegraZonesCoordinator coordinator_outputs: SatelIntegraOutputsCoordinator coordinator_partitions: SatelIntegraPartitionsCoordinator + coordinator_temperatures: SatelIntegraTemperaturesCoordinator type SatelConfigEntry = ConfigEntry[SatelIntegraData] @@ -38,7 +40,11 @@ class SatelIntegraBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): config_entry: SatelConfigEntry def __init__( - self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + self, + hass: HomeAssistant, + entry: SatelConfigEntry, + client: SatelClient, + update_interval: timedelta | None = None, ) -> None: """Initialize the base coordinator.""" self.client = client @@ -48,8 +54,20 @@ class SatelIntegraBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): _LOGGER, config_entry=entry, name=f"{entry.entry_id} {self.__class__.__name__}", + update_interval=update_interval, ) + def setup(self) -> None: + """Set up client callbacks for this coordinator.""" + self.client.controller.add_connection_status_callback( + self._async_handle_connection_state_update + ) + + @callback + def _async_handle_connection_state_update(self) -> None: + """Notify listeners on connection state changes from the client.""" + self.async_update_listeners() + class SatelIntegraZonesCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]): """DataUpdateCoordinator to handle zone updates.""" @@ -124,3 +142,36 @@ class SatelIntegraPartitionsCoordinator( _LOGGER.debug("Sending request to update panel state") self._debouncer.async_schedule_call() + + +class SatelIntegraTemperaturesCoordinator( + SatelIntegraBaseCoordinator[dict[int, float | None]] +): + """DataUpdateCoordinator to poll zone temperatures.""" + + def __init__( + self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + entry, + client, + update_interval=TEMPERATURE_SENSOR_UPDATE_INTERVAL, + ) + + self._zone_numbers = [ + subentry.data[CONF_ZONE_NUMBER] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_ZONE) + if subentry.data.get(CONF_ENABLE_TEMPERATURE_SENSOR, False) + ] + + async def _async_update_data(self) -> dict[int, float | None]: + """Fetch temperatures from the alarm.""" + if not self._zone_numbers: + return {} + + try: + return await self.client.controller.read_temperatures(self._zone_numbers) + except Exception as err: + raise UpdateFailed(f"Failed to fetch temperatures: {err}") from err diff --git a/homeassistant/components/satel_integra/diagnostics.py b/homeassistant/components/satel_integra/diagnostics.py index 93e9bd104ee..d7e172819c6 100644 --- a/homeassistant/components/satel_integra/diagnostics.py +++ b/homeassistant/components/satel_integra/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Satel Integra.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -9,7 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant -TO_REDACT = {CONF_CODE} +from .const import CONF_ENCRYPTION_KEY + +TO_REDACT = {CONF_CODE, CONF_ENCRYPTION_KEY} async def async_get_config_entry_diagnostics( @@ -18,7 +18,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for the config entry.""" diag: dict[str, Any] = {} - diag["config_entry_data"] = dict(entry.data) + diag["config_entry_data"] = async_redact_data(entry.data, TO_REDACT) diag["config_entry_options"] = async_redact_data(entry.options, TO_REDACT) diag["subentries"] = dict(entry.subentries) diff --git a/homeassistant/components/satel_integra/entity.py b/homeassistant/components/satel_integra/entity.py index ac8e391aa96..e98575a55fc 100644 --- a/homeassistant/components/satel_integra/entity.py +++ b/homeassistant/components/satel_integra/entity.py @@ -1,7 +1,5 @@ """Satel Integra base entity.""" -from __future__ import annotations - from typing import TYPE_CHECKING from satel_integra import AsyncSatel @@ -35,7 +33,6 @@ class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator]( _attr_should_poll = False _attr_has_entity_name = True - _attr_name = None _controller: AsyncSatel @@ -65,3 +62,8 @@ class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator]( identifiers={(DOMAIN, self._attr_unique_id)}, via_device=(DOMAIN, config_entry_id), ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._controller.connected diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index d1f707cf6ff..1520a7c87ba 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["satel_integra"], "quality_scale": "bronze", - "requirements": ["satel-integra==1.1.0"] + "requirements": ["satel-integra==1.3.1"] } diff --git a/homeassistant/components/satel_integra/sensor.py b/homeassistant/components/satel_integra/sensor.py new file mode 100644 index 00000000000..b8ee13cfe64 --- /dev/null +++ b/homeassistant/components/satel_integra/sensor.py @@ -0,0 +1,78 @@ +"""Support for Satel Integra temperature sensors.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_ENABLE_TEMPERATURE_SENSOR, CONF_ZONE_NUMBER, SUBENTRY_TYPE_ZONE +from .coordinator import SatelConfigEntry, SatelIntegraTemperaturesCoordinator +from .entity import SatelIntegraEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SatelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Satel Integra temperature sensors.""" + runtime_data = config_entry.runtime_data + + temperature_subentries = filter( + lambda subentry: subentry.data[CONF_ENABLE_TEMPERATURE_SENSOR], + config_entry.get_subentries_of_type(SUBENTRY_TYPE_ZONE), + ) + + for subentry in temperature_subentries: + zone_num: int = subentry.data[CONF_ZONE_NUMBER] + + async_add_entities( + [ + SatelIntegraTemperatureSensor( + runtime_data.coordinator_temperatures, + config_entry.entry_id, + subentry, + zone_num, + ) + ], + config_subentry_id=subentry.subentry_id, + ) + + +class SatelIntegraTemperatureSensor( + SatelIntegraEntity[SatelIntegraTemperaturesCoordinator], SensorEntity +): + """Representation of a Satel Integra temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: SatelIntegraTemperaturesCoordinator, + config_entry_id: str, + subentry: ConfigSubentry, + device_number: int, + ) -> None: + """Initialize the temperature sensor.""" + super().__init__( + coordinator, + config_entry_id, + subentry, + device_number, + ) + + self._attr_unique_id = f"{self.unique_id}_temperature" + + @property + def native_value(self) -> float | None: + """Return the state.""" + return self.coordinator.data.get(self._device_number) diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 67fe3b94101..4d40282b536 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -1,7 +1,9 @@ { "common": { "code": "Access code", - "code_input_description": "Code to toggle switchable outputs" + "code_input_description": "Code to toggle switchable outputs", + "encryption_key": "Integration encryption key", + "encryption_key_description": "If the alarm panel requires encryption, enter the integration encryption key here." }, "config": { "abort": { @@ -9,7 +11,10 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "connection_initialization_failed": "Successfully connected, but failed to read data from the panel. Please check the integration encryption key is correct if your panel requires encryption.", + "panel_busy": "Successfully connected, but alarm panel reports it's busy. Please check no other connections are active.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "code": { @@ -22,20 +27,24 @@ }, "reconfigure": { "data": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key_description%]", "host": "[%key:component::satel_integra::config::step::user::data_description::host%]", "port": "[%key:component::satel_integra::config::step::user::data_description::port%]" } }, "user": { "data": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key_description%]", "host": "The IP address of the alarm panel", "port": "The port of the alarm panel" } @@ -144,9 +153,13 @@ } }, "zone": { + "abort": { + "entry_not_loaded": "Cannot validate zone temperature support while the integration is disabled." + }, "entry_type": "Zone", "error": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "zone_does_not_report_temperature": "This zone does not report temperature data." }, "initiate_flow": { "user": "Add zone" @@ -154,10 +167,12 @@ "step": { "reconfigure": { "data": { + "enable_temperature_sensor": "[%key:component::satel_integra::config_subentries::zone::step::user::data::enable_temperature_sensor%]", "name": "[%key:common::config_flow::data::name%]", "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data::type%]" }, "data_description": { + "enable_temperature_sensor": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::enable_temperature_sensor%]", "name": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::name%]", "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::type%]" }, @@ -165,11 +180,13 @@ }, "user": { "data": { + "enable_temperature_sensor": "Enable temperature sensor", "name": "[%key:common::config_flow::data::name%]", "type": "Zone type", "zone_number": "Zone number" }, "data_description": { + "enable_temperature_sensor": "Enable this if the zone also reports a temperature reading", "name": "The name to give to the sensor", "type": "Choose the device class you would like the sensor to show as", "zone_number": "Enter zone number to configure" @@ -180,8 +197,17 @@ } }, "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "connection_initialization_failed": { + "message": "[%key:component::satel_integra::config::error::connection_initialization_failed%]" + }, "missing_output_access_code": { "message": "Cannot control switchable outputs because no user code is configured for this Satel Integra entry. Configure a code in the integration options to enable output control." + }, + "panel_busy": { + "message": "[%key:component::satel_integra::config::error::panel_busy%]" } }, "options": { diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 4b33f7d4ef2..a330b8d57a7 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -1,7 +1,5 @@ """Support for Satel Integra modifiable outputs represented as switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity @@ -19,6 +17,8 @@ from .const import ( from .coordinator import SatelConfigEntry, SatelIntegraOutputsCoordinator from .entity import SatelIntegraEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -29,12 +29,9 @@ async def async_setup_entry( runtime_data = config_entry.runtime_data - switchable_output_subentries = filter( - lambda entry: entry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - config_entry.subentries.values(), - ) - - for subentry in switchable_output_subentries: + for subentry in config_entry.get_subentries_of_type( + SUBENTRY_TYPE_SWITCHABLE_OUTPUT + ): switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER] async_add_entities( @@ -56,6 +53,8 @@ class SatelIntegraSwitch( ): """Representation of an Satel Integra switch.""" + _attr_name = None + def __init__( self, coordinator: SatelIntegraOutputsCoordinator, diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index 6248ac8dd72..8dca2541ab1 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -1,7 +1,5 @@ """The Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - from pysaunum import SaunumClient, SaunumConnectionError, SaunumTimeoutError from homeassistant.config_entries import ConfigEntry @@ -41,6 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> try: client = await SaunumClient.create(host) except (SaunumConnectionError, SaunumTimeoutError) as exc: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc entry.async_on_unload(client.async_close) diff --git a/homeassistant/components/saunum/binary_sensor.py b/homeassistant/components/saunum/binary_sensor.py index 1a50b8f4abd..819270e6f84 100644 --- a/homeassistant/components/saunum/binary_sensor.py +++ b/homeassistant/components/saunum/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index f2615593cbc..2fb559d9714 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -1,7 +1,5 @@ """Climate platform for Saunum Leil Sauna Control Unit.""" -from __future__ import annotations - import asyncio from datetime import timedelta from typing import Any diff --git a/homeassistant/components/saunum/config_flow.py b/homeassistant/components/saunum/config_flow.py index a13525537bf..33eb3ef55e0 100644 --- a/homeassistant/components/saunum/config_flow.py +++ b/homeassistant/components/saunum/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/saunum/coordinator.py b/homeassistant/components/saunum/coordinator.py index 540da3b55f4..9ea66674ccb 100644 --- a/homeassistant/components/saunum/coordinator.py +++ b/homeassistant/components/saunum/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/saunum/diagnostics.py b/homeassistant/components/saunum/diagnostics.py index 5e42e926d33..72ed510815c 100644 --- a/homeassistant/components/saunum/diagnostics.py +++ b/homeassistant/components/saunum/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/saunum/entity.py b/homeassistant/components/saunum/entity.py index c0ed7bad517..5917692f9bd 100644 --- a/homeassistant/components/saunum/entity.py +++ b/homeassistant/components/saunum/entity.py @@ -1,7 +1,5 @@ """Base entity for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/saunum/light.py b/homeassistant/components/saunum/light.py index 30be9924f08..209e314c333 100644 --- a/homeassistant/components/saunum/light.py +++ b/homeassistant/components/saunum/light.py @@ -1,7 +1,5 @@ """Light platform for Saunum Leil Sauna Control Unit.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from pysaunum import SaunumException diff --git a/homeassistant/components/saunum/number.py b/homeassistant/components/saunum/number.py index d6da69deede..bf3b2bc428d 100644 --- a/homeassistant/components/saunum/number.py +++ b/homeassistant/components/saunum/number.py @@ -1,7 +1,5 @@ """Number platform for Saunum Leil Sauna Control Unit.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/saunum/sensor.py b/homeassistant/components/saunum/sensor.py index 7e9a09e0517..a4969c36889 100644 --- a/homeassistant/components/saunum/sensor.py +++ b/homeassistant/components/saunum/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Saunum Leil Sauna Control Unit integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/saunum/services.py b/homeassistant/components/saunum/services.py index c45c412e164..9992ec23638 100644 --- a/homeassistant/components/saunum/services.py +++ b/homeassistant/components/saunum/services.py @@ -1,7 +1,5 @@ """Define services for the Saunum integration.""" -from __future__ import annotations - from datetime import timedelta from pysaunum import ( diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index b4e23a36d82..7db7e2b1d10 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -1,7 +1,5 @@ """Allow users to set and activate scenes.""" -from __future__ import annotations - import functools as ft import importlib import logging diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index a2139529ccf..f213666de41 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -39,7 +39,6 @@ create: selector: text: entities: - advanced: true example: | light.tv_back_light: "on" light.ceiling: diff --git a/homeassistant/components/scene/trigger.py b/homeassistant/components/scene/trigger.py index 15f14f8c38a..cefeb14c7bb 100644 --- a/homeassistant/components/scene/trigger.py +++ b/homeassistant/components/scene/trigger.py @@ -1,36 +1,16 @@ """Provides triggers for scenes.""" -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA, - EntityTriggerBase, - Trigger, -) +from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger from . import DOMAIN -class SceneActivatedTrigger(EntityTriggerBase): +class SceneActivatedTrigger(StatelessEntityTriggerBase): """Trigger for scene entity activations.""" _domain_specs = {DOMAIN: DomainSpec()} - _schema = ENTITY_STATE_TRIGGER_SCHEMA - - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and different from the current state.""" - - # UNKNOWN is a valid from_state, otherwise the first time the scene is activated - # it would not trigger - if from_state.state == STATE_UNAVAILABLE: - return False - - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 1e0706621a5..3d60c005bd6 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -1,7 +1,5 @@ """Support for schedules in Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, time, timedelta import itertools @@ -70,7 +68,8 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: # Sort the schedule by start times schedule = sorted(schedule, key=lambda time_range: time_range[CONF_FROM]) - # Check if the start time of the next event is before the end time of the previous event + # Check if the start time of the next event is before + # the end time of the previous event previous_to = None for time_range in schedule: if time_range[CONF_FROM] >= time_range[CONF_TO]: @@ -271,7 +270,8 @@ class Schedule(CollectionEntity): self._attr_name = self._config[CONF_NAME] self._attr_unique_id = self._config[CONF_ID] - # Exclude any custom attributes that may be present on time ranges from recording. + # Exclude any custom attributes that may be present + # on time ranges from recording. self._unrecorded_attributes = self.all_custom_data_keys() self._Entity__combined_unrecorded_attributes = ( self._entity_component_unrecorded_attributes | self._unrecorded_attributes @@ -390,7 +390,7 @@ class Schedule(CollectionEntity): def all_custom_data_keys(self) -> frozenset[str]: """Return the set of all currently used custom data attribute keys.""" - data_keys = set() + data_keys: set[str] = set() for weekday in WEEKDAY_TO_CONF.values(): if not (weekday_config := self._config.get(weekday)): diff --git a/homeassistant/components/schedule/conditions.yaml b/homeassistant/components/schedule/conditions.yaml index d9d89d32932..342c54d0238 100644 --- a/homeassistant/components/schedule/conditions.yaml +++ b/homeassistant/components/schedule/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_off: *condition_common is_on: *condition_common diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index b8d3581a696..00abdc22f49 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is on" @@ -44,21 +52,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "get_schedule": { "description": "Retrieves the configured time ranges of one or multiple schedules.", @@ -76,6 +69,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::trigger_for_name%]" } }, "name": "Schedule block ended" @@ -85,6 +81,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::trigger_for_name%]" } }, "name": "Schedule block started" diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py index fb49e963a31..bb7a910bd6f 100644 --- a/homeassistant/components/schedule/trigger.py +++ b/homeassistant/components/schedule/trigger.py @@ -1,6 +1,6 @@ """Provides triggers for schedules.""" -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -20,10 +20,7 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): _to_states = {STATE_ON} def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state matches the expected ones.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check that the origin matches and the next event changed.""" from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) diff --git a/homeassistant/components/schedule/triggers.yaml b/homeassistant/components/schedule/triggers.yaml index e05c515b401..99df23eed56 100644 --- a/homeassistant/components/schedule/triggers.yaml +++ b/homeassistant/components/schedule/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: turned_off: *trigger_common turned_on: *trigger_common diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index c9dcf2d09af..37e74eea532 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -1,7 +1,5 @@ """The Schlage integration.""" -from __future__ import annotations - from pycognito.exceptions import WarrantException import pyschlage import voluptuous as vol @@ -36,7 +34,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_domain=LOCK_DOMAIN, schema={ vol.Required("name"): cv.string, - vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), + vol.Required("code"): vol.All(cv.string, cv.matches_regex(r"^\d{4,8}$")), vol.Optional("notify_on_use", default=True): cv.boolean, }, func=SERVICE_ADD_CODE, diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index 62e69b5cb4a..0974bec1cc6 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for Schlage binary_sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index 6e8f94473dd..39a1e51e4eb 100644 --- a/homeassistant/components/schlage/config_flow.py +++ b/homeassistant/components/schlage/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Schlage integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index eec143c574f..b6f4e70ab42 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Schlage integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py index 357f04f00db..949a02df980 100644 --- a/homeassistant/components/schlage/diagnostics.py +++ b/homeassistant/components/schlage/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Schlage.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/schlage/entity.py b/homeassistant/components/schlage/entity.py index cc4745e51cc..b189cfe3145 100644 --- a/homeassistant/components/schlage/entity.py +++ b/homeassistant/components/schlage/entity.py @@ -42,4 +42,8 @@ class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.device_id in self.coordinator.data.locks + return ( + super().available + and self.device_id in self.coordinator.data.locks + and self._lock.connected + ) diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index a7699d9004c..af58ffb6288 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -1,7 +1,5 @@ """Platform for Schlage lock integration.""" -from __future__ import annotations - from typing import Any from pyschlage.code import AccessCode diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index cb142f01717..26b78e5340f 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -1,7 +1,5 @@ """Platform for Schlage select integration.""" -from __future__ import annotations - from pyschlage.lock import AUTO_LOCK_TIMES from homeassistant.components.select import SelectEntity, SelectEntityDescription diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index 494efc7585a..689b36c4f92 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -1,7 +1,5 @@ """Platform for Schlage sensor integration.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index c40d0c41e88..3bd376f933d 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -1,7 +1,5 @@ """Platform for Schlage switch integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index 94eb00fe11b..5ce65fa14ab 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -1,7 +1,5 @@ """Support for Schluter thermostats.""" -from __future__ import annotations - import logging from typing import Any @@ -62,6 +60,7 @@ async def async_setup_platform( coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name="schluter", update_method=async_update_data, update_interval=SCAN_INTERVAL, @@ -136,5 +135,6 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): try: if target_temp is not None: self._api.set_temperature(self._session_id, serial_number, target_temp) + # pylint: disable-next=home-assistant-action-swallowed-exception except RequestException as ex: _LOGGER.error("An error occurred while setting temperature: %s", ex) diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 5c39b57f785..740b80a54e3 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -1,30 +1,41 @@ """The scrape component.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine +from copy import deepcopy from datetime import timedelta +import logging +from types import MappingProxyType from typing import Any import voluptuous as vol from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_HEADERS, + CONF_NAME, + CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, discovery, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, @@ -32,11 +43,22 @@ from homeassistant.helpers.trigger_template_entity import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS +from .const import ( + CONF_ADVANCED, + CONF_AUTH, + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + PLATFORMS, +) from .coordinator import ScrapeCoordinator type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator] +_LOGGER = logging.getLogger(__name__) + SENSOR_SCHEMA = vol.Schema( { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, @@ -103,7 +125,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool: """Set up Scrape from a config entry.""" - rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options)) + config: dict[str, Any] = dict(entry.options) + # Config flow uses sections but the COMBINED SCHEMA does not + # so we need to flatten the config here + config.update(config.pop(CONF_ADVANCED, {})) + config.update(config.pop(CONF_AUTH, {})) + + rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(config)) rest = create_rest_data_from_config(hass, rest_config) coordinator = ScrapeCoordinator( @@ -117,17 +145,160 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version > 2: + # Don't migrate from future version + return False + + if entry.version == 1: + old_to_new_sensor_id = {} + for sensor_config in entry.options[SENSOR_DOMAIN]: + # Create a new sub config entry per sensor + title = sensor_config[CONF_NAME] + old_unique_id = sensor_config[CONF_UNIQUE_ID] + subentry_config = { + CONF_INDEX: sensor_config[CONF_INDEX], + CONF_SELECT: sensor_config[CONF_SELECT], + CONF_ADVANCED: {}, + } + + for sensor_advanced_key in ( + CONF_ATTRIBUTE, + CONF_VALUE_TEMPLATE, + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + ): + if sensor_advanced_key not in sensor_config: + continue + subentry_config[CONF_ADVANCED][sensor_advanced_key] = sensor_config[ + sensor_advanced_key + ] + + new_sub_entry = ConfigSubentry( + data=MappingProxyType(subentry_config), + subentry_type="entity", + title=title, + unique_id=None, + ) + _LOGGER.debug( + "Migrating sensor %s with unique id %s to sub config" + " entry id %s, old data %s, new data %s", + title, + old_unique_id, + new_sub_entry.subentry_id, + sensor_config, + subentry_config, + ) + old_to_new_sensor_id[old_unique_id] = new_sub_entry.subentry_id + hass.config_entries.async_add_subentry(entry, new_sub_entry) + + # Use the new sub config entry id as the unique id for the sensor entity + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entities: + if (old_unique_id := entity.unique_id) in old_to_new_sensor_id: + new_unique_id = old_to_new_sensor_id[old_unique_id] + _LOGGER.debug( + "Migrating entity %s with unique id %s to new unique id %s", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + entity_reg.async_update_entity( + entity.entity_id, + config_entry_id=entry.entry_id, + config_subentry_id=new_unique_id, + new_unique_id=new_unique_id, + ) + + # Use the new sub config entry id as the identifier for the sensor device + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, entry.entry_id) + for device in devices: + for domain, identifier in device.identifiers: + if domain != DOMAIN or identifier not in old_to_new_sensor_id: + continue + + subentry_id = old_to_new_sensor_id[identifier] + new_identifiers = deepcopy(device.identifiers) + new_identifiers.remove((domain, identifier)) + new_identifiers.add((domain, old_to_new_sensor_id[identifier])) + _LOGGER.debug( + "Migrating device %s with identifiers %s to new identifiers %s", + device.id, + device.identifiers, + new_identifiers, + ) + device_reg.async_update_device( + device.id, + add_config_entry_id=entry.entry_id, + add_config_subentry_id=subentry_id, + new_identifiers=new_identifiers, + ) + + # Removing None from the list of subentries if existing + # as the device should only belong to the subentry + # and not the main config entry + device_reg.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + # Update the resource config + new_config_entry_data = dict(entry.options) + new_config_entry_data[CONF_AUTH] = {} + new_config_entry_data[CONF_ADVANCED] = {} + new_config_entry_data.pop(SENSOR_DOMAIN, None) + for resource_advanced_key in ( + CONF_HEADERS, + CONF_VERIFY_SSL, + CONF_TIMEOUT, + CONF_ENCODING, + ): + if resource_advanced_key in new_config_entry_data: + new_config_entry_data[CONF_ADVANCED][resource_advanced_key] = ( + new_config_entry_data.pop(resource_advanced_key) + ) + for resource_auth_key in (CONF_AUTHENTICATION, CONF_USERNAME, CONF_PASSWORD): + if resource_auth_key in new_config_entry_data: + new_config_entry_data[CONF_AUTH][resource_auth_key] = ( + new_config_entry_data.pop(resource_auth_key) + ) + + _LOGGER.debug( + "Migrating config entry %s from version 1 to version 2 with data %s", + entry.entry_id, + new_config_entry_data, + ) + hass.config_entries.async_update_entry( + entry, version=2, options=new_config_entry_data + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool: """Unload Scrape config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def update_listener(hass: HomeAssistant, entry: ScrapeConfigEntry) -> None: + """Handle config entry update.""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry ) -> bool: """Remove Scrape config entry from a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 768416aca3e..90cd71b4fc3 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -1,27 +1,36 @@ """Adds config flow for Scrape integration.""" -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any, cast -import uuid +from copy import deepcopy +import logging +from typing import Any import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.components.rest import create_rest_data_from_config -from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import +from homeassistant.components.rest.data import ( # pylint: disable=home-assistant-component-root-import DEFAULT_TIMEOUT, ) -from homeassistant.components.rest.schema import ( # pylint: disable=hass-component-root-import +from homeassistant.components.rest.schema import ( # pylint: disable=home-assistant-component-root-import DEFAULT_METHOD, METHODS, ) from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + FlowType, + OptionsFlow, + SubentryFlowContext, + SubentryFlowResult, +) from homeassistant.const import ( CONF_ATTRIBUTE, CONF_AUTHENTICATION, @@ -33,7 +42,6 @@ from homeassistant.const import ( CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -42,15 +50,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, UnitOfTemperature, ) -from homeassistant.core import async_get_hass -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaCommonFlowHandler, - SchemaConfigFlowHandler, - SchemaFlowError, - SchemaFlowFormStep, - SchemaFlowMenuStep, -) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, NumberSelector, @@ -69,6 +69,8 @@ from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY from . import COMBINED_SCHEMA from .const import ( + CONF_ADVANCED, + CONF_AUTH, CONF_ENCODING, CONF_INDEX, CONF_SELECT, @@ -78,243 +80,244 @@ from .const import ( DOMAIN, ) -RESOURCE_SETUP = { - vol.Required(CONF_RESOURCE): TextSelector( - TextSelectorConfig(type=TextSelectorType.URL) - ), - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( - SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) - ), - vol.Optional(CONF_PAYLOAD): ObjectSelector(), - vol.Optional(CONF_AUTHENTICATION): SelectSelector( - SelectSelectorConfig( - options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional(CONF_USERNAME): TextSelector(), - vol.Optional(CONF_PASSWORD): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - vol.Optional(CONF_HEADERS): ObjectSelector(), - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), -} +_LOGGER = logging.getLogger(__name__) -SENSOR_SETUP = { - vol.Required(CONF_SELECT): TextSelector(), - vol.Optional(CONF_INDEX, default=0): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_ATTRIBUTE): TextSelector(), - vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_AVAILABILITY): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( - SelectSelectorConfig( - options=[ - cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM - ], - mode=SelectSelectorMode.DROPDOWN, - translation_key="device_class", - sort=True, - ) - ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( - SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) - ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( - SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], - custom_value=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key="unit_of_measurement", - sort=True, - ) - ), -} - - -async def validate_rest_setup( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Validate rest setup.""" - hass = async_get_hass() - rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input) - try: - rest = create_rest_data_from_config(hass, rest_config) - await rest.async_update() - except Exception as err: - raise SchemaFlowError("resource_error") from err - if rest.data is None: - raise SchemaFlowError("resource_error") - return user_input - - -async def validate_sensor_setup( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Validate sensor input.""" - user_input[CONF_INDEX] = int(user_input[CONF_INDEX]) - user_input[CONF_UNIQUE_ID] = str(uuid.uuid1()) - - # Standard behavior is to merge the result with the options. - # In this case, we want to add a sub-item so we update the options directly. - sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) - sensors.append(user_input) - return {} - - -async def validate_select_sensor( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Store sensor index in flow state.""" - handler.flow_state["_idx"] = int(user_input[CONF_INDEX]) - return {} - - -async def get_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: - """Return schema for selecting a sensor.""" - return vol.Schema( - { - vol.Required(CONF_INDEX): vol.In( - { - str(index): config[CONF_NAME] - for index, config in enumerate(handler.options[SENSOR_DOMAIN]) - }, - ) - } - ) - - -async def get_edit_sensor_suggested_values( - handler: SchemaCommonFlowHandler, -) -> dict[str, Any]: - """Return suggested values for sensor editing.""" - idx: int = handler.flow_state["_idx"] - return dict(handler.options[SENSOR_DOMAIN][idx]) - - -async def validate_sensor_edit( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Update edited sensor.""" - user_input[CONF_INDEX] = int(user_input[CONF_INDEX]) - - # Standard behavior is to merge the result with the options. - # In this case, we want to add a sub-item so we update the options directly, - # including popping omitted optional schema items. - idx: int = handler.flow_state["_idx"] - handler.options[SENSOR_DOMAIN][idx].update(user_input) - for key in DATA_SCHEMA_EDIT_SENSOR.schema: - if isinstance(key, vol.Optional) and key not in user_input: - # Key not present, delete keys old value (if present) too - handler.options[SENSOR_DOMAIN][idx].pop(key, None) - return {} - - -async def get_remove_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: - """Return schema for sensor removal.""" - return vol.Schema( - { - vol.Required(CONF_INDEX): cv.multi_select( - { - str(index): config[CONF_NAME] - for index, config in enumerate(handler.options[SENSOR_DOMAIN]) - }, - ) - } - ) - - -async def validate_remove_sensor( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Validate remove sensor.""" - removed_indexes: set[str] = set(user_input[CONF_INDEX]) - - # Standard behavior is to merge the result with the options. - # In this case, we want to remove sub-items so we update the options directly. - entity_registry = er.async_get(handler.parent_handler.hass) - sensors: list[dict[str, Any]] = [] - sensor: dict[str, Any] - for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]): - if str(index) not in removed_indexes: - sensors.append(sensor) - elif entity_id := entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, sensor[CONF_UNIQUE_ID] - ): - entity_registry.async_remove(entity_id) - handler.options[SENSOR_DOMAIN] = sensors - return {} - - -DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP) -DATA_SCHEMA_EDIT_SENSOR = vol.Schema(SENSOR_SETUP) -DATA_SCHEMA_SENSOR = vol.Schema( +RESOURCE_SETUP = vol.Schema( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - **SENSOR_SETUP, + vol.Required(CONF_RESOURCE): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( + SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) + ), + vol.Optional(CONF_PAYLOAD): ObjectSelector(), + vol.Required(CONF_AUTH): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_AUTHENTICATION): SelectSelector( + SelectSelectorConfig( + options=[ + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, autocomplete="username" + ) + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } + ), + data_entry_flow.SectionConfig(collapsed=True), + ), + vol.Required(CONF_ADVANCED): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_HEADERS): ObjectSelector(), + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): BooleanSelector(), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_ENCODING, default=DEFAULT_ENCODING + ): TextSelector(), + } + ), + data_entry_flow.SectionConfig(collapsed=True), + ), } ) -CONFIG_FLOW = { - "user": SchemaFlowFormStep( - schema=DATA_SCHEMA_RESOURCE, - next_step="sensor", - validate_user_input=validate_rest_setup, - ), - "sensor": SchemaFlowFormStep( - schema=DATA_SCHEMA_SENSOR, - validate_user_input=validate_sensor_setup, - ), -} -OPTIONS_FLOW = { - "init": SchemaFlowMenuStep( - ["resource", "add_sensor", "select_edit_sensor", "remove_sensor"] - ), - "resource": SchemaFlowFormStep( - DATA_SCHEMA_RESOURCE, - validate_user_input=validate_rest_setup, - ), - "add_sensor": SchemaFlowFormStep( - DATA_SCHEMA_SENSOR, - suggested_values=None, - validate_user_input=validate_sensor_setup, - ), - "select_edit_sensor": SchemaFlowFormStep( - get_select_sensor_schema, - suggested_values=None, - validate_user_input=validate_select_sensor, - next_step="edit_sensor", - ), - "edit_sensor": SchemaFlowFormStep( - DATA_SCHEMA_EDIT_SENSOR, - suggested_values=get_edit_sensor_suggested_values, - validate_user_input=validate_sensor_edit, - ), - "remove_sensor": SchemaFlowFormStep( - get_remove_sensor_schema, - suggested_values=None, - validate_user_input=validate_remove_sensor, - ), -} +SENSOR_SETTINGS = vol.Schema( + { + vol.Required(CONF_SELECT): TextSelector(), + vol.Optional(CONF_INDEX, default=0): vol.All( + NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + vol.Required(CONF_ADVANCED): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_ATTRIBUTE): TextSelector(), + vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), + vol.Optional(CONF_AVAILABILITY): TemplateSelector(), + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", + sort=True, + ) + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", + sort=True, + ) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", + sort=True, + ) + ), + } + ), + data_entry_flow.SectionConfig(collapsed=True), + ), + } +) +SENSOR_SETUP = vol.Schema( + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field + {vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector()} +).extend(SENSOR_SETTINGS.schema) -class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): - """Handle a config flow for Scrape.""" +async def validate_rest_setup( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate rest setup.""" + config = deepcopy(user_input) + config.update(config.pop(CONF_ADVANCED, {})) + config.update(config.pop(CONF_AUTH, {})) + rest_config: dict[str, Any] = COMBINED_SCHEMA(config) + try: + rest = create_rest_data_from_config(hass, rest_config) + await rest.async_update() + except Exception: + _LOGGER.exception("Error when getting resource %s", config[CONF_RESOURCE]) + return {"base": "resource_error"} + if rest.data is None: + return {"base": "no_data"} + return {} - config_flow = CONFIG_FLOW - options_flow = OPTIONS_FLOW - options_flow_reloads = True - def async_config_entry_title(self, options: Mapping[str, Any]) -> str: - """Return config entry title.""" - return cast(str, options[CONF_RESOURCE]) +class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN): + """Scrape configuration flow.""" + + VERSION = 2 + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow: + """Get the options flow for this handler.""" + return ScrapeOptionFlow() + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"entity": ScrapeSubentryFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User flow to create the main config entry.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await validate_rest_setup(self.hass, user_input) + title = user_input[CONF_RESOURCE] + if not errors: + return self.async_create_entry(data={}, options=user_input, title=title) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + RESOURCE_SETUP, user_input or {} + ), + errors=errors, + ) + + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Start subentry flow after creating main entry.""" + subentry_result = await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, "entity"), + context=SubentryFlowContext(source=SOURCE_USER), + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_result["flow_id"], + ) + return result + + +class ScrapeOptionFlow(OptionsFlow): + """Scrape Options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage Scrape options.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await validate_rest_setup(self.hass, user_input) + if not errors: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + RESOURCE_SETUP, + user_input or self.config_entry.options, + ), + errors=errors, + ) + + +class ScrapeSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + title = user_input.pop("name") + return self.async_create_entry(data=user_input, title=title) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + SENSOR_SETUP, user_input or {} + ), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to reconfigure a sensor subentry.""" + if user_input is not None: + self.async_update_and_abort( + self._get_entry(), self._get_reconfigure_subentry(), data=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + SENSOR_SETTINGS, user_input or self._get_reconfigure_subentry().data + ), + ) diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py index 292f0d0b247..d2cab27d3c5 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -1,7 +1,5 @@ """Constants for Scrape integration.""" -from __future__ import annotations - from datetime import timedelta from homeassistant.const import Platform @@ -14,6 +12,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) PLATFORMS = [Platform.SENSOR] +CONF_ADVANCED = "advanced" +CONF_AUTH = "auth" CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/coordinator.py b/homeassistant/components/scrape/coordinator.py index d491e5925e1..e594f1ff1b9 100644 --- a/homeassistant/components/scrape/coordinator.py +++ b/homeassistant/components/scrape/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the scrape component.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -61,7 +59,8 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): raise UpdateFailed("REST data is not available") # Detect if content is XML and use appropriate parser - # Check Content-Type header first (most reliable), then fall back to content detection + # Check Content-Type header first (most reliable), + # then fall back to content detection parser = "lxml" headers = self._rest.headers content_type = headers.get("Content-Type", "") if headers else "" @@ -78,7 +77,8 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): after_xml_lower = after_xml.lower() is_html = after_xml_lower.startswith((" None: """Set up the Web scrape sensor.""" - discovery_info = cast(DiscoveryInfoType, discovery_info) + if discovery_info is None: + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + SENSOR_DOMAIN, + yaml_config_under_integration_supported=True, + learn_more_url="https://www.home-assistant.io/integrations/scrape/", + logger=_LOGGER, + ) + return + coordinator: ScrapeCoordinator = discovery_info["coordinator"] sensors_config: list[ConfigType] = discovery_info["configs"] @@ -70,7 +80,7 @@ async def async_setup_platform( entities: list[ScrapeSensor] = [] for sensor_config in sensors_config: - trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]} + trigger_entity_config = {} for key in TRIGGER_ENTITY_OPTIONS: if key not in sensor_config: continue @@ -98,23 +108,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Scrape sensor entry.""" - entities: list = [] - coordinator = entry.runtime_data - config = dict(entry.options) - for sensor in config["sensor"]: + for subentry in entry.subentries.values(): + sensor = dict(subentry.data) + sensor.update(sensor.pop("advanced", {})) + sensor[CONF_UNIQUE_ID] = subentry.subentry_id + sensor[CONF_NAME] = subentry.title + sensor_config: ConfigType = vol.Schema( TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA )(sensor) - name: str = sensor_config[CONF_NAME] value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) value_template: ValueTemplate | None = ( ValueTemplate(value_string, hass) if value_string is not None else None ) - trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} + trigger_entity_config: dict[str, str | Template | None] = {} for key in TRIGGER_ENTITY_OPTIONS: if key not in sensor_config: continue @@ -123,21 +134,22 @@ async def async_setup_entry( continue trigger_entity_config[key] = sensor_config[key] - entities.append( - ScrapeSensor( - hass, - coordinator, - trigger_entity_config, - sensor_config[CONF_SELECT], - sensor_config.get(CONF_ATTRIBUTE), - sensor_config[CONF_INDEX], - value_template, - False, - ) + async_add_entities( + [ + ScrapeSensor( + hass, + coordinator, + trigger_entity_config, + sensor_config[CONF_SELECT], + sensor_config.get(CONF_ATTRIBUTE), + sensor_config[CONF_INDEX], + value_template, + False, + ) + ], + config_subentry_id=subentry.subentry_id, ) - async_add_entities(entities) - class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity): """Representation of a web scrape sensor.""" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 4aeae3ce685..8ffc5d0606b 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -4,134 +4,140 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { - "resource_error": "Could not update rest data. Verify your configuration" + "no_data": "REST data is empty. Verify your configuration", + "resource_error": "Could not update REST data. Verify your configuration" }, "step": { - "sensor": { - "data": { - "attribute": "Attribute", - "availability": "Availability template", - "device_class": "Device class", - "index": "Index", - "name": "[%key:common::config_flow::data::name%]", - "select": "Select", - "state_class": "State class", - "unit_of_measurement": "Unit of measurement", - "value_template": "Value template" - }, - "data_description": { - "attribute": "Get value of an attribute on the selected tag.", - "availability": "Defines a template to get the availability of the sensor.", - "device_class": "The type/class of the sensor to set the icon in the frontend.", - "index": "Defines which of the elements returned by the CSS selector to use.", - "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.", - "state_class": "The state_class of the sensor.", - "unit_of_measurement": "Choose unit of measurement or create your own.", - "value_template": "Defines a template to get the state of the sensor." - } - }, "user": { "data": { - "authentication": "Select authentication method", - "encoding": "Character encoding", - "headers": "Headers", "method": "Method", - "password": "[%key:common::config_flow::data::password%]", "payload": "Payload", - "resource": "Resource", - "timeout": "Timeout", - "username": "[%key:common::config_flow::data::username%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "resource": "Resource" }, "data_description": { - "authentication": "Type of the HTTP authentication. Either basic or digest.", - "encoding": "Character encoding to use. Defaults to UTF-8.", - "headers": "Headers to use for the web request.", "payload": "Payload to use when method is POST.", - "resource": "The URL to the website that contains the value.", - "timeout": "Timeout for connection to website.", - "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed." + "resource": "The URL to the website that contains the value." + }, + "sections": { + "advanced": { + "data": { + "encoding": "Character encoding", + "headers": "Headers", + "timeout": "Timeout", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "encoding": "Character encoding to use. Defaults to UTF-8.", + "headers": "Headers to use for the web request.", + "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" + }, + "auth": { + "data": { + "authentication": "Select authentication method", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "authentication": "Type of the HTTP authentication. Either basic or digest." + }, + "description": "Provide authentication details to access the resource.", + "name": "Authentication settings" + } + } + } + } + }, + "config_subentries": { + "entity": { + "entry_type": "Sensor", + "initiate_flow": { + "user": "Add sensor" + }, + "step": { + "user": { + "data": { + "index": "Index", + "select": "Select" + }, + "data_description": { + "index": "Defines which of the elements returned by the CSS selector to use.", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details." + }, + "sections": { + "advanced": { + "data": { + "attribute": "Attribute", + "availability": "Availability template", + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template" + }, + "data_description": { + "attribute": "Get value of an attribute on the selected tag.", + "availability": "Defines a template to get the availability of the sensor.", + "device_class": "The type/class of the sensor to set the icon in the frontend.", + "state_class": "The state_class of the sensor.", + "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" + } + } } } } }, "options": { + "error": { + "no_data": "[%key:component::scrape::config::error::no_data%]", + "resource_error": "[%key:component::scrape::config::error::resource_error%]" + }, "step": { - "add_sensor": { - "data": { - "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", - "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", - "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", - "index": "[%key:component::scrape::config::step::sensor::data::index%]", - "name": "[%key:common::config_flow::data::name%]", - "select": "[%key:component::scrape::config::step::sensor::data::select%]", - "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", - "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]", - "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]" - }, - "data_description": { - "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", - "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", - "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", - "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", - "select": "[%key:component::scrape::config::step::sensor::data_description::select%]", - "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", - "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]", - "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]" - } - }, - "edit_sensor": { - "data": { - "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", - "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", - "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", - "index": "[%key:component::scrape::config::step::sensor::data::index%]", - "name": "[%key:common::config_flow::data::name%]", - "select": "[%key:component::scrape::config::step::sensor::data::select%]", - "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", - "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]", - "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]" - }, - "data_description": { - "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", - "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", - "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", - "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", - "select": "[%key:component::scrape::config::step::sensor::data_description::select%]", - "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", - "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]", - "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]" - } - }, "init": { - "menu_options": { - "add_sensor": "Add sensor", - "remove_sensor": "Remove sensor", - "resource": "Configure resource", - "select_edit_sensor": "Configure sensor" - } - }, - "resource": { "data": { - "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", - "encoding": "[%key:component::scrape::config::step::user::data::encoding%]", - "headers": "[%key:component::scrape::config::step::user::data::headers%]", "method": "[%key:component::scrape::config::step::user::data::method%]", - "password": "[%key:common::config_flow::data::password%]", "payload": "[%key:component::scrape::config::step::user::data::payload%]", - "resource": "[%key:component::scrape::config::step::user::data::resource%]", - "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", - "username": "[%key:common::config_flow::data::username%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "resource": "[%key:component::scrape::config::step::user::data::resource%]" }, "data_description": { - "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", - "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]", - "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "payload": "[%key:component::scrape::config::step::user::data_description::payload%]", - "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", - "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", - "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]" + "resource": "[%key:component::scrape::config::step::user::data_description::resource%]" + }, + "sections": { + "advanced": { + "data": { + "encoding": "[%key:component::scrape::config::step::user::sections::advanced::data::encoding%]", + "headers": "[%key:component::scrape::config::step::user::sections::advanced::data::headers%]", + "timeout": "[%key:component::scrape::config::step::user::sections::advanced::data::timeout%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "encoding": "[%key:component::scrape::config::step::user::sections::advanced::data_description::encoding%]", + "headers": "[%key:component::scrape::config::step::user::sections::advanced::data_description::headers%]", + "timeout": "[%key:component::scrape::config::step::user::sections::advanced::data_description::timeout%]", + "verify_ssl": "[%key:component::scrape::config::step::user::sections::advanced::data_description::verify_ssl%]" + }, + "description": "[%key:component::scrape::config::step::user::sections::advanced::description%]", + "name": "[%key:component::scrape::config::step::user::sections::advanced::name%]" + }, + "auth": { + "data": { + "authentication": "[%key:component::scrape::config::step::user::sections::auth::data::authentication%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "authentication": "[%key:component::scrape::config::step::user::sections::auth::data_description::authentication%]" + }, + "description": "[%key:component::scrape::config::step::user::sections::auth::description%]", + "name": "[%key:component::scrape::config::step::user::sections::auth::name%]" + } } } } diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index c6e4f0c279c..39ec28d1571 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -145,7 +145,8 @@ async def _async_migrate_entries( continue if device == "pump" and source_index is None: _LOGGER.debug( - "Unable to parse 'source_index' from existing unique_id for pump entity '%s'", + "Unable to parse 'source_index' from existing" + " unique_id for pump entity '%s'", source_key, ) continue @@ -160,7 +161,8 @@ async def _async_migrate_entries( entry.domain, entry.platform, new_unique_id ): _LOGGER.debug( - "Cannot migrate '%s' to unique_id '%s', already exists for entity '%s'. Aborting", + "Cannot migrate '%s' to unique_id '%s'," + " already exists for entity '%s'. Aborting", entry.unique_id, new_unique_id, existing_entity_id, diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index b4deb9b36aa..58afe7e76d5 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ScreenLogic.""" -from __future__ import annotations - import logging from typing import Any @@ -206,6 +204,8 @@ class ScreenLogicOptionsFlowHandler(OptionsFlow): step_id="init", data_schema=vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Required( CONF_SCAN_INTERVAL, default=current_interval, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 746abc2fde6..0798a023253 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -138,7 +138,8 @@ class ScreenLogicPushEntity(ScreenLogicEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - # For push entities, only take updates from the coordinator if availability changes. + # For push entities, only take updates from the + # coordinator if availability changes. if self.coordinator.last_update_success != self._last_update_success: self._async_data_updated() diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 3901f1cfd37..677b8fbb549 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -74,7 +74,7 @@ async def _get_coordinators( f"Failed to call service '{service_call.service}'. Config entry " f"'{entry_id}' is not a {DOMAIN} config" ) - if not config_entry.state == ConfigEntryState.LOADED: + if config_entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( f"Failed to call service '{service_call.service}'. Config entry " f"'{entry_id}' not loaded" diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 5542b7bf611..730a8e16212 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -1,7 +1,5 @@ """Support for scripts.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass @@ -65,7 +63,6 @@ from homeassistant.helpers.script import ( from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task from homeassistant.util.dt import parse_datetime @@ -91,7 +88,6 @@ SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema( RELOAD_SERVICE_SCHEMA = vol.Schema({}) -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the script is on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) @@ -770,11 +766,14 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): async def async_will_remove_from_hass(self) -> None: """Stop script and remove service when it will be removed from HA.""" - await self.script.async_stop() - - # remove service self.hass.services.async_remove(DOMAIN, self._attr_unique_id) + if self.registry_entry and self.registry_entry.entity_id != self.entity_id: + # Entity ID change, do not unload the script as it will be reused. + await self.script.async_stop() + return + await self.script.async_unload() + @websocket_api.websocket_command({"type": "script/config", "entity_id": str}) def websocket_config( diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index bec6167b1f5..cb49979dcbb 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -1,7 +1,5 @@ """Config validation helper for the script integration.""" -from __future__ import annotations - from collections.abc import Mapping from contextlib import suppress from enum import StrEnum diff --git a/homeassistant/components/script/trace.py b/homeassistant/components/script/trace.py index 1b8ec56227e..afbe30825f7 100644 --- a/homeassistant/components/script/trace.py +++ b/homeassistant/components/script/trace.py @@ -1,7 +1,5 @@ """Trace support for script.""" -from __future__ import annotations - from collections.abc import Generator from contextlib import contextmanager from typing import Any diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index 4c4d2c2949a..355b6a31557 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -1,7 +1,5 @@ """Support for SCSGate covers.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 6729364ad19..cd72402d7f8 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -1,7 +1,5 @@ """Support for SCSGate lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index 296c7097e06..7ae2cab0229 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -1,7 +1,5 @@ """Support for SCSGate switches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index adec8ff1257..214da9aacb1 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -1,7 +1,5 @@ """The Search integration.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Iterable from enum import StrEnum @@ -137,7 +135,8 @@ class Searcher: # Scripts referencing this area self._add(ItemType.SCRIPT, script.scripts_with_area(self.hass, area_id)) - # Entity in this area, will extend this with the entities of the devices in this area + # Entity in this area, will extend this with + # the entities of the devices in this area entity_entries = er.async_entries_for_area(self._entity_registry, area_id) # Devices in this area diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 77c408f4e3f..4178a76f1dd 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Season integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index e87a607b167..64ab58be730 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -1,7 +1,5 @@ """Support for Season sensors.""" -from __future__ import annotations - from datetime import datetime import ephem diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 18f520f9a23..851d7ead00b 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -1,7 +1,5 @@ """Component to allow selecting an option from a list as platforms.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, final diff --git a/homeassistant/components/select/conditions.yaml b/homeassistant/components/select/conditions.yaml index 18ff8c47c0c..d6719808a99 100644 --- a/homeassistant/components/select/conditions.yaml +++ b/homeassistant/components/select/conditions.yaml @@ -8,11 +8,13 @@ is_option_selected: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: option: context: filter_target: target diff --git a/homeassistant/components/select/const.py b/homeassistant/components/select/const.py index 1e2aa488e8b..38a1b6169b8 100644 --- a/homeassistant/components/select/const.py +++ b/homeassistant/components/select/const.py @@ -4,6 +4,7 @@ DOMAIN = "select" ATTR_CYCLE = "cycle" ATTR_OPTIONS = "options" +# pylint: disable-next=home-assistant-duplicate-const ATTR_OPTION = "option" CONF_CYCLE = "cycle" @@ -12,5 +13,6 @@ CONF_OPTION = "option" SERVICE_SELECT_FIRST = "select_first" SERVICE_SELECT_LAST = "select_last" SERVICE_SELECT_NEXT = "select_next" +# pylint: disable-next=home-assistant-duplicate-const SERVICE_SELECT_OPTION = "select_option" SERVICE_SELECT_PREVIOUS = "select_previous" diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index 1801d34d182..b796d1c5a8e 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Select.""" -from __future__ import annotations - from contextlib import suppress import voluptuous as vol diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index cd99009dd90..87f38bad35b 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -1,7 +1,5 @@ """Provide the device conditions for Select.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index b09a25ba082..41d3ef5d177 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Select.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/select/reproduce_state.py b/homeassistant/components/select/reproduce_state.py index 88ccda6f07d..24297205c98 100644 --- a/homeassistant/components/select/reproduce_state.py +++ b/homeassistant/components/select/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce a Select entity state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/select/significant_change.py b/homeassistant/components/select/significant_change.py index c9cd6b735d6..da828916c8d 100644 --- a/homeassistant/components/select/significant_change.py +++ b/homeassistant/components/select/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Select state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 77f6d51a7fb..2a2c894eca7 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -6,6 +6,9 @@ "behavior": { "name": "Condition passes if" }, + "for": { + "name": "For at least" + }, "option": { "description": "The options to check for.", "name": "Option" @@ -51,14 +54,6 @@ "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - } - }, "services": { "select_first": { "description": "Selects the first option of a select.", diff --git a/homeassistant/components/select/trigger.py b/homeassistant/components/select/trigger.py index d33f0656c10..db2bd0a6de2 100644 --- a/homeassistant/components/select/trigger.py +++ b/homeassistant/components/select/trigger.py @@ -1,8 +1,7 @@ """Provides triggers for selects.""" from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, @@ -19,16 +18,6 @@ class SelectionChangedTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - TRIGGERS: dict[str, type[Trigger]] = { "selection_changed": SelectionChangedTrigger, diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 613329c3658..a9692114278 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -1,7 +1,5 @@ """SendGrid notification service.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index a5393181057..fbd49cecb39 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -6,6 +6,7 @@ import logging from sense_energy import ( ASyncSenseable, + SenseAPIException, SenseAuthenticationException, SenseMFARequiredException, ) @@ -88,6 +89,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo ) from err except SENSE_WEBSOCKET_EXCEPTIONS as err: raise ConfigEntryNotReady(str(err) or "Error during realtime update") from err + except SenseAPIException as err: + raise ConfigEntryNotReady( + str(err) or "API error retrieving realtime data" + ) from err trends_coordinator = SenseTrendCoordinator(hass, entry, gateway) realtime_coordinator = SenseRealtimeCoordinator(hass, entry, gateway) diff --git a/homeassistant/components/sense/coordinator.py b/homeassistant/components/sense/coordinator.py index 1957352aea6..e548f648514 100644 --- a/homeassistant/components/sense/coordinator.py +++ b/homeassistant/components/sense/coordinator.py @@ -1,13 +1,12 @@ """Sense Coordinators.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING from sense_energy import ( ASyncSenseable, + SenseAPIException, SenseAuthenticationException, SenseMFARequiredException, ) @@ -95,6 +94,8 @@ class SenseRealtimeCoordinator(SenseCoordinator): try: await self._gateway.update_realtime() except SENSE_TIMEOUT_EXCEPTIONS as ex: - _LOGGER.error("Timeout retrieving data: %s", ex) + raise UpdateFailed(f"Timeout retrieving realtime data: {ex}") from ex except SENSE_WEBSOCKET_EXCEPTIONS as ex: - _LOGGER.error("Failed to update data: %s", ex) + raise UpdateFailed(f"Failed to update realtime data: {ex}") from ex + except SenseAPIException as ex: + raise UpdateFailed(f"API error retrieving realtime data: {ex}") from ex diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 3816a8c4ff9..07187066dcd 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.14.0"] + "requirements": ["sense-energy==0.14.1"] } diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 0e6d6df2892..d0a91db2b63 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -1,7 +1,5 @@ """The Sensibo component.""" -from __future__ import annotations - from pysensibo.exceptions import AuthenticationError from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index c7116db7954..bfb5ae70b3b 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index d36967dae06..4f579ddb80e 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -1,7 +1,5 @@ """Button platform for Sensibo integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index daffad0447a..43722a0b27d 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -1,7 +1,5 @@ """Support for Sensibo climate devices.""" -from __future__ import annotations - from bisect import bisect_left from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index e3d9f70d2c3..c1bae238f98 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index 3fa8a6e5dae..70986824f1d 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Sensibo integration.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index f781887ec0a..18f086749d9 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Sensibo.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index f9ffc4b31c5..604d1383fc9 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -1,7 +1,5 @@ """Base entity for Sensibo integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Concatenate diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index e71ed6f0235..b154925fa30 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -1,7 +1,5 @@ """Number platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 1ed9a1bbefc..a2b67fd377f 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -1,7 +1,5 @@ """Select platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index bab85eb2294..91aa618f635 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/sensibo/services.py b/homeassistant/components/sensibo/services.py index 682954e6d7c..aecb1c45f92 100644 --- a/homeassistant/components/sensibo/services.py +++ b/homeassistant/components/sensibo/services.py @@ -1,7 +1,5 @@ """Sensibo services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.climate import ( diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 03e7c12ec2b..16664542a89 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -1,7 +1,5 @@ """Switch platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 6f868e5f366..e1573ddbdf3 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -1,7 +1,5 @@ """Update platform for Sensibo integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py index 3c750b2f017..e208ddcf854 100644 --- a/homeassistant/components/sensibo/util.py +++ b/homeassistant/components/sensibo/util.py @@ -1,7 +1,5 @@ """Utils for Sensibo integration.""" -from __future__ import annotations - import asyncio from pysensibo import SensiboClient diff --git a/homeassistant/components/sensirion_ble/__init__.py b/homeassistant/components/sensirion_ble/__init__.py index 5ea5593004e..f678713c018 100644 --- a/homeassistant/components/sensirion_ble/__init__.py +++ b/homeassistant/components/sensirion_ble/__init__.py @@ -1,7 +1,5 @@ """The sensirion_ble integration.""" -from __future__ import annotations - import logging from sensirion_ble import SensirionBluetoothDeviceData @@ -14,26 +12,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type SensirionBluetoothConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: SensirionBluetoothConfigEntry +) -> bool: """Set up Sensirion BLE device from a config entry.""" address = entry.unique_id assert address is not None data = SensirionBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + entry.runtime_data = coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( @@ -42,9 +40,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SensirionBluetoothConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sensirion_ble/config_flow.py b/homeassistant/components/sensirion_ble/config_flow.py index 82cf5ebbeea..9b7c327a54e 100644 --- a/homeassistant/components/sensirion_ble/config_flow.py +++ b/homeassistant/components/sensirion_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for sensirion_ble.""" -from __future__ import annotations - from typing import Any from sensirion_ble import SensirionBluetoothDeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -72,6 +71,7 @@ class SensirionConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index 16f7571f392..74deb9491b5 100644 --- a/homeassistant/components/sensirion_ble/sensor.py +++ b/homeassistant/components/sensirion_ble/sensor.py @@ -1,7 +1,5 @@ """Support for Sensirion sensors.""" -from __future__ import annotations - from sensor_state_data import ( DeviceKey, SensorDescription, @@ -10,12 +8,10 @@ from sensor_state_data import ( Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -33,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import SensirionBluetoothConfigEntry SENSOR_DESCRIPTIONS: dict[ tuple[SSDSensorDeviceClass, Units | None], SensorEntityDescription @@ -105,20 +101,20 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: SensirionBluetoothConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensirion BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( SensirionBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class SensirionBluetoothSensorEntity( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2..9cb3e55bf0a 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -1,9 +1,7 @@ """Component to interface with various sensors that can be monitored.""" -from __future__ import annotations - import asyncio -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta @@ -32,6 +30,7 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, Undef from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey +from homeassistant.util.variance import ignore_variance from .const import ( # noqa: F401 AMBIGUOUS_UNITS, @@ -63,6 +62,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL: Final = timedelta(seconds=30) +UPTIME_DEFAULT_TOLERANCE_SECONDS: Final = 60 +UPTIME_MIN_TOLERANCE_SECONDS: Final = 5 __all__ = [ "ATTR_LAST_RESET", @@ -180,6 +181,9 @@ TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" + # Allow per-entity override of drift tolerance + _attr_uptime_drift_tolerance: int = UPTIME_DEFAULT_TOLERANCE_SECONDS + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) entity_description: SensorEntityDescription @@ -201,6 +205,19 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _sensor_option_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED _invalid_suggested_unit_of_measurement_reported = False + _get_uptime: Callable[[datetime], datetime] | None = None + + def _normalize_uptime(self, current_uptime: datetime) -> datetime: + """Normalize uptime to suppress small drift between updates.""" + if self._get_uptime is None: + drift_tolerance = max( + self._attr_uptime_drift_tolerance, UPTIME_MIN_TOLERANCE_SECONDS + ) + self._get_uptime = ignore_variance( + func=lambda value: value, + ignored_variance=timedelta(seconds=drift_tolerance), + ) + return self._get_uptime(current_uptime) @callback def add_to_platform_start( @@ -283,7 +300,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): await super().async_internal_added_to_hass() if self.entity_category == EntityCategory.CONFIG: raise HomeAssistantError( - f"Entity {self.entity_id} cannot be added as the entity category is set to config" + f"Entity {self.entity_id} cannot be added as" + " the entity category is set to config" ) if not self.registry_entry: @@ -400,8 +418,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if suggested_unit_of_measurement is None and ( unit_converter := UNIT_CONVERTERS.get(self.device_class) ): - # If the device class is not known by the unit system but has a unit converter, - # fall back to the unit suggested by the unit converter's unit class. + # If the device class is not known by the unit + # system but has a unit converter, fall back to + # the unit suggested by the unit converter's + # unit class. suggested_unit_of_measurement = self.hass.config.units.get_converted_unit( unit_converter.UNIT_CLASS, self.__native_unit_of_measurement_compat ) @@ -441,9 +461,12 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): state_class = self.state_class if state_class != SensorStateClass.TOTAL: raise ValueError( - f"Entity {self.entity_id} ({type(self)}) with state_class {state_class}" - " has set last_reset. Setting last_reset for entities with state_class" - " other than 'total' is not supported. Please update your configuration" + f"Entity {self.entity_id} ({type(self)})" + f" with state_class {state_class}" + " has set last_reset. Setting last_reset" + " for entities with state_class" + " other than 'total' is not supported." + " Please update your configuration" " if state_class is manually configured." ) @@ -550,9 +573,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): if native_unit_of_measurement is not None: raise ValueError( - f"Sensor {type(self)} from integration '{self.platform.platform_name}' " - f"has a translation key for unit_of_measurement '{unit_of_measurement}', " - f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + f"Sensor {type(self)} from integration" + f" '{self.platform.platform_name}' " + "has a translation key for" + f" unit_of_measurement '{unit_of_measurement}'" + ", but also has a" + " native_unit_of_measurement" + f" '{native_unit_of_measurement}'" ) return unit_of_measurement @@ -610,10 +637,14 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Checks below only apply if there is a value if value is None: + if device_class is SensorDeviceClass.UPTIME: + # Reset baseline so the first uptime after unavailable is not + # compared against a stale value. + self._get_uptime = None return None # Received a datetime - if device_class is SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -627,11 +658,16 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if value.tzinfo != UTC: value = value.astimezone(UTC) + if device_class is SensorDeviceClass.UPTIME: + value = self._normalize_uptime(value) + return value.isoformat(timespec="seconds") except (AttributeError, OverflowError, TypeError) as err: raise ValueError( - f"Invalid datetime: {self.entity_id} has timestamp device class " - f"but provides state {value}:{type(value)} resulting in '{err}'" + f"Invalid datetime: {self.entity_id}" + f" has {device_class.value} device class" + f" but provides state {value}:{type(value)}" + f" resulting in '{err}'" ) from err # Received a date value @@ -749,9 +785,12 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): and native_unit_of_measurement not in units ): raise ValueError( - f"Sensor {self.entity_id} ({type(self)}) is using native unit of " - f"measurement '{native_unit_of_measurement}' which is not a valid unit " - f"for the state class ('{state_class}') it is using; expected one of {units};" + f"Sensor {self.entity_id} ({type(self)}) is" + " using native unit of measurement" + f" '{native_unit_of_measurement}' which is" + " not a valid unit for the state class" + f" ('{state_class}') it is using;" + f" expected one of {units};" ) return value @@ -770,15 +809,18 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _get_adjusted_display_precision(self) -> int | None: """Return the display precision for the sensor. - When the integration has specified a suggested display precision, it will be used. - If a unit conversion is needed, the display precision will be adjusted based on - the ratio from the native unit to the current one. + When the integration has specified a suggested display + precision, it will be used. If a unit conversion is needed, + the display precision will be adjusted based on the ratio + from the native unit to the current one. - When the integration does not specify a suggested display precision, a default - device class precision will be used from UNITS_PRECISION, and the final precision - will be adjusted based on the ratio from the default unit to the current one. It - will also be capped so that the extra precision (from the base unit) does not - exceed DEFAULT_PRECISION_LIMIT. + When the integration does not specify a suggested + display precision, a default device class precision will + be used from UNITS_PRECISION, and the final precision + will be adjusted based on the ratio from the default + unit to the current one. It will also be capped so that + the extra precision (from the base unit) does not exceed + DEFAULT_PRECISION_LIMIT. """ display_precision = self.suggested_display_precision device_class = self.device_class diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 381deb0a255..7878b380ad2 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -1,7 +1,5 @@ """Constants for sensor.""" -from __future__ import annotations - from enum import StrEnum from typing import Final @@ -60,6 +58,7 @@ from homeassistant.util.unit_conversion import ( ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -116,6 +115,20 @@ class SensorDeviceClass(StrEnum): ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 """ + UPTIME = "uptime" + """Uptime. + + Represents the point in time when a device or service last restarted. + + Small drift between updates is automatically suppressed in + `SensorEntity.state` to avoid unnecessary state changes caused by clock + jitter. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + # Numerical device classes, these should be aligned with NumberDeviceClass ABSOLUTE_HUMIDITY = "absolute_humidity" """Absolute humidity. @@ -162,7 +175,8 @@ class SensorDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³` + Unit of measurement: `ppb` (parts per billion), + `ppm` (parts per million), `mg/m³`, `μg/m³` """ CO2 = "carbon_dioxide" @@ -180,7 +194,7 @@ class SensorDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -214,16 +228,20 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, + `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, + `Mcal`, `Gcal` """ ENERGY_DISTANCE = "energy_distance" """Energy distance. - Use this device class for sensors measuring energy by distance, for example the amount - of electric energy consumed by an electric car. + Use this device class for sensors measuring energy by + distance, for example the amount of electric energy + consumed by an electric car. - Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` + Unit of measurement: `kWh/100km`, `Wh/km`, + `mi/kWh`, `km/kWh` """ ENERGY_STORAGE = "energy_storage" @@ -232,7 +250,9 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, + `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, + `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -451,8 +471,10 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `VOLUME_*` units - SI / metric: `mL`, `L`, `m³` - - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in - USCS/imperial units are currently assumed to be US volumes) + - USCS / imperial: `ft³`, `CCF`, `MCF`, + `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be + US volumes) """ VOLUME_STORAGE = "volume_storage" @@ -463,8 +485,10 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `VOLUME_*` units - SI / metric: `mL`, `L`, `m³` - - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in - USCS/imperial units are currently assumed to be US volumes) + - USCS / imperial: `ft³`, `CCF`, `MCF`, + `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be + US volumes) """ VOLUME_FLOW_RATE = "volume_flow_rate" @@ -515,6 +539,7 @@ NON_NUMERIC_DEVICE_CLASSES = { SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -531,7 +556,10 @@ class SensorStateClass(StrEnum): """The state represents a measurement in present time.""" MEASUREMENT_ANGLE = "measurement_angle" - """The state represents a angle measurement in present time. Currently only degrees are supported.""" + """The state represents an angle measurement in present time. + + Currently only degrees are supported. + """ TOTAL = "total" """The state represents a total amount. @@ -565,6 +593,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, + SensorDeviceClass.FREQUENCY: FrequencyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter, SensorDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter, @@ -814,6 +843,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TEMPERATURE_DELTA: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), + SensorDeviceClass.UPTIME: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index ba5eb1fae2a..27846ae6fac 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -1,7 +1,5 @@ """Provides device conditions for sensors.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8..e413ce7b135 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -1,7 +1,5 @@ """Helpers for sensor entities.""" -from __future__ import annotations - from datetime import date, datetime import logging @@ -18,7 +16,7 @@ def async_parse_date_datetime( value: str, entity_id: str, device_class: SensorDeviceClass | str | None ) -> datetime | date | None: """Parse datetime string to a data or datetime.""" - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): if (parsed_timestamp := dt_util.parse_datetime(value)) is None: _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) return None diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 59d57da2803..966e19439e3 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -163,6 +163,9 @@ "timestamp": { "default": "mdi:clock" }, + "uptime": { + "default": "mdi:clock-start" + }, "volatile_organic_compounds": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6585a9ecd8b..32326022220 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -1,7 +1,5 @@ """Statistics helper for sensor.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Iterable from contextlib import suppress @@ -94,7 +92,8 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") -# Keep track of entities for which a warning about statistics mean algorithm change has been logged +# Keep track of entities for which a warning about +# statistics mean algorithm change has been logged WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey( f"{DOMAIN}_warn_statistics_mean_change" ) @@ -167,8 +166,8 @@ def _time_weighted_circular_mean( ) -> tuple[float, float]: """Calculate a time weighted circular mean. - The circular mean is calculated by weighting the states by duration in seconds between - state changes. + The circular mean is calculated by weighting the states + by duration in seconds between state changes. Note: there's no interpolation of values between state changes. """ old_fstate: float | None = None @@ -409,11 +408,12 @@ def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: def warn_dip( hass: HomeAssistant, entity_id: str, state: State, previous_fstate: float ) -> None: - """Log a warning once if a sensor with state class TOTAL_INCREASING has a decreasing value. + """Log a warning once if a sensor with TOTAL_INCREASING has a decreasing value. - The log will be suppressed until two dips have been seen to prevent warning due to - rounding issues with databases storing the state as a single precision float, which - was fixed in recorder DB version 20. + The log will be suppressed until two dips have been seen + to prevent warning due to rounding issues with databases + storing the state as a single precision float, which was + fixed in recorder DB version 20. """ if SEEN_DIP not in hass.data: hass.data[SEEN_DIP] = set() @@ -445,7 +445,7 @@ def warn_dip( def warn_negative(hass: HomeAssistant, entity_id: str, state: State) -> None: - """Log a warning once if a sensor with state class TOTAL_INCREASING has a negative value.""" + """Log a warning once if a sensor with TOTAL_INCREASING has a negative value.""" if WARN_NEGATIVE not in hass.data: hass.data[WARN_NEGATIVE] = set() if entity_id not in hass.data[WARN_NEGATIVE]: @@ -664,7 +664,8 @@ def compile_statistics( # noqa: C901 hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id) _LOGGER.warning( ( - "The statistics mean algorithm for %s have changed from %s to %s." + "The statistics mean algorithm for %s have" + " changed from %s to %s." " Generation of long term statistics will be suppressed" " unless it changes back or go to %s to delete the old" " statistics" diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 06598a1d0a0..17497be74f6 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant sensor state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.const import ( diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 6f8ef1ae530..e51c139e8de 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -297,6 +297,9 @@ "timestamp": { "name": "Timestamp" }, + "uptime": { + "name": "Uptime" + }, "volatile_organic_compounds": { "name": "Volatile organic compounds" }, @@ -330,15 +333,12 @@ }, "issues": { "mean_type_changed": { - "description": "", "title": "The mean type of {statistic_id} has changed" }, "state_class_removed": { - "description": "", "title": "{statistic_id} no longer has a state class" }, "units_changed": { - "description": "", "title": "The unit of {statistic_id} has changed" } }, diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 92df6fa69e9..c9a68e3b410 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -1,7 +1,5 @@ """The sensor websocket API.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/sensorpro/__init__.py b/homeassistant/components/sensorpro/__init__.py index be15b65e0f9..167c5d16746 100644 --- a/homeassistant/components/sensorpro/__init__.py +++ b/homeassistant/components/sensorpro/__init__.py @@ -1,7 +1,5 @@ """The SensorPro integration.""" -from __future__ import annotations - import logging from sensorpro_ble import SensorProBluetoothDeviceData diff --git a/homeassistant/components/sensorpro/config_flow.py b/homeassistant/components/sensorpro/config_flow.py index be602d1fd43..ef1ffb0cae6 100644 --- a/homeassistant/components/sensorpro/config_flow.py +++ b/homeassistant/components/sensorpro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for sensorpro ble integration.""" -from __future__ import annotations - from typing import Any from sensorpro_ble import SensorProBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/sensorpro/device.py b/homeassistant/components/sensorpro/device.py index 38b94a19452..bbaa77dd223 100644 --- a/homeassistant/components/sensorpro/device.py +++ b/homeassistant/components/sensorpro/device.py @@ -1,7 +1,5 @@ """Support for SensorPro devices.""" -from __future__ import annotations - from sensorpro_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py index 997fa0db995..095de31d15d 100644 --- a/homeassistant/components/sensorpro/sensor.py +++ b/homeassistant/components/sensorpro/sensor.py @@ -1,7 +1,5 @@ """Support for SensorPro sensors.""" -from __future__ import annotations - from sensorpro_ble import ( SensorDeviceClass as SensorProSensorDeviceClass, SensorUpdate, @@ -114,6 +112,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SensorPro BLE sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] @@ -123,7 +123,9 @@ async def async_setup_entry( SensorProBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class SensorProBluetoothSensorEntity( diff --git a/homeassistant/components/sensorpush/__init__.py b/homeassistant/components/sensorpush/__init__.py index c15dafb01d6..eab5085c0eb 100644 --- a/homeassistant/components/sensorpush/__init__.py +++ b/homeassistant/components/sensorpush/__init__.py @@ -1,7 +1,5 @@ """The SensorPush Bluetooth integration.""" -from __future__ import annotations - import logging from sensorpush_ble import SensorPushBluetoothDeviceData diff --git a/homeassistant/components/sensorpush/config_flow.py b/homeassistant/components/sensorpush/config_flow.py index d3233ac2d5f..b9c6922d7bb 100644 --- a/homeassistant/components/sensorpush/config_flow.py +++ b/homeassistant/components/sensorpush/config_flow.py @@ -1,7 +1,5 @@ """Config flow for sensorpush integration.""" -from __future__ import annotations - from typing import Any from sensorpush_ble import SensorPushBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 730277350b5..accbbe0150f 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -1,7 +1,5 @@ """Support for sensorpush ble sensors.""" -from __future__ import annotations - from sensorpush_ble import DeviceClass, DeviceKey, SensorUpdate, Units from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/sensorpush_cloud/__init__.py b/homeassistant/components/sensorpush_cloud/__init__.py index 2d9d299c132..61a2da54c20 100644 --- a/homeassistant/components/sensorpush_cloud/__init__.py +++ b/homeassistant/components/sensorpush_cloud/__init__.py @@ -1,7 +1,5 @@ """The SensorPush Cloud integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/sensorpush_cloud/config_flow.py b/homeassistant/components/sensorpush_cloud/config_flow.py index 8cf68e09134..bea753beeb1 100644 --- a/homeassistant/components/sensorpush_cloud/config_flow.py +++ b/homeassistant/components/sensorpush_cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the SensorPush Cloud integration.""" -from __future__ import annotations - from typing import Any from sensorpush_ha import SensorPushCloudApi, SensorPushCloudAuthError diff --git a/homeassistant/components/sensorpush_cloud/coordinator.py b/homeassistant/components/sensorpush_cloud/coordinator.py index 9885538b55a..844f539dd17 100644 --- a/homeassistant/components/sensorpush_cloud/coordinator.py +++ b/homeassistant/components/sensorpush_cloud/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the SensorPush Cloud integration.""" -from __future__ import annotations - from sensorpush_ha import ( SensorPushCloudApi, SensorPushCloudData, diff --git a/homeassistant/components/sensorpush_cloud/sensor.py b/homeassistant/components/sensorpush_cloud/sensor.py index d2855f63a62..5c7048f4601 100644 --- a/homeassistant/components/sensorpush_cloud/sensor.py +++ b/homeassistant/components/sensorpush_cloud/sensor.py @@ -1,7 +1,5 @@ """Support for SensorPush Cloud sensors.""" -from __future__ import annotations - from typing import Final from homeassistant.components.sensor import ( diff --git a/homeassistant/components/sensoterra/__init__.py b/homeassistant/components/sensoterra/__init__.py index 1559dc10c43..765697be5e0 100644 --- a/homeassistant/components/sensoterra/__init__.py +++ b/homeassistant/components/sensoterra/__init__.py @@ -1,7 +1,5 @@ """The Sensoterra integration.""" -from __future__ import annotations - from sensoterra.customerapi import CustomerApi from homeassistant.const import CONF_TOKEN, Platform diff --git a/homeassistant/components/sensoterra/config_flow.py b/homeassistant/components/sensoterra/config_flow.py index c98710dfa7d..8633769907d 100644 --- a/homeassistant/components/sensoterra/config_flow.py +++ b/homeassistant/components/sensoterra/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Sensoterra integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py index 56f47ade212..9c75f48b1f6 100644 --- a/homeassistant/components/sensoterra/sensor.py +++ b/homeassistant/components/sensoterra/sensor.py @@ -1,7 +1,5 @@ """Sensoterra devices.""" -from __future__ import annotations - from datetime import UTC, datetime, timedelta from enum import StrEnum, auto @@ -167,5 +165,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity): return False # Expire sensor if no update within the last few days. - expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) + expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow return sensor.timestamp >= expiration diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 5b89518c616..410f09b328f 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -1,7 +1,5 @@ """The sentry integration.""" -from __future__ import annotations - from collections.abc import Mapping import re from typing import Any diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py index 2fead7c27cd..b86533edde9 100644 --- a/homeassistant/components/sentry/config_flow.py +++ b/homeassistant/components/sentry/config_flow.py @@ -1,7 +1,5 @@ """Config flow for sentry integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index ac3c1949c34..d3ab6503b3e 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -1,7 +1,5 @@ """The nVent RAYCHEM SENZ integration.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/senz/api.py b/homeassistant/components/senz/api.py index 1500f1bf645..b05a2037cec 100644 --- a/homeassistant/components/senz/api.py +++ b/homeassistant/components/senz/api.py @@ -9,7 +9,7 @@ from homeassistant.helpers import config_entry_oauth2_flow class SENZConfigEntryAuth(AbstractSENZAuth): - """Provide nVent RAYCHEM SENZ authentication tied to an OAuth2 based config entry.""" + """Provide nVent RAYCHEM SENZ authentication tied to an OAuth2 config entry.""" def __init__( self, diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 9f5bc15e5bf..c0f92c3ef09 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -1,7 +1,5 @@ """nVent RAYCHEM SENZ climate platform.""" -from __future__ import annotations - from typing import Any from httpx import RequestError diff --git a/homeassistant/components/senz/coordinator.py b/homeassistant/components/senz/coordinator.py index 44f218d7b40..e39b86751ba 100644 --- a/homeassistant/components/senz/coordinator.py +++ b/homeassistant/components/senz/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for SENZ.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/senz/sensor.py b/homeassistant/components/senz/sensor.py index 8f7eb2cc0eb..bc87377cc95 100644 --- a/homeassistant/components/senz/sensor.py +++ b/homeassistant/components/senz/sensor.py @@ -1,7 +1,5 @@ """nVent RAYCHEM SENZ sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index 2a5d3c78737..b36ff253584 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.16"] + "requirements": ["serialx==1.8.0"] } diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index f4bfea72cb8..2ccff802b66 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -1,13 +1,11 @@ """Support for reading data from a serial port.""" -from __future__ import annotations - import asyncio +from asyncio import Task import json import logging -from serial import SerialException -import serial_asyncio_fast as serial_asyncio +from serialx import Parity, SerialException, StopBits, open_serial_connection import voluptuous as vol from homeassistant.components.sensor import ( @@ -18,6 +16,7 @@ from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSIST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -33,9 +32,9 @@ CONF_DSRDTR = "dsrdtr" DEFAULT_NAME = "Serial Sensor" DEFAULT_BAUDRATE = 9600 -DEFAULT_BYTESIZE = serial_asyncio.serial.EIGHTBITS -DEFAULT_PARITY = serial_asyncio.serial.PARITY_NONE -DEFAULT_STOPBITS = serial_asyncio.serial.STOPBITS_ONE +DEFAULT_BYTESIZE = 8 +DEFAULT_PARITY = Parity.NONE +DEFAULT_STOPBITS = StopBits.ONE DEFAULT_XONXOFF = False DEFAULT_RTSCTS = False DEFAULT_DSRDTR = False @@ -46,28 +45,21 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In( - [ - serial_asyncio.serial.FIVEBITS, - serial_asyncio.serial.SIXBITS, - serial_asyncio.serial.SEVENBITS, - serial_asyncio.serial.EIGHTBITS, - ] - ), + vol.Optional(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([5, 6, 7, 8]), vol.Optional(CONF_PARITY, default=DEFAULT_PARITY): vol.In( [ - serial_asyncio.serial.PARITY_NONE, - serial_asyncio.serial.PARITY_EVEN, - serial_asyncio.serial.PARITY_ODD, - serial_asyncio.serial.PARITY_MARK, - serial_asyncio.serial.PARITY_SPACE, + Parity.NONE, + Parity.EVEN, + Parity.ODD, + Parity.MARK, + Parity.SPACE, ] ), vol.Optional(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In( [ - serial_asyncio.serial.STOPBITS_ONE, - serial_asyncio.serial.STOPBITS_ONE_POINT_FIVE, - serial_asyncio.serial.STOPBITS_TWO, + StopBits.ONE, + StopBits.ONE_POINT_FIVE, + StopBits.TWO, ] ), vol.Optional(CONF_XONXOFF, default=DEFAULT_XONXOFF): cv.boolean, @@ -84,28 +76,17 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Serial sensor platform.""" - name = config.get(CONF_NAME) - port = config.get(CONF_SERIAL_PORT) - baudrate = config.get(CONF_BAUDRATE) - bytesize = config.get(CONF_BYTESIZE) - parity = config.get(CONF_PARITY) - stopbits = config.get(CONF_STOPBITS) - xonxoff = config.get(CONF_XONXOFF) - rtscts = config.get(CONF_RTSCTS) - dsrdtr = config.get(CONF_DSRDTR) - value_template = config.get(CONF_VALUE_TEMPLATE) - sensor = SerialSensor( - name, - port, - baudrate, - bytesize, - parity, - stopbits, - xonxoff, - rtscts, - dsrdtr, - value_template, + name=config[CONF_NAME], + port=config[CONF_SERIAL_PORT], + baudrate=config[CONF_BAUDRATE], + bytesize=config[CONF_BYTESIZE], + parity=config[CONF_PARITY], + stopbits=config[CONF_STOPBITS], + xonxoff=config[CONF_XONXOFF], + rtscts=config[CONF_RTSCTS], + dsrdtr=config[CONF_DSRDTR], + value_template=config.get(CONF_VALUE_TEMPLATE), ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read) @@ -119,17 +100,17 @@ class SerialSensor(SensorEntity): def __init__( self, - name, - port, - baudrate, - bytesize, - parity, - stopbits, - xonxoff, - rtscts, - dsrdtr, - value_template, - ): + name: str, + port: str, + baudrate: int, + bytesize: int, + parity: Parity, + stopbits: StopBits, + xonxoff: bool, + rtscts: bool, + dsrdtr: bool, + value_template: Template | None, + ) -> None: """Initialize the Serial sensor.""" self._attr_name = name self._port = port @@ -140,12 +121,12 @@ class SerialSensor(SensorEntity): self._xonxoff = xonxoff self._rtscts = rtscts self._dsrdtr = dsrdtr - self._serial_loop_task = None + self._serial_loop_task: Task[None] | None = None self._template = value_template async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" - self._serial_loop_task = self.hass.loop.create_task( + self._serial_loop_task = self.hass.async_create_background_task( self.serial_read( self._port, self._baudrate, @@ -155,26 +136,31 @@ class SerialSensor(SensorEntity): self._xonxoff, self._rtscts, self._dsrdtr, - ) + ), + "Serial reader", ) async def serial_read( self, - device, - baudrate, - bytesize, - parity, - stopbits, - xonxoff, - rtscts, - dsrdtr, + device: str, + baudrate: int, + bytesize: int, + parity: Parity, + stopbits: StopBits, + xonxoff: bool, + rtscts: bool, + dsrdtr: bool, **kwargs, ): """Read the data from the port.""" logged_error = False + while True: + reader = None + writer = None + try: - reader, _ = await serial_asyncio.open_serial_connection( + reader, writer = await open_serial_connection( url=device, baudrate=baudrate, bytesize=bytesize, @@ -185,8 +171,7 @@ class SerialSensor(SensorEntity): dsrdtr=dsrdtr, **kwargs, ) - - except SerialException: + except OSError, SerialException, TimeoutError: if not logged_error: _LOGGER.exception( "Unable to connect to the serial device %s. Will retry", device @@ -197,15 +182,15 @@ class SerialSensor(SensorEntity): _LOGGER.debug("Serial device %s connected", device) while True: try: - line = await reader.readline() - except SerialException: + line_bytes = await reader.readline() + except OSError, SerialException: _LOGGER.exception( "Error while reading serial device %s", device ) await self._handle_error() break else: - line = line.decode("utf-8").strip() + line = line_bytes.decode("utf-8").strip() try: data = json.loads(line) @@ -223,6 +208,10 @@ class SerialSensor(SensorEntity): _LOGGER.debug("Received: %s", line) self._attr_native_value = line self.async_write_ha_state() + finally: + if writer is not None: + writer.close() + await writer.wait_closed() async def _handle_error(self): """Handle error for serial connection.""" diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 570d1ac0d63..c67175afb9f 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -1,7 +1,5 @@ """Support for particulate matter sensors connected to a serial port.""" -from __future__ import annotations - import logging from pmsensor import serial_pm as pm diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index 5165d3d4798..1d36c4505a6 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -1,7 +1,5 @@ """Support for Sesame, by CANDY HOUSE.""" -from __future__ import annotations - from typing import Any import pysesame2 diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 29ebe8f03ea..b661ace7285 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -1,7 +1,5 @@ """Optical character recognition processing of seven segments displays.""" -from __future__ import annotations - import io import logging import os diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 745b96bb2eb..75906382a1b 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==12.1.1"] + "requirements": ["Pillow==12.2.0"] } diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index afb538c6b32..6a3e83e7bb2 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -3,7 +3,6 @@ from pyseventeentrack import Client as SeventeenTrackClient from pyseventeentrack.errors import SeventeenTrackError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -12,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import SeventeenTrackCoordinator +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -28,7 +27,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SeventeenTrackConfigEntry +) -> bool: """Set up 17Track from a config entry.""" session = async_create_clientsession(hass) @@ -43,6 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await seventeen_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = seventeen_coordinator + entry.runtime_data = seventeen_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index 58cffbb1303..21b87d14ac3 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for 17track.net.""" -from __future__ import annotations - import logging from typing import Any @@ -9,7 +7,7 @@ from pyseventeentrack import Client as SeventeenTrackClient from pyseventeentrack.errors import SeventeenTrackError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -25,6 +23,7 @@ from .const import ( DEFAULT_SHOW_DELIVERED, DOMAIN, ) +from .coordinator import SeventeenTrackConfigEntry CONF_SHOW = { vol.Optional(CONF_SHOW_ARCHIVED, default=DEFAULT_SHOW_ARCHIVED): bool, @@ -54,7 +53,7 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 107f1d48a21..39a42727c51 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -20,6 +20,8 @@ from .const import ( LOGGER, ) +type SeventeenTrackConfigEntry = ConfigEntry[SeventeenTrackCoordinator] + @dataclass class SeventeenTrackData: @@ -32,12 +34,12 @@ class SeventeenTrackData: class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]): """Class to manage fetching 17Track data.""" - config_entry: ConfigEntry + config_entry: SeventeenTrackConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, client: SeventeenTrackClient, ) -> None: """Initialize.""" diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 1064296fa61..e4080c43a5e 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.1.2"] + "requirements": ["pyseventeentrack==1.1.3"] } diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index c6fd7942655..a0e7a51eb81 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -1,27 +1,24 @@ """Support for package tracking sensors from 17track.net.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SeventeenTrackCoordinator from .const import ATTRIBUTION, DOMAIN +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a 17Track sensor entry.""" - coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( SeventeenTrackSummarySensor(status, coordinator) diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 62a12b9ddcf..e0cc3909678 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -16,7 +16,6 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv, selector, service from homeassistant.util import slugify -from . import SeventeenTrackCoordinator from .const import ( ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, @@ -34,6 +33,7 @@ from .const import ( SERVICE_ARCHIVE_PACKAGE, SERVICE_GET_PACKAGES, ) +from .coordinator import SeventeenTrackConfigEntry SERVICE_GET_PACKAGES_SCHEMA: Final = vol.Schema( { @@ -72,13 +72,11 @@ async def _get_packages(call: ServiceCall) -> ServiceResponse: """Get packages from 17Track.""" package_states = call.data.get(ATTR_PACKAGE_STATE, []) - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data live_packages = sorted( await seventeen_coordinator.client.profile.packages( show_archived=seventeen_coordinator.show_archived @@ -99,13 +97,11 @@ async def _add_package(call: ServiceCall) -> None: tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data await seventeen_coordinator.client.profile.add_package( tracking_number, friendly_name @@ -115,13 +111,11 @@ async def _add_package(call: ServiceCall) -> None: async def _archive_package(call: ServiceCall) -> None: tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data await seventeen_coordinator.client.profile.archive_package(tracking_number) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 1a717e82d82..925548bf6e5 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -1,9 +1,6 @@ """SFR Box.""" -from __future__ import annotations - import asyncio -from typing import TYPE_CHECKING from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError @@ -14,14 +11,14 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS, PLATFORMS_WITH_AUTH +from .const import DOMAIN, PLATFORMS from .coordinator import SFRConfigEntry, SFRDataUpdateCoordinator, SFRRuntimeData async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: """Set up SFR box as config entry.""" box = SFRBox(ip=entry.data[CONF_HOST], client=async_get_clientsession(hass)) - platforms = PLATFORMS + has_auth = False if (username := entry.data.get(CONF_USERNAME)) and ( password := entry.data.get(CONF_PASSWORD) ): @@ -38,10 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: translation_key="unknown_error", translation_placeholders={"error": str(err)}, ) from err - platforms = PLATFORMS_WITH_AUTH + has_auth = True data = SFRRuntimeData( box=box, + has_authentication=has_auth, dsl=SFRDataUpdateCoordinator( hass, entry, box, "dsl", lambda b: b.dsl_get_info() ), @@ -51,18 +49,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: system=SFRDataUpdateCoordinator( hass, entry, box, "system", lambda b: b.system_get_info() ), + voip=None, wan=SFRDataUpdateCoordinator( hass, entry, box, "wan", lambda b: b.wan_get_info() ), ) + if has_auth: + data.voip = SFRDataUpdateCoordinator( + hass, entry, box, "voip", lambda b: b.voip_get_info() + ) # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data - if TYPE_CHECKING: - assert system_info is not None # Preload other coordinators (based on net infrastructure) tasks = [data.wan.async_config_entry_first_refresh()] + if data.voip is not None: + tasks.append(data.voip.async_config_entry_first_refresh()) if (net_infra := system_info.net_infra) == "adsl": tasks.append(data.dsl.async_config_entry_first_refresh()) elif net_infra == "ftth": @@ -82,15 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: ) entry.runtime_data = data - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: """Unload a config entry.""" - if entry.data.get(CONF_USERNAME) and entry.data.get(CONF_PASSWORD): - return await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_WITH_AUTH - ) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index bcd0fd71d8f..0e295b6df8c 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -1,12 +1,9 @@ """SFR Box sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING -from sfrbox_api.models import DslInfo, FtthInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, VoipInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -49,6 +46,26 @@ FTTH_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[FtthInfo], ...] = ( translation_key="ftth_status", ), ) +VOIP_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[VoipInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + translation_key="voip_status", + ), + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="callhistory_active", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.callhistory_active == "on", + translation_key="voip_callhistory_active", + ), + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="hook_status", + value_fn=lambda x: x.hook_status == "offhook", + translation_key="voip_hook_status", + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", @@ -68,13 +85,16 @@ async def async_setup_entry( """Set up the sensors.""" data = entry.runtime_data system_info = data.system.data - if TYPE_CHECKING: - assert system_info is not None entities: list[SFRBoxBinarySensor] = [ SFRBoxBinarySensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ] + if data.voip is not None: + entities.extend( + SFRBoxBinarySensor(data.voip, description, system_info) + for description in VOIP_SENSOR_TYPES + ) if (net_infra := system_info.net_infra) == "adsl": entities.extend( SFRBoxBinarySensor(data.dsl, description, system_info) diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 350f72c68ac..d8da3af6ba7 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -1,11 +1,9 @@ """SFR Box button platform.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from functools import wraps -from typing import TYPE_CHECKING, Any, Concatenate +from typing import Any, Concatenate from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError @@ -78,9 +76,11 @@ async def async_setup_entry( ) -> None: """Set up the buttons.""" data = entry.runtime_data + if not data.has_authentication: + # All buttons currently require authentication + return + system_info = data.system.data - if TYPE_CHECKING: - assert system_info is not None entities = [ SFRBoxButton(data.box, description, system_info) for description in BUTTON_TYPES diff --git a/homeassistant/components/sfr_box/config_flow.py b/homeassistant/components/sfr_box/config_flow.py index 1d60f170878..f7f652c204c 100644 --- a/homeassistant/components/sfr_box/config_flow.py +++ b/homeassistant/components/sfr_box/config_flow.py @@ -1,7 +1,5 @@ """SFR Box config flow.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/sfr_box/const.py b/homeassistant/components/sfr_box/const.py index acc4e8e4941..69195289034 100644 --- a/homeassistant/components/sfr_box/const.py +++ b/homeassistant/components/sfr_box/const.py @@ -7,5 +7,4 @@ DEFAULT_USERNAME = "admin" DOMAIN = "sfr_box" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -PLATFORMS_WITH_AUTH = [*PLATFORMS, Platform.BUTTON] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index 9b131177e37..285258cf3f5 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -1,7 +1,5 @@ """SFR Box coordinator.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta @@ -10,7 +8,7 @@ from typing import Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError -from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, VoipInfo, WanInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -29,9 +27,11 @@ class SFRRuntimeData: """Runtime data for SFR Box.""" box: SFRBox + has_authentication: bool dsl: SFRDataUpdateCoordinator[DslInfo] ftth: SFRDataUpdateCoordinator[FtthInfo] system: SFRDataUpdateCoordinator[SystemInfo] + voip: SFRDataUpdateCoordinator[VoipInfo] | None wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 6ff44301b22..67a2169c172 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -1,7 +1,5 @@ """SFR Box diagnostics platform.""" -from __future__ import annotations - import dataclasses from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/sfr_box/entity.py b/homeassistant/components/sfr_box/entity.py index ee7fe3e44f5..cbe24806973 100644 --- a/homeassistant/components/sfr_box/entity.py +++ b/homeassistant/components/sfr_box/entity.py @@ -1,7 +1,5 @@ """SFR Box base entity.""" -from __future__ import annotations - from sfrbox_api.models import SystemInfo from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/sfr_box/icons.json b/homeassistant/components/sfr_box/icons.json new file mode 100644 index 00000000000..a24499e0ae3 --- /dev/null +++ b/homeassistant/components/sfr_box/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "voip_hook_status": { + "default": "mdi:phone-hangup", + "state": { + "on": "mdi:phone-in-talk" + } + } + } + } +} diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 88477903687..4884886854c 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -2,9 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, SystemInfo, VoipInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -183,6 +182,21 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( value_fn=lambda x: _get_temperature(x.temperature), ), ) +VOIP_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[VoipInfo], ...] = ( + SFRBoxSensorEntityDescription[VoipInfo]( + key="infra", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + "adsl", + "ftth", + "gprs", + ], + translation_key="voip_infra", + value_fn=lambda x: _value_to_option(x.infra), + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( SFRBoxSensorEntityDescription[WanInfo]( key="mode", @@ -221,8 +235,6 @@ async def async_setup_entry( """Set up the sensors.""" data = entry.runtime_data system_info = data.system.data - if TYPE_CHECKING: - assert system_info is not None entities: list[SFRBoxSensor] = [ SFRBoxSensor(data.system, description, system_info) @@ -232,6 +244,11 @@ async def async_setup_entry( SFRBoxSensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ) + if data.voip is not None: + entities.extend( + SFRBoxSensor(data.voip, description, system_info) + for description in VOIP_SENSOR_TYPES + ) if system_info.net_infra == "adsl": entities.extend( SFRBoxSensor(data.dsl, description, system_info) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 52ba0b295cd..dbf02e369cc 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -47,6 +47,19 @@ "ftth_status": { "name": "FTTH status" }, + "voip_callhistory_active": { + "name": "VoIP call history active" + }, + "voip_hook_status": { + "name": "VoIP phone hook status", + "state": { + "off": "On-hook", + "on": "Off-hook" + } + }, + "voip_status": { + "name": "VoIP status" + }, "wan_status": { "name": "WAN status" } @@ -113,6 +126,14 @@ "gprs": "GPRS" } }, + "voip_infra": { + "name": "VoIP infrastructure", + "state": { + "adsl": "[%key:component::sfr_box::entity::sensor::net_infra::state::adsl%]", + "ftth": "[%key:component::sfr_box::entity::sensor::net_infra::state::ftth%]", + "gprs": "[%key:component::sfr_box::entity::sensor::net_infra::state::gprs%]" + } + }, "wan_mode": { "name": "WAN mode", "state": { diff --git a/homeassistant/components/sftp_storage/__init__.py b/homeassistant/components/sftp_storage/__init__.py index 9b095c2decf..fdf2ff596b7 100644 --- a/homeassistant/components/sftp_storage/__init__.py +++ b/homeassistant/components/sftp_storage/__init__.py @@ -1,7 +1,5 @@ """Integration for SFTP Storage.""" -from __future__ import annotations - import contextlib from dataclasses import dataclass, field import errno @@ -10,17 +8,14 @@ from pathlib import Path from homeassistant.components.backup import BackupAgentError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from .client import BackupAgentClient from .const import ( CONF_BACKUP_LOCATION, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, CONF_PRIVATE_KEY_FILE, - CONF_USERNAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN, LOGGER, @@ -91,7 +86,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> Non pkey.unlink() except OSError as e: LOGGER.warning( - "Failed to remove private key %s for %s integration for host %s@%s. %s", + "Failed to remove private key %s for %s" + " integration for host %s@%s. %s", pkey.name, DOMAIN, entry.data[CONF_USERNAME], @@ -105,7 +101,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> Non if e.errno == errno.ENOTEMPTY: # Directory not empty if LOGGER.isEnabledFor(logging.DEBUG): leftover_files = [] - # If we get an exception while gathering leftover files, make sure to log plain message. + # If we get an exception while gathering + # leftover files, make sure to log plain + # message. with contextlib.suppress(OSError): leftover_files = [f.name for f in pkey.parent.iterdir()] @@ -119,7 +117,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> Non ) else: LOGGER.warning( - "Error occurred while removing directory %s for integration %s: %s at host %s@%s", + "Error occurred while removing directory %s" + " for integration %s: %s at host %s@%s", str(pkey.parent), DOMAIN, str(e), diff --git a/homeassistant/components/sftp_storage/backup.py b/homeassistant/components/sftp_storage/backup.py index 2367d022a44..0dfd9ec4fd2 100644 --- a/homeassistant/components/sftp_storage/backup.py +++ b/homeassistant/components/sftp_storage/backup.py @@ -1,7 +1,5 @@ """Backup platform for the SFTP Storage integration.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from typing import Any @@ -69,7 +67,8 @@ class SFTPBackupAgent(BackupAgent): ) -> AsyncIterator[bytes]: """Download a backup file from SFTP.""" LOGGER.debug( - "Establishing SFTP connection to remote host in order to download backup id: %s", + "Establishing SFTP connection to remote host" + " in order to download backup id: %s", backup_id, ) try: diff --git a/homeassistant/components/sftp_storage/client.py b/homeassistant/components/sftp_storage/client.py index 246862f8551..a50df34a251 100644 --- a/homeassistant/components/sftp_storage/client.py +++ b/homeassistant/components/sftp_storage/client.py @@ -1,7 +1,5 @@ """Client for SFTP Storage integration.""" -from __future__ import annotations - from collections.abc import AsyncIterator from dataclasses import dataclass import json @@ -32,7 +30,7 @@ if TYPE_CHECKING: def get_client_options(cfg: SFTPConfigEntryData) -> SSHClientConnectionOptions: - """Use this function with `hass.async_add_executor_job` to asynchronously get `SSHClientConnectionOptions`.""" + """Get `SSHClientConnectionOptions` for use with `hass.async_add_executor_job`.""" return SSHClientConnectionOptions( known_hosts=None, @@ -179,7 +177,8 @@ class BackupAgentClient: if not await self.sftp.exists(metadata.file_path): await self.sftp.unlink(metadata.metadata_file) raise FileNotFoundError( - f"File at provided remote location: {metadata.file_path} does not exist." + "File at provided remote location:" + f" {metadata.file_path} does not exist." ) LOGGER.debug("Removing file at path: %s", metadata.file_path) @@ -188,7 +187,7 @@ class BackupAgentClient: await self.sftp.unlink(metadata.metadata_file) async def async_list_backups(self) -> list[AgentBackup]: - """Iterate through a list of metadata files and return a list of `AgentBackup` objects.""" + """Iterate through metadata files and return a list of `AgentBackup` objects.""" backups: list[AgentBackup] = [] @@ -219,7 +218,7 @@ class BackupAgentClient: iterator: AsyncIterator[bytes], backup: AgentBackup, ) -> None: - """Accept `iterator` as bytes iterator and write backup archive to SFTP Server.""" + """Accept `iterator` as bytes iterator and write backup archive.""" file_path = ( f"{self.cfg.runtime_data.backup_location}/{suggested_filename(backup)}" @@ -246,8 +245,10 @@ class BackupAgentClient: async def iter_file(self, backup_id: str) -> AsyncFileIterator: """Return Async File Iterator object. - `SFTPClientFile` object (that would be returned with `sftp.open`) is not an iterator. - So we return custom made class - `AsyncFileIterator` that would allow iteration on file object. + `SFTPClientFile` object (that would be returned with + `sftp.open`) is not an iterator. So we return custom + made class - `AsyncFileIterator` that would allow + iteration on file object. Raises: ------ @@ -296,7 +297,9 @@ class BackupAgentClient: ) except (OSError, PermissionDenied) as e: raise BackupAgentError( - "Failure while attempting to establish SSH connection. Please check SSH credentials and if changed, re-install the integration" + "Failure while attempting to establish SSH" + " connection. Please check SSH credentials" + " and if changed, re-install the integration" ) from e # Configure SFTP Client Connection @@ -305,7 +308,8 @@ class BackupAgentClient: await self.sftp.chdir(self.cfg.runtime_data.backup_location) except (SFTPNoSuchFile, SFTPPermissionDenied) as e: raise BackupAgentError( - "Failed to create SFTP client. Re-installing integration might be required" + "Failed to create SFTP client." + " Re-installing integration might be required" ) from e return self diff --git a/homeassistant/components/sftp_storage/config_flow.py b/homeassistant/components/sftp_storage/config_flow.py index cecd7d54b35..de7c13307b7 100644 --- a/homeassistant/components/sftp_storage/config_flow.py +++ b/homeassistant/components/sftp_storage/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the SFTP Storage integration.""" -from __future__ import annotations - from contextlib import suppress from pathlib import Path import shutil @@ -14,6 +12,7 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.selector import ( FileSelector, @@ -29,11 +28,7 @@ from . import SFTPConfigEntryData from .client import get_client_options from .const import ( CONF_BACKUP_LOCATION, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, CONF_PRIVATE_KEY_FILE, - CONF_USERNAME, DEFAULT_PKEY_NAME, DOMAIN, LOGGER, @@ -60,11 +55,11 @@ class SFTPStorageException(Exception): class SFTPStorageInvalidPrivateKey(SFTPStorageException): - """Exception raised during config flow - when user provided invalid private key file.""" + """Exception raised when user provided invalid private key file.""" class SFTPStorageMissingPasswordOrPkey(SFTPStorageException): - """Exception raised during config flow - when user did not provide password or private key file.""" + """Exception raised when user did not provide password or private key file.""" class SFTPFlowHandler(ConfigFlow, domain=DOMAIN): @@ -87,8 +82,10 @@ class SFTPFlowHandler(ConfigFlow, domain=DOMAIN): Returns: the possibly updated `user_input`. Raises: - - SFTPStorageMissingPasswordOrPkey: Neither password nor private key provided - - SFTPStorageInvalidPrivateKey: The provided private key has an invalid format + - SFTPStorageMissingPasswordOrPkey: Neither password + nor private key provided + - SFTPStorageInvalidPrivateKey: The provided private + key has an invalid format """ # If neither password nor private key is provided, error out; @@ -155,9 +152,11 @@ class SFTPFlowHandler(ConfigFlow, domain=DOMAIN): # - OSError, if host or port are not correct. # - SFTPStorageInvalidPrivateKey, if private key is not valid format. # - asyncssh.misc.PermissionDenied, if credentials are not correct. - # - SFTPStorageMissingPasswordOrPkey, if password and private key are not provided. + # - SFTPStorageMissingPasswordOrPkey, if password + # and private key are not provided. # - asyncssh.sftp.SFTPNoSuchFile, if directory does not exist. - # - asyncssh.sftp.SFTPPermissionDenied, if we don't have access to said directory + # - asyncssh.sftp.SFTPPermissionDenied, + # if we don't have access to said directory async with ( connect( host=user_config.host, diff --git a/homeassistant/components/sftp_storage/const.py b/homeassistant/components/sftp_storage/const.py index aa582760be8..c6c3693aa48 100644 --- a/homeassistant/components/sftp_storage/const.py +++ b/homeassistant/components/sftp_storage/const.py @@ -1,7 +1,5 @@ """Constants for the SFTP Storage integration.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Final @@ -12,10 +10,6 @@ DOMAIN: Final = "sftp_storage" LOGGER = logging.getLogger(__package__) -CONF_HOST: Final = "host" -CONF_PORT: Final = "port" -CONF_USERNAME: Final = "username" -CONF_PASSWORD: Final = "password" CONF_PRIVATE_KEY_FILE: Final = "private_key_file" CONF_BACKUP_LOCATION: Final = "backup_location" diff --git a/homeassistant/components/sftp_storage/quality_scale.yaml b/homeassistant/components/sftp_storage/quality_scale.yaml index 1d34426be02..8842a23ff01 100644 --- a/homeassistant/components/sftp_storage/quality_scale.yaml +++ b/homeassistant/components/sftp_storage/quality_scale.yaml @@ -111,7 +111,7 @@ rules: status: exempt comment: | This integration does not have entities. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: | diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 4fc53614fa2..4a903cf3786 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,7 +13,6 @@ from sharkiq import ( ) from homeassistant import exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -28,7 +27,7 @@ from .const import ( SHARKIQ_REGION_DEFAULT, SHARKIQ_REGION_EUROPE, ) -from .coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqConfigEntry, SharkIqUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -60,7 +59,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: SharkIqConfigEntry +) -> bool: """Initialize the sharkiq platform via config entry.""" if CONF_REGION not in config_entry.data: hass.config_entries.async_update_entry( @@ -93,8 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -116,15 +116,15 @@ async def async_update_options(hass: HomeAssistant, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SharkIqConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: - domain_data = hass.data[DOMAIN][config_entry.entry_id] with suppress(SharkIqAuthError): - await async_disconnect_or_timeout(coordinator=domain_data) - hass.data[DOMAIN].pop(config_entry.entry_id) + await async_disconnect_or_timeout(coordinator=config_entry.runtime_data) return unload_ok diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 7174c634787..d66d67223f5 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Shark IQ integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from typing import Any @@ -73,7 +71,9 @@ async def _validate_input( LOGGER.exception("Unexpected exception") LOGGER.error(error) raise UnknownAuth( - "An unknown error occurred. Check your region settings and open an issue on Github if the issue persists." + "An unknown error occurred. Check your region" + " settings and open an issue on GitHub" + " if the issue persists." ) from error # Return info that you want to store in the config entry. diff --git a/homeassistant/components/sharkiq/coordinator.py b/homeassistant/components/sharkiq/coordinator.py index 1a4a819cdf6..5e8e4338889 100644 --- a/homeassistant/components/sharkiq/coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for shark iq vacuums.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta @@ -20,16 +18,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL +type SharkIqConfigEntry = ConfigEntry[SharkIqUpdateCoordinator] + class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Define a wrapper class to update Shark IQ data.""" - config_entry: ConfigEntry + config_entry: SharkIqConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SharkIqConfigEntry, ayla_api: AylaApi, shark_vacs: list[SharkIqVacuum], ) -> None: diff --git a/homeassistant/components/sharkiq/services.py b/homeassistant/components/sharkiq/services.py index 631ce294fc5..df9578e33a6 100644 --- a/homeassistant/components/sharkiq/services.py +++ b/homeassistant/components/sharkiq/services.py @@ -1,7 +1,5 @@ """Shark IQ services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN diff --git a/homeassistant/components/sharkiq/services.yaml b/homeassistant/components/sharkiq/services.yaml index 7f82ed40702..6a6eb11fd53 100644 --- a/homeassistant/components/sharkiq/services.yaml +++ b/homeassistant/components/sharkiq/services.yaml @@ -7,7 +7,6 @@ clean_room: fields: rooms: required: true - advanced: false example: "Kitchen" default: "" selector: diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 3856bf73554..94e0bf82220 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -1,7 +1,5 @@ """Shark IQ Wrapper.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any @@ -12,7 +10,6 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_ROOMS, DOMAIN, LOGGER, SHARK -from .coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqConfigEntry, SharkIqUpdateCoordinator OPERATING_STATE_MAP = { OperatingModes.PAUSE: VacuumActivity.PAUSED, @@ -46,11 +43,11 @@ ATTR_RECHARGE_RESUME = "recharge_and_resume" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SharkIqConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Shark IQ vacuum cleaner.""" - coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data devices: Iterable[SharkIqVacuum] = coordinator.shark_vacs.values() device_names = [d.name for d in devices] LOGGER.debug( @@ -140,9 +137,10 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum def activity(self) -> VacuumActivity | None: """Get the current vacuum state. - NB: Currently, we do not return an error state because they can be very, very stale. - In the app, these are (usually) handled by showing the robot as stopped and sending the - user a notification. + NB: Currently, we do not return an error state + because they can be very, very stale. In the app, + these are (usually) handled by showing the robot as + stopped and sending the user a notification. """ if self.sharkiq.get_property_value(Properties.CHARGING_STATUS): return VacuumActivity.DOCKED diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 8dbd867a389..61863489ce8 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -1,7 +1,5 @@ """Expose regular shell commands as services.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress @@ -191,6 +189,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Reload shell_command from YAML configuration.""" try: raw_config = await conf_util.async_hass_config_yaml(hass) + # pylint: disable-next=home-assistant-action-swallowed-exception except HomeAssistantError as err: _LOGGER.error("Error loading configuration.yaml: %s", err) return diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index f8e72a4ffe9..02ebbdc530f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -1,7 +1,5 @@ """The Shelly integration.""" -from __future__ import annotations - from functools import partial from typing import Final @@ -41,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, + CONF_BLE_SCANNER_MODE, CONF_COAP_PORT, CONF_SLEEP_PERIOD, DOMAIN, @@ -48,6 +47,7 @@ from .const import ( LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, + BLEScannerMode, ) from .coordinator import ( ShellyBlockCoordinator, @@ -84,6 +84,7 @@ PLATFORMS: Final = [ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -117,6 +118,8 @@ CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Shelly component.""" if (conf := config.get(DOMAIN)) is not None: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = {CONF_COAP_PORT: conf[CONF_COAP_PORT]} async_setup_services(hass) @@ -124,20 +127,35 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version > 1 or (entry.version == 1 and entry.minor_version > 3): + return False + if entry.minor_version < 3: + # One-time flip of explicit Active scanning to Auto so existing + # installs get the new battery-friendly default; Passive stays + # Passive because users picked it deliberately. + options = dict(entry.options) + if options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE: + options[CONF_BLE_SCANNER_MODE] = BLEScannerMode.AUTO + hass.config_entries.async_update_entry(entry, options=options, minor_version=3) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Set up Shelly from a config entry.""" entry.runtime_data = ShellyEntryData([]) - # The community integration for Shelly devices uses Shelly domain as well as Core - # integration. If the user removes the community integration but doesn't remove - # the config entry, Core integration will try to configure that config entry with - # an error. The config entry data for this community integration doesn't contain - # host value, so if host isn't present, config entry will not be configured. + # The custom component for Shelly devices uses shelly domain as well as core + # integration. If the user removes the custom component but doesn't remove the + # config entry, core integration will try to configure that config entry with an + # error. The config entry data for this custom component doesn't contain host + # value, so if host isn't present, config entry will not be configured. if not entry.data.get(CONF_HOST): LOGGER.warning( ( - "The config entry %s probably comes from a community integration, " - "please remove it if you want to use the Core Shelly integration" + "The config entry %s probably comes from a custom integration, please" + " remove it if you want to use core Shelly integration" ), entry.title, ) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 632e5277de5..7116c4453f7 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final, cast @@ -350,6 +348,33 @@ RPC_SENSORS: Final = { device_class=BinarySensorDeviceClass.OCCUPANCY, entity_class=RpcPresenceBinarySensor, ), + "occupancy": RpcBinarySensorDescription( + key="occupancy", + sub_key="value", + device_class=BinarySensorDeviceClass.OCCUPANCY, + ), + "cury_tilt": RpcBinarySensorDescription( + key="cury", + sub_key="errors", + translation_key="tilt", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda status, _: ( + False if status is None else "orientation_tilt" in status + ), + supported=lambda status: status.get("slots") is not None, + ), + "cury_rotation": RpcBinarySensorDescription( + key="cury", + sub_key="errors", + translation_key="rotation", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda status, _: ( + False if status is None else "orientation_plug_rotated" in status + ), + supported=lambda status: status.get("slots") is not None, + ), } diff --git a/homeassistant/components/shelly/ble_provisioning.py b/homeassistant/components/shelly/ble_provisioning.py index e2b1d2b7ab3..dc3eceecf87 100644 --- a/homeassistant/components/shelly/ble_provisioning.py +++ b/homeassistant/components/shelly/ble_provisioning.py @@ -1,7 +1,5 @@ """BLE provisioning helpers for Shelly integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, field import logging diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 2b772bd1b78..7d44dd7c646 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -1,7 +1,5 @@ """Bluetooth support for shelly.""" -from __future__ import annotations - from typing import TYPE_CHECKING from aioshelly.ble import async_start_scanner, create_scanner @@ -21,6 +19,7 @@ if TYPE_CHECKING: BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = { BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE, BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE, + BLEScannerMode.AUTO: BluetoothScanningMode.AUTO, } @@ -33,13 +32,25 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode] + # Options persist as plain strings, coerce so `is` checks work. + scanner_mode = BLEScannerMode(scanner_mode) + requested_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode] + # AUTO runs the radio passive and lets habluetooth's auto-scheduler + # flip the BLE script to active on demand. + firmware_active = scanner_mode is BLEScannerMode.ACTIVE + current_mode = ( + BluetoothScanningMode.ACTIVE + if firmware_active + else BluetoothScanningMode.PASSIVE + ) scanner = create_scanner( coordinator.bluetooth_source, entry.title, - requested_mode=bluetooth_scanning_mode, - current_mode=bluetooth_scanning_mode, + requested_mode=requested_mode, + current_mode=current_mode, ) + if scanner_mode is BLEScannerMode.AUTO: + scanner.set_active_window_provider(device) unload_callbacks = [ async_register_scanner( hass, @@ -54,7 +65,7 @@ async def async_connect_scanner( ] await async_start_scanner( device=device, - active=scanner_mode == BLEScannerMode.ACTIVE, + active=firmware_active, event_type=BLE_SCAN_RESULT_EVENT, data_version=BLE_SCAN_RESULT_VERSION, ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 9fb3cb89516..346bf34357f 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -1,7 +1,5 @@ """Button for Shelly.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index eacf61d4d3a..a26483f5c35 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -1,7 +1,5 @@ """Climate support for Shelly.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 98dcab1be7b..eb01a1a25ce 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Shelly integration.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager @@ -105,6 +103,7 @@ CONFIG_SCHEMA: Final = vol.Schema( BLE_SCANNER_OPTIONS = [ BLEScannerMode.DISABLED, + BLEScannerMode.AUTO, BLEScannerMode.ACTIVE, BLEScannerMode.PASSIVE, ] @@ -207,7 +206,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Shelly.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 host: str = "" port: int = DEFAULT_HTTP_PORT @@ -238,7 +237,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): and isinstance(model_id, int) and (model_name := get_name_from_model_id(model_id)) ): - # Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4") + # Remove spaces from model name + # (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4") return f"{model_name.replace(' ', '')}-{mac}" return f"Shelly-{mac}" @@ -409,11 +409,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_connect_and_get_info( self, host: str, port: int ) -> ConfigFlowResult | None: - """Connect to device, validate, and create entry or return None to continue flow. + """Connect to device, validate, and create entry or return None. - This helper consolidates the common logic between Zeroconf device selection - and manual entry flows. Returns a ConfigFlowResult if the flow should end - (create_entry or abort), or None if the flow should continue (e.g., to credentials). + This helper consolidates the common logic between + Zeroconf device selection and manual entry flows. + Returns a ConfigFlowResult if the flow should end + (create_entry or abort), or None if the flow should + continue (e.g., to credentials). Sets self.info, self.host, and self.port on success. """ @@ -687,7 +689,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self._async_discovered_mac(mac, host) async def _async_discovered_mac(self, mac: str, host: str) -> None: - """Abort and reconnect soon if the device with the mac address is already configured.""" + """Abort and reconnect soon if the device with the mac is already configured.""" if ( current_entry := await self.async_set_unique_id(mac) ) and current_entry.data.get(CONF_HOST) == host: @@ -807,7 +809,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): ) ssid_options = [network["ssid"] for network in sorted_networks] - # Pre-select SSID if returning from failed provisioning attempt + # Preselect SSID if returning from failed provisioning attempt suggested_values: dict[str, Any] = {} if self.selected_ssid: suggested_values[CONF_SSID] = self.selected_ssid @@ -916,7 +918,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult | None: """Provision WiFi credentials via BLE and wait for zeroconf discovery. - Returns the flow result to be stored in self._provision_result, or None if failed. + Returns the flow result to be stored in + self._provision_result, or None if failed. """ # Provision WiFi via BLE using persistent connection try: @@ -976,7 +979,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): state.port = DEFAULT_HTTP_PORT else: LOGGER.debug("BLE fallback also failed - provisioning unsuccessful") - # Store failure info and return None - provision_done will handle redirect + # Store failure info and return None + # provision_done will handle redirect return None else: state.host, state.port = result @@ -1086,7 +1090,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle failed provisioning - allow retry.""" if user_input is not None: - # User wants to retry - keep selected_ssid so it's pre-selected + # User wants to retry - keep selected_ssid so it's preselected self.wifi_networks = [] return await self.async_step_wifi_scan() diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index bfb399dc5b9..6900bcfb16f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,7 +1,5 @@ """Constants for the Shelly integration.""" -from __future__ import annotations - from enum import StrEnum from logging import Logger, getLogger import re @@ -26,6 +24,7 @@ from aioshelly.const import ( MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, MODEL_WALL_DISPLAY_X2, + MODEL_WALL_DISPLAY_X2I, MODEL_WALL_DISPLAY_XL, ) @@ -218,8 +217,6 @@ KELVIN_MIN_VALUE_COLOR: Final = 3000 BLOCK_WRONG_SLEEP_PERIOD = 21600 BLOCK_EXPECTED_SLEEP_PERIOD = 43200 -UPTIME_DEVIATION: Final = 60 - # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 @@ -227,6 +224,7 @@ SHELLY_GAS_MODELS = [MODEL_GAS] SHELLY_WALL_DISPLAY_MODELS = ( MODEL_WALL_DISPLAY, MODEL_WALL_DISPLAY_X2, + MODEL_WALL_DISPLAY_X2I, MODEL_WALL_DISPLAY_XL, ) @@ -239,6 +237,7 @@ class BLEScannerMode(StrEnum): DISABLED = "disabled" ACTIVE = "active" PASSIVE = "passive" + AUTO = "auto" BLE_SCANNER_MIN_FIRMWARE = "1.5.1" @@ -289,10 +288,8 @@ OTA_SUCCESS = "ota_success" GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" +WALL_DISPLAY_RELEASE_URL = "https://github.com/ShellyGroup/Wall-Display-Changelog" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( - MODEL_WALL_DISPLAY, - MODEL_WALL_DISPLAY_X2, - MODEL_WALL_DISPLAY_XL, MODEL_MOTION, MODEL_MOTION_2, MODEL_VALVE, @@ -347,3 +344,5 @@ TRV_CHANNEL = 0 ATTR_KEY = "key" ATTR_VALUE = "value" + +DRIVER_MISSING_ERROR = "Sensor driver missing from firmware" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 2a832c4dba4..8b4578f0e1f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -1,7 +1,5 @@ """Coordinators for the Shelly integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass @@ -124,8 +122,9 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( self.suggested_area: str | None = None device_name = device.name if device.initialized else entry.title interval_td = timedelta(seconds=update_interval) - # The device has come online at least once. In the case of a sleeping RPC - # device, this means that the device has connected to the WS server at least once. + # The device has come online at least once. In the case + # of a sleeping RPC device, this means that the device + # has connected to the WS server at least once. self._came_online_once = False super().__init__( hass, diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index f5f75db6fda..ef763dfa77e 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -1,7 +1,5 @@ """Cover for Shelly.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 740e6aae9b2..2f2a2c0009a 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Shelly.""" -from __future__ import annotations - from typing import Final import voluptuous as vol diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index 3e87f2f0959..a5117e3830b 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Shelly.""" -from __future__ import annotations - from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 9540f2560f3..6ed6834345a 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -1,7 +1,5 @@ """Shelly entity helper.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine, Mapping from dataclasses import dataclass from functools import wraps @@ -74,7 +72,7 @@ def async_setup_block_attribute_entities( for block in coordinator.device.blocks: for sensor_id in block.sensor_ids: - description = sensors.get((cast(str, block.type), sensor_id)) + description = sensors.get((block.type, sensor_id)) if description is None: continue @@ -246,7 +244,8 @@ def async_restore_rpc_attribute_entities( sensor_class: Callable, ) -> None: """Restore RPC attributes entities.""" - entities = [] + entities: list[Entity] = [] + sleep_period = config_entry.data[CONF_SLEEP_PERIOD] ent_reg = er.async_get(hass) entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) @@ -261,11 +260,13 @@ def async_restore_rpc_attribute_entities( attribute = entry.unique_id.split("-")[-1] if description := sensors.get(attribute): - entities.append( - get_entity_class(sensor_class, description)( - coordinator, key, attribute, description, entry + entity_class = get_entity_class(sensor_class, description) + if sleep_period: + entities.append( + entity_class(coordinator, key, attribute, description, entry) ) - ) + else: + entities.append(entity_class(coordinator, key, attribute, description)) if not entities: return @@ -375,7 +376,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self._attr_device_info = get_entity_block_device_info(coordinator, block) self._attr_unique_id = f"{coordinator.mac}-{block.description}" - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -430,7 +431,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Device status by entity key.""" return cast(dict, self.coordinator.device.status[self.key]) - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 9b3e73bb4c1..39f3bd77969 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -1,7 +1,5 @@ """Event for Shelly.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final @@ -165,7 +163,8 @@ def _async_setup_rpc_entry( ShellyRpcScriptEvent(coordinator, script, SCRIPT_EVENT, event_types) ) - # If a script is removed, from the device configuration, we need to remove orphaned entities + # If a script is removed, from the device configuration, + # we need to remove orphaned entities async_remove_orphaned_entities( hass, config_entry.entry_id, diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 9e0dba362df..2fb3193e386 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -1,7 +1,5 @@ """Light for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final, cast diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 309823a5eb2..870f82d9ce4 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -1,7 +1,5 @@ """Describe Shelly logbook events.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME @@ -43,7 +41,10 @@ def async_describe_events( rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: key = f"input:{channel - 1}" - input_name = f"{rpc_coordinator.device.name} {get_rpc_channel_name(rpc_coordinator.device, key)}" + input_name = ( + f"{rpc_coordinator.device.name}" + f" {get_rpc_channel_name(rpc_coordinator.device, key)}" + ) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: block_coordinator = get_block_coordinator_by_device_id(hass, device_id) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 7400cc5cf06..15ca9bef76b 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.23.1"], + "requirements": ["aioshelly==13.26.1"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/shelly/media_player.py b/homeassistant/components/shelly/media_player.py new file mode 100644 index 00000000000..79e0abaeaf1 --- /dev/null +++ b/homeassistant/components/shelly/media_player.py @@ -0,0 +1,469 @@ +"""Media player for Shelly.""" + +import base64 +import binascii +import contextlib +from dataclasses import dataclass +import datetime +import hashlib +from typing import Any, Final, cast + +from aioshelly.const import RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, + rpc_call, +) +from .utils import get_device_entry_gen + +CONTENT_TYPE_AUDIO = "audio" +CONTENT_TYPE_RADIO = "radio" + +ALLOWED_IMAGE_MIME_TYPES: Final = frozenset( + { + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + } +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RpcMediaPlayerDescription(RpcEntityDescription, MediaPlayerEntityDescription): + """Class to describe a Shelly RPC media player entity.""" + + +RPC_MEDIA_PLAYER_ENTITIES: Final = { + "media": RpcMediaPlayerDescription( + key="media", + device_class=MediaPlayerDeviceClass.SPEAKER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up media player for Shelly devices.""" + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: + return _async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return None + + +@callback +def _async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_MEDIA_PLAYER_ENTITIES, + ShellyRpcMediaPlayer, + ) + + +class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity): + """Representation of a Shelly RPC media player entity.""" + + _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + _attr_media_content_type = MediaType.MUSIC + entity_description: RpcMediaPlayerDescription + + _last_media_position: int | None = None + _last_media_position_updated_at: datetime.datetime | None = None + + _cached_thumb: str | None = None + _cached_thumb_result: tuple[bytes, str] | None = None + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcMediaPlayerDescription, + ) -> None: + """Initialize Shelly RPC media player.""" + super().__init__(coordinator, key, attribute, description) + + @property + def _media_meta(self) -> dict[str, Any]: + """Return the media metadata.""" + return cast(dict[str, Any], self.status["playback"].get("media_meta", {})) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the media player.""" + if self.status["playback"]["buffering"]: + return MediaPlayerState.BUFFERING + + if self.status["playback"]["enable"]: + return MediaPlayerState.PLAYING + + return MediaPlayerState.IDLE + + @property + def volume_level(self) -> float | None: + """Return the volume level of the media player (0..1).""" + volume = self.status["playback"]["volume"] + + return cast(float, volume) / 10 + + @property + def media_title(self) -> str | None: + """Return the title of current playing media.""" + if title := self._media_meta.get("title"): + return cast(str, title) + + return None + + @property + def media_artist(self) -> str | None: + """Return the artist of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if artist := self._media_meta.get("artist"): + return cast(str, artist) + + return None + + @property + def media_album_name(self) -> str | None: + """Return the album name of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if album := self._media_meta.get("album"): + return cast(str, album) + + return None + + @property + def media_duration(self) -> int | None: + """Return the duration of current playing media in seconds.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if (duration := self._media_meta.get("duration")) is not None: + return cast(int, duration) // 1000 + + return None + + @property + def media_position(self) -> int | None: + """Return the current playback position in seconds.""" + if (position := self._get_updated_media_position()) is not None: + return position // 1000 + + return None + + @property + def media_position_updated_at(self) -> datetime.datetime | None: + """Return when the position was last updated.""" + self._get_updated_media_position() + + return self._last_media_position_updated_at + + @property + def entity_picture(self) -> str | None: + """Return image of the media playing.""" + if not self.available: + return None + + return super().entity_picture + + @property + def media_image_url(self) -> str | None: + """Return the image URL of current playing media.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("http"): + return cast(str, thumb) + + return None + + @property + def media_image_remotely_accessible(self) -> bool: + """Return True if the image URL is remotely accessible.""" + return self.media_image_url is not None + + @property + def media_image_hash(self) -> str | None: + """Hash value for media image.""" + thumb = self._media_meta.get("thumb") + if not thumb or self._decode_image_data(thumb) is None: + return super().media_image_hash + + return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16] + + def _get_updated_media_position(self) -> int | None: + """Return the current playback position and update its timestamp.""" + if (position := self._media_meta.get("position")) is None: + self._last_media_position = None + self._last_media_position_updated_at = None + return None + + current_position = cast(int, position) + if current_position != self._last_media_position: + self._last_media_position = current_position + self._last_media_position_updated_at = dt_util.utcnow() + + return current_position + + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch media image of current playing track.""" + thumb = self._media_meta.get("thumb") + if not thumb or (result := self._decode_image_data(thumb)) is None: + return await super().async_get_media_image() + + return result + + @rpc_call + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.coordinator.device.media_stop() + + @rpc_call + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.coordinator.device.media_next() + + @rpc_call + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.device.media_previous() + + @rpc_call + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.device.media_set_volume(round(volume * 10)) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse radio stations and audio files.""" + if not media_content_type: + return await self._async_browse_media_root() + + try: + if media_content_type == CONTENT_TYPE_RADIO: + return await self._async_browse_radio_stations(expanded=True) + if media_content_type == CONTENT_TYPE_AUDIO: + return await self._async_browse_audio_files(expanded=True) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError as err: + await self.coordinator.async_shutdown_device_and_start_reauth() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "device": self.coordinator.name, + }, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_content_type", + translation_placeholders={"media_content_type": str(media_content_type)}, + ) + + async def _async_browse_media_root(self) -> BrowseMedia: + """Return root BrowseMedia tree.""" + return BrowseMedia( + title="Shelly", + media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id="", + children=[ + await self._async_browse_radio_stations(), + await self._async_browse_audio_files(), + ], + can_play=False, + can_expand=True, + ) + + async def _async_browse_audio_files(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for audio files.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_media() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=item["title"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=str(item["id"]), + thumbnail=item["preview"], + can_play=True, + can_expand=False, + ) + for item in result + if item["type"] == "AUDIO" + ] + else: + children = None + + return BrowseMedia( + title="Audio files", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=CONTENT_TYPE_AUDIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + async def _async_browse_radio_stations(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for radio stations.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_radio_stations() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=station["name"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=str(station["id"]), + thumbnail=station["icon"], + can_play=True, + can_expand=False, + ) + for station in result + ] + else: + children = None + + return BrowseMedia( + title="Radio stations", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=CONTENT_TYPE_RADIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + @rpc_call + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play media by type and id.""" + if media_id.isdecimal() is False: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_id", + translation_placeholders={"media_id": media_id}, + ) + + if media_type == CONTENT_TYPE_RADIO: + await self.coordinator.device.media_play_radio_station(int(media_id)) + return + + if media_type == CONTENT_TYPE_AUDIO: + await self.coordinator.device.media_play_media(int(media_id)) + return + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={"media_type": str(media_type)}, + ) + + def _decode_image_data(self, thumb: str) -> tuple[bytes, str] | None: + """Return image_bytes and mime_type for a valid image data or None.""" + if thumb == self._cached_thumb: + return self._cached_thumb_result + + result: tuple[bytes, str] | None = None + if thumb.startswith("data"): + try: + prefix, image_data = thumb.split(",", 1) + mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1] + except IndexError, ValueError: + pass + else: + if mime in ALLOWED_IMAGE_MIME_TYPES: + with contextlib.suppress(binascii.Error): + result = base64.b64decode(image_data, validate=True), mime + + self._cached_thumb = thumb + self._cached_thumb_result = result + + return result diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 305dd5ebd70..e99cb0cff13 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -1,7 +1,5 @@ """Number for Shelly.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Final, cast diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index 47df0a3a079..479c29108a3 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -1,7 +1,5 @@ """Repairs flow for Shelly.""" -from __future__ import annotations - from typing import TYPE_CHECKING from aioshelly.block_device import BlockDevice @@ -11,8 +9,11 @@ from aioshelly.rpc_device import RpcDevice from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -52,10 +53,9 @@ def async_manage_ble_scanner_firmware_unsupported_issue( if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3): firmware = AwesomeVersion(device.shelly["ver"]) - if ( - firmware < BLE_SCANNER_MIN_FIRMWARE - and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE - ): + if firmware < BLE_SCANNER_MIN_FIRMWARE and entry.options.get( + CONF_BLE_SCANNER_MODE + ) in (BLEScannerMode.ACTIVE, BLEScannerMode.AUTO): ir.async_create_issue( hass, DOMAIN, @@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue( device = entry.runtime_data.rpc.device + if not device.initialized: + return + if ( (ws_config := device.config.get("ws")) and ws_config["enable"] @@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue( device = entry.runtime_data.rpc.device + if not device.initialized: + return + # Check if WiFi AP is enabled and is open (no password) if ( (wifi_config := device.config.get("wifi")) @@ -208,7 +214,7 @@ class CoiotConfigureFlow(ShellyBlockRepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -222,7 +228,7 @@ class CoiotConfigureFlow(ShellyBlockRepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" coiot_addr = await get_coiot_address(self.hass) coiot_port = get_coiot_port(self.hass) @@ -238,7 +244,7 @@ class CoiotConfigureFlow(ShellyBlockRepairsFlow): async def async_step_ignore( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the ignore step of a fix flow.""" ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) return self.async_abort(reason="issue_ignored") @@ -253,13 +259,13 @@ class ShellyRpcRepairsFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: return await self._async_step_confirm() @@ -275,7 +281,7 @@ class ShellyRpcRepairsFlow(RepairsFlow): description_placeholders=description_placeholders, ) - async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + async def _async_step_confirm(self) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" raise NotImplementedError @@ -283,13 +289,13 @@ class ShellyRpcRepairsFlow(RepairsFlow): class FirmwareUpdateFlow(ShellyRpcRepairsFlow): """Handler for Firmware Update flow.""" - async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + async def _async_step_confirm(self) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" return await self.async_step_update_firmware() async def async_step_update_firmware( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if not self._device.status["sys"]["available_updates"]: return self.async_abort(reason="update_not_available") @@ -304,13 +310,13 @@ class FirmwareUpdateFlow(ShellyRpcRepairsFlow): class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow): """Handler for Disable Outbound WebSocket flow.""" - async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + async def _async_step_confirm(self) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" return await self.async_step_disable_outbound_websocket() async def async_step_disable_outbound_websocket( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" try: result = await self._device.ws_setconfig( @@ -334,7 +340,7 @@ class DisableOpenWiFiApFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -348,7 +354,7 @@ class DisableOpenWiFiApFlow(RepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" try: result = await self._device.wifi_setconfig(ap_enable=False) @@ -361,7 +367,7 @@ class DisableOpenWiFiApFlow(RepairsFlow): async def async_step_ignore( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the ignore step of a fix flow.""" ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) return self.async_abort(reason="issue_ignored") diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 262efcd01ee..496e0161604 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -1,7 +1,5 @@ """Select for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, Final diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 5eeb818c59a..8023f0afe6a 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,8 +1,7 @@ """Sensor for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass +from datetime import timedelta from typing import Final, cast from aioshelly.block_device import Block @@ -41,8 +40,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow -from .const import CONF_SLEEP_PERIOD, ROLE_GENERIC +from .const import CONF_SLEEP_PERIOD, DRIVER_MISSING_ERROR, ROLE_GENERIC from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -62,7 +62,6 @@ from .utils import ( async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, - get_device_uptime, get_shelly_air_lamp_life, get_virtual_component_unit, is_rpc_wifi_stations_disabled, @@ -466,9 +465,8 @@ REST_SENSORS: Final = { ), "uptime": RestSensorDescription( key="uptime", - translation_key="last_restart", - value=lambda status, last: get_device_uptime(status["uptime"], last), - device_class=SensorDeviceClass.TIMESTAMP, + value=lambda status, _: utcnow() - timedelta(seconds=status["uptime"]), + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -1227,6 +1225,9 @@ RPC_SENSORS: Final = { suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + removal_condition=lambda _, status, key: ( + DRIVER_MISSING_ERROR in status[key].get("errors", []) + ), ), "rssi": RpcSensorDescription( key="wifi", @@ -1242,9 +1243,8 @@ RPC_SENSORS: Final = { "uptime": RpcSensorDescription( key="sys", sub_key="uptime", - translation_key="last_restart", - value=get_device_uptime, - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, + value=lambda status, _: utcnow() - timedelta(seconds=status), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, @@ -1256,6 +1256,9 @@ RPC_SENSORS: Final = { suggested_display_precision=1, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, + removal_condition=lambda _, status, key: ( + DRIVER_MISSING_ERROR in status[key].get("errors", []) + ), ), "battery": RpcSensorDescription( key="devicepower", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 1a2fc3513f6..bad550bfd3d 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -211,8 +211,14 @@ "restart_required": { "name": "Restart required" }, + "rotation": { + "name": "Rotation" + }, "smoke_with_channel_name": { "name": "{channel_name} smoke" + }, + "tilt": { + "name": "Tilt" } }, "button": { @@ -418,9 +424,6 @@ "lamp_life": { "name": "Lamp life" }, - "last_restart": { - "name": "Last restart" - }, "left_slot_level": { "name": "Left slot level" }, @@ -651,6 +654,15 @@ "rpc_call_error": { "message": "RPC call error occurred for {device}" }, + "unsupported_media_content_type": { + "message": "Unsupported media content type for Shelly device: {media_content_type}" + }, + "unsupported_media_id": { + "message": "Unsupported media ID for Shelly device: {media_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Shelly device: {media_type}" + }, "update_error": { "message": "An error occurred while retrieving data from {device}" }, @@ -661,7 +673,7 @@ "message": "An error occurred while reconnecting to {device}" }, "update_error_sleeping_device": { - "message": "Sleeping device did not update within {period} seconds interval" + "message": "Sleeping device {device} did not update within {period} seconds interval" } }, "issues": { @@ -673,7 +685,7 @@ }, "step": { "confirm": { - "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as a BLE scanner in Active or Auto mode. This firmware version is not supported for these BLE scanner modes.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.", "title": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::title%]" } } @@ -775,16 +787,17 @@ "data_description": { "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning." }, - "description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices." + "description": "Auto is recommended for most setups; the Shelly listens passively and only briefly switches to active when needed, saving battery on your Bluetooth devices." } } }, "selector": { "ble_scanner_mode": { "options": { - "active": "[%key:common::state::active%]", + "active": "Active (uses more device battery, fastest updates)", + "auto": "Auto (recommended, saves device battery)", "disabled": "[%key:common::state::disabled%]", - "passive": "Passive" + "passive": "Passive (lowest device battery use, some details may be missing)" } }, "device": { diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5a4f8debd1b..e616505b891 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,7 +1,5 @@ """Switch for Shelly.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast @@ -263,7 +261,6 @@ RPC_SWITCHES = { method_on="cury_set", method_off="cury_set", method_params_fn=lambda id, value: (id, "left", value), - entity_registry_enabled_default=True, available=lambda status: ( (left := status["left"]) is not None and left.get("vial", {}).get("level", -1) != -1 @@ -277,7 +274,6 @@ RPC_SWITCHES = { method_on="cury_boost", method_off="cury_stop_boost", method_params_fn=lambda id, _: (id, "left"), - entity_registry_enabled_default=True, available=lambda status: ( (left := status["left"]) is not None and left.get("vial", {}).get("level", -1) != -1 @@ -291,7 +287,6 @@ RPC_SWITCHES = { method_on="cury_set", method_off="cury_set", method_params_fn=lambda id, value: (id, "right", value), - entity_registry_enabled_default=True, available=lambda status: ( (right := status["right"]) is not None and right.get("vial", {}).get("level", -1) != -1 @@ -305,7 +300,6 @@ RPC_SWITCHES = { method_on="cury_boost", method_off="cury_stop_boost", method_params_fn=lambda id, _: (id, "right"), - entity_registry_enabled_default=True, available=lambda status: ( (right := status["right"]) is not None and right.get("vial", {}).get("level", -1) != -1 diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 4d526f65a7e..74b454d06a7 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -1,7 +1,5 @@ """Text for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 3dfdc8ae55c..95820c3dc6f 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -1,7 +1,5 @@ """Update entities for Shelly devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -349,10 +347,7 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="ota_update_rpc_error", - translation_placeholders={ - "entity": self.entity_id, - "device": self.coordinator.name, - }, + translation_placeholders={"device": self.coordinator.name}, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 27afa335e5e..7ac27001cc8 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -1,9 +1,6 @@ """Shelly helpers functions.""" -from __future__ import annotations - from collections.abc import Iterable, Mapping -from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address from typing import TYPE_CHECKING, Any, cast @@ -51,7 +48,6 @@ from homeassistant.helpers.device_registry import ( DeviceInfo, ) from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.util.dt import utcnow from .const import ( API_WS_URL, @@ -76,10 +72,11 @@ from .const import ( SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHELLY_EMIT_EVENT_PATTERN, + SHELLY_WALL_DISPLAY_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, - UPTIME_DEVIATION, VIRTUAL_COMPONENTS, VIRTUAL_COMPONENTS_MAP, + WALL_DISPLAY_RELEASE_URL, All_LIGHT_TYPES, ) @@ -120,7 +117,7 @@ def get_block_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None: """Get custom name from device settings.""" - if block and (key := cast(str, block.type) + "s") and key in device.settings: + if block and (key := block.type + "s") and key in device.settings: assert block.channel if name := device.settings[key][int(block.channel)].get("name"): @@ -192,29 +189,6 @@ def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: return is_block_channel_type_light(settings, block) -def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: - """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=uptime) - - if ( - not last_uptime - or (diff := abs((delta_uptime - last_uptime).total_seconds())) - > UPTIME_DEVIATION - ): - if last_uptime: - LOGGER.debug( - "Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s", - diff, - UPTIME_DEVIATION, - uptime, - last_uptime, - delta_uptime, - ) - return delta_uptime - - return last_uptime - - def get_block_input_triggers( device: BlockDevice, block: Block ) -> list[tuple[str, str]]: @@ -249,6 +223,8 @@ def get_shbtn_input_triggers() -> list[tuple[str, str]]: def get_coiot_port(hass: HomeAssistant) -> int: """Get CoIoT port from config.""" if DOMAIN in hass.data: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data return cast(int, hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT)) return DEFAULT_COAP_PORT @@ -588,6 +564,9 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: ) or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None + if model in SHELLY_WALL_DISPLAY_MODELS: + return WALL_DISPLAY_RELEASE_URL + if beta: return GEN2_BETA_RELEASE_URL diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index c964142d656..e22a1f1744a 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -1,7 +1,5 @@ """Valve for Shelly.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index ef0f4dafd83..b475bc016aa 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -1,7 +1,5 @@ """Sensor for displaying the number of result on Shodan.io.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index e60acf4b377..c1b7eaf54fd 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,12 +1,8 @@ """Support to manage a shopping list.""" -from __future__ import annotations - -from collections.abc import Callable from http import HTTPStatus import logging -from typing import Any, cast -import uuid +from typing import Any from aiohttp import web import voluptuous as vol @@ -14,19 +10,26 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import save_json +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, + callback, +) +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import JsonValueType, load_json_array +from .common import ( + NoMatchingShoppingListItem, + ShoppingData, + ShoppingListConfigEntry, + _get_shopping_data, +) from .const import ( ATTR_REVERSE, DEFAULT_REVERSE, DOMAIN, - EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, SERVICE_CLEAR_COMPLETED_ITEMS, SERVICE_COMPLETE_ALL, @@ -39,12 +42,9 @@ from .const import ( PLATFORMS = [Platform.TODO] -ATTR_COMPLETE = "complete" - _LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) -ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) -PERSISTENCE = ".shopping_list.json" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) @@ -59,55 +59,74 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) + hass.async_create_task(_async_setup(hass)) return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def _async_setup(hass: HomeAssistant) -> None: + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Shopping List", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ShoppingListConfigEntry +) -> bool: """Set up shopping list from config flow.""" async def add_item_service(call: ServiceCall) -> None: """Add an item with `name`.""" - data = hass.data[DOMAIN] - await data.async_add(call.data[ATTR_NAME]) + await config_entry.runtime_data.async_add(call.data[ATTR_NAME]) async def remove_item_service(call: ServiceCall) -> None: """Remove the first item with matching `name`.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data name = call.data[ATTR_NAME] try: item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception except IndexError: _LOGGER.error("Removing of item failed: %s cannot be found", name) else: - await data.async_remove(item["id"]) + await data.async_remove(str(item["id"])) async def complete_item_service(call: ServiceCall) -> None: """Mark the first item with matching `name` as completed.""" - data = hass.data[DOMAIN] name = call.data[ATTR_NAME] try: - await data.async_complete(name) + await config_entry.runtime_data.async_complete(name) + # pylint: disable-next=home-assistant-action-swallowed-exception except NoMatchingShoppingListItem: _LOGGER.error("Completing of item failed: %s cannot be found", name) async def incomplete_item_service(call: ServiceCall) -> None: """Mark the first item with matching `name` as incomplete.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data name = call.data[ATTR_NAME] try: item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception except IndexError: _LOGGER.error("Restoring of item failed: %s cannot be found", name) else: - await data.async_update(item["id"], {"name": name, "complete": False}) + await data.async_update(str(item["id"]), {"name": name, "complete": False}) async def complete_all_service(call: ServiceCall) -> None: """Mark all items in the list as complete.""" @@ -125,42 +144,50 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Sort all items by name.""" await data.async_sort(call.data[ATTR_REVERSE]) - data = hass.data[DOMAIN] = ShoppingData(hass) + data = config_entry.runtime_data = ShoppingData(hass) await data.async_load() + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_INCOMPLETE_ITEM, incomplete_item_service, schema=SERVICE_ITEM_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_COMPLETE_ALL, complete_all_service, schema=SERVICE_LIST_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_INCOMPLETE_ALL, incomplete_all_service, schema=SERVICE_LIST_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_CLEAR_COMPLETED_ITEMS, clear_completed_items_service, schema=SERVICE_LIST_SCHEMA, ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SORT, @@ -185,247 +212,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -class NoMatchingShoppingListItem(Exception): - """No matching item could be found in the shopping list.""" - - -class ShoppingData: - """Class to hold shopping list data.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the shopping list.""" - self.hass = hass - self.items: list[dict[str, JsonValueType]] = [] - self._listeners: list[Callable[[], None]] = [] - - async def async_add( - self, name: str | None, complete: bool = False, context: Context | None = None - ) -> dict[str, JsonValueType]: - """Add a shopping list item.""" - item: dict[str, JsonValueType] = { - "name": name, - "id": uuid.uuid4().hex, - "complete": complete, - } - self.items.append(item) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "add", "item": item}, - context=context, - ) - return item - - async def async_remove( - self, item_id: str, context: Context | None = None - ) -> dict[str, JsonValueType] | None: - """Remove a shopping list item.""" - removed = await self.async_remove_items( - item_ids=set({item_id}), context=context - ) - return next(iter(removed), None) - - async def async_remove_items( - self, item_ids: set[str], context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Remove a shopping list item.""" - items_dict: dict[str, dict[str, JsonValueType]] = {} - for itm in self.items: - item_id = cast(str, itm["id"]) - items_dict[item_id] = itm - removed = [] - for item_id in item_ids: - _LOGGER.debug( - "Removing %s", - ) - if not (item := items_dict.pop(item_id, None)): - raise NoMatchingShoppingListItem( - "Item '{item_id}' not found in shopping list" - ) - removed.append(item) - self.items = list(items_dict.values()) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - for item in removed: - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "remove", "item": item}, - context=context, - ) - return removed - - async def async_complete( - self, name: str, context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Mark all shopping list items with the given name as complete.""" - complete_items = [ - item for item in self.items if item["name"] == name and not item["complete"] - ] - - if len(complete_items) == 0: - raise NoMatchingShoppingListItem - - for item in complete_items: - _LOGGER.debug("Completing %s", item) - item["complete"] = True - await self.hass.async_add_executor_job(self.save) - self._async_notify() - for item in complete_items: - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "complete", "item": item}, - context=context, - ) - return complete_items - - async def async_update( - self, item_id: str | None, info: dict[str, Any], context: Context | None = None - ) -> dict[str, JsonValueType]: - """Update a shopping list item.""" - item = next((itm for itm in self.items if itm["id"] == item_id), None) - - if item is None: - raise NoMatchingShoppingListItem - - info = ITEM_UPDATE_SCHEMA(info) - item.update(info) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "update", "item": item}, - context=context, - ) - return item - - async def async_clear_completed(self, context: Context | None = None) -> None: - """Clear completed items.""" - self.items = [itm for itm in self.items if not itm["complete"]] - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "clear"}, - context=context, - ) - - async def async_update_list( - self, info: dict[str, JsonValueType], context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Update all items in the list.""" - for item in self.items: - item.update(info) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "update_list"}, - context=context, - ) - return self.items - - async def async_reorder( - self, item_ids: list[str], context: Context | None = None - ) -> None: - """Reorder items.""" - # The array for sorted items. - new_items = [] - all_items_mapping = {item["id"]: item for item in self.items} - # Append items by the order of passed in array. - for item_id in item_ids: - if item_id not in all_items_mapping: - raise NoMatchingShoppingListItem - new_items.append(all_items_mapping[item_id]) - # Remove the item from mapping after it's appended in the result array. - del all_items_mapping[item_id] - # Append the rest of the items - for value in all_items_mapping.values(): - # All the unchecked items must be passed in the item_ids array, - # so all items left in the mapping should be checked items. - if value["complete"] is False: - raise vol.Invalid( - "The item ids array doesn't contain all the unchecked shopping list" - " items." - ) - new_items.append(value) - self.items = new_items - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "reorder"}, - context=context, - ) - - async def async_move_item(self, uid: str, previous: str | None = None) -> None: - """Re-order a shopping list item.""" - if uid == previous: - return - item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} - if uid not in item_idx: - raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - if previous and previous not in item_idx: - raise NoMatchingShoppingListItem( - f"Item '{previous}' not found in shopping list" - ) - dst_idx = item_idx[previous] + 1 if previous else 0 - src_idx = item_idx[uid] - src_item = self.items.pop(src_idx) - if dst_idx > src_idx: - dst_idx -= 1 - self.items.insert(dst_idx, src_item) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "reorder"}, - ) - - async def async_sort( - self, reverse: bool = False, context: Context | None = None - ) -> None: - """Sort items by name.""" - self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "sorted"}, - context=context, - ) - - async def async_load(self) -> None: - """Load items.""" - - def load() -> list[dict[str, JsonValueType]]: - """Load the items synchronously.""" - return cast( - list[dict[str, JsonValueType]], - load_json_array(self.hass.config.path(PERSISTENCE)), - ) - - self.items = await self.hass.async_add_executor_job(load) - - def save(self) -> None: - """Save the items.""" - save_json(self.hass.config.path(PERSISTENCE), self.items) - - def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: - """Add a listener to notify when data is updated.""" - - def unsub() -> None: - self._listeners.remove(cb) - - self._listeners.append(cb) - return unsub - - def _async_notify(self) -> None: - """Notify all listeners that data has been updated.""" - for listener in self._listeners: - listener() - - class ShoppingListView(http.HomeAssistantView): """View to retrieve shopping list content.""" @@ -435,7 +221,7 @@ class ShoppingListView(http.HomeAssistantView): @callback def get(self, request: web.Request) -> web.Response: """Retrieve shopping list items.""" - return self.json(request.app[http.KEY_HASS].data[DOMAIN].items) + return self.json(_get_shopping_data(request.app[http.KEY_HASS]).items) class UpdateShoppingListItemView(http.HomeAssistantView): @@ -447,10 +233,10 @@ class UpdateShoppingListItemView(http.HomeAssistantView): async def post(self, request: web.Request, item_id: str) -> web.Response: """Update a shopping list item.""" data = await request.json() - hass = request.app[http.KEY_HASS] + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) try: - item = await hass.data[DOMAIN].async_update(item_id, data) + item = await shopping_data.async_update(item_id, data) return self.json(item) except NoMatchingShoppingListItem: return self.json_message("Item not found", HTTPStatus.NOT_FOUND) @@ -467,8 +253,8 @@ class CreateShoppingListItemView(http.HomeAssistantView): @RequestDataValidator(vol.Schema({vol.Required("name"): str})) async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Create a new shopping list item.""" - hass = request.app[http.KEY_HASS] - item = await hass.data[DOMAIN].async_add(data["name"]) + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) + item = await shopping_data.async_add(data["name"]) return self.json(item) @@ -480,8 +266,8 @@ class ClearCompletedItemsView(http.HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" - hass = request.app[http.KEY_HASS] - await hass.data[DOMAIN].async_clear_completed() + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) + await shopping_data.async_clear_completed() return self.json_message("Cleared completed items.") @@ -494,7 +280,7 @@ def websocket_handle_items( ) -> None: """Handle getting shopping_list items.""" connection.send_message( - websocket_api.result_message(msg["id"], hass.data[DOMAIN].items) + websocket_api.result_message(msg["id"], _get_shopping_data(hass).items) ) @@ -508,7 +294,7 @@ async def websocket_handle_add( msg: dict[str, Any], ) -> None: """Handle adding item to shopping_list.""" - item = await hass.data[DOMAIN].async_add( + item = await _get_shopping_data(hass).async_add( msg["name"], context=connection.context(msg) ) connection.send_message(websocket_api.result_message(msg["id"], item)) @@ -529,7 +315,9 @@ async def websocket_handle_remove( msg.pop("type") try: - item = await hass.data[DOMAIN].async_remove(item_id, connection.context(msg)) + item = await _get_shopping_data(hass).async_remove( + item_id, connection.context(msg) + ) except NoMatchingShoppingListItem: connection.send_message( websocket_api.error_message(msg_id, "item_not_found", "Item not found") @@ -560,7 +348,7 @@ async def websocket_handle_update( data = msg try: - item = await hass.data[DOMAIN].async_update( + item = await _get_shopping_data(hass).async_update( item_id, data, connection.context(msg) ) except NoMatchingShoppingListItem: @@ -580,7 +368,8 @@ async def websocket_handle_clear( msg: dict[str, Any], ) -> None: """Handle clearing shopping_list items.""" - await hass.data[DOMAIN].async_clear_completed(connection.context(msg)) + shopping_data = _get_shopping_data(hass) + await shopping_data.async_clear_completed(connection.context(msg)) connection.send_message(websocket_api.result_message(msg["id"])) @@ -599,9 +388,8 @@ async def websocket_handle_reorder( """Handle reordering shopping_list items.""" msg_id = msg.pop("id") try: - await hass.data[DOMAIN].async_reorder( - msg.pop("item_ids"), connection.context(msg) - ) + shopping_data = _get_shopping_data(hass) + await shopping_data.async_reorder(msg.pop("item_ids"), connection.context(msg)) except NoMatchingShoppingListItem: connection.send_error( msg_id, diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py new file mode 100644 index 00000000000..4a8523c39e3 --- /dev/null +++ b/homeassistant/components/shopping_list/common.py @@ -0,0 +1,279 @@ +"""Shopping list commons.""" + +from collections.abc import Callable +import logging +from typing import Any, cast +import uuid + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import save_json +from homeassistant.util.json import JsonValueType, load_json_array + +from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED + +_LOGGER = logging.getLogger(__name__) + +ATTR_COMPLETE = "complete" + +ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) +PERSISTENCE = ".shopping_list.json" + + +type ShoppingListConfigEntry = ConfigEntry[ShoppingData] + + +class NoMatchingShoppingListItem(Exception): + """No matching item could be found in the shopping list.""" + + +class ShoppingData: + """Class to hold shopping list data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the shopping list.""" + self.hass = hass + self.items: list[dict[str, JsonValueType]] = [] + self._listeners: list[Callable[[], None]] = [] + + async def async_add( + self, name: str | None, complete: bool = False, context: Context | None = None + ) -> dict[str, JsonValueType]: + """Add a shopping list item.""" + item: dict[str, JsonValueType] = { + "name": name, + "id": uuid.uuid4().hex, + "complete": complete, + } + self.items.append(item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "add", "item": item}, + context=context, + ) + return item + + async def async_remove( + self, item_id: str, context: Context | None = None + ) -> dict[str, JsonValueType] | None: + """Remove a shopping list item.""" + removed = await self.async_remove_items( + item_ids=set({item_id}), context=context + ) + return next(iter(removed), None) + + async def async_remove_items( + self, item_ids: set[str], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Remove a shopping list item.""" + items_dict: dict[str, dict[str, JsonValueType]] = {} + for itm in self.items: + item_id = cast(str, itm["id"]) + items_dict[item_id] = itm + removed = [] + for item_id in item_ids: + _LOGGER.debug("Removing %s", item_id) + if not (item := items_dict.pop(item_id, None)): + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + removed.append(item) + self.items = list(items_dict.values()) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in removed: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "remove", "item": item}, + context=context, + ) + return removed + + async def async_complete( + self, name: str, context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Mark all shopping list items with the given name as complete.""" + complete_items = [ + item for item in self.items if item["name"] == name and not item["complete"] + ] + + if len(complete_items) == 0: + raise NoMatchingShoppingListItem(f"No items with name '{name}' found") + + for item in complete_items: + _LOGGER.debug("Completing %s", item) + item["complete"] = True + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in complete_items: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "complete", "item": item}, + context=context, + ) + return complete_items + + async def async_update( + self, item_id: str | None, info: dict[str, Any], context: Context | None = None + ) -> dict[str, JsonValueType]: + """Update a shopping list item.""" + item = next((itm for itm in self.items if itm["id"] == item_id), None) + + if item is None: + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + + info = ITEM_UPDATE_SCHEMA(info) + item.update(info) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "update", "item": item}, + context=context, + ) + return item + + async def async_clear_completed(self, context: Context | None = None) -> None: + """Clear completed items.""" + self.items = [itm for itm in self.items if not itm["complete"]] + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "clear"}, + context=context, + ) + + async def async_update_list( + self, info: dict[str, JsonValueType], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Update all items in the list.""" + for item in self.items: + item.update(info) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "update_list"}, + context=context, + ) + return self.items + + async def async_reorder( + self, item_ids: list[str], context: Context | None = None + ) -> None: + """Reorder items.""" + # The array for sorted items. + new_items = [] + all_items_mapping = {item["id"]: item for item in self.items} + # Append items by the order of passed in array. + for item_id in item_ids: + if item_id not in all_items_mapping: + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + new_items.append(all_items_mapping[item_id]) + # Remove the item from mapping after it's appended in the result array. + del all_items_mapping[item_id] + # Append the rest of the items + for value in all_items_mapping.values(): + # All the unchecked items must be passed in the item_ids array, + # so all items left in the mapping should be checked items. + if value["complete"] is False: + raise vol.Invalid( + "The item ids array doesn't contain all the unchecked shopping list" + " items." + ) + new_items.append(value) + self.items = new_items + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + context=context, + ) + + async def async_move_item(self, uid: str, previous: str | None = None) -> None: + """Re-order a shopping list item.""" + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: + raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + ) + + async def async_sort( + self, reverse: bool = False, context: Context | None = None + ) -> None: + """Sort items by name.""" + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "sorted"}, + context=context, + ) + + async def async_load(self) -> None: + """Load items.""" + + def load() -> list[dict[str, JsonValueType]]: + """Load the items synchronously.""" + return cast( + list[dict[str, JsonValueType]], + load_json_array(self.hass.config.path(PERSISTENCE)), + ) + + self.items = await self.hass.async_add_executor_job(load) + + def save(self) -> None: + """Save the items.""" + save_json(self.hass.config.path(PERSISTENCE), self.items) + + def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: + """Add a listener to notify when data is updated.""" + + def unsub() -> None: + self._listeners.remove(cb) + + self._listeners.append(cb) + return unsub + + def _async_notify(self) -> None: + """Notify all listeners that data has been updated.""" + for listener in self._listeners: + listener() + + +def _get_shopping_data(hass: HomeAssistant) -> ShoppingData: + entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: + raise HomeAssistantError("No shopping list config entry found") + return entries[0].runtime_data diff --git a/homeassistant/components/shopping_list/config_flow.py b/homeassistant/components/shopping_list/config_flow.py index ffc8a3be21a..c9d5b2ed8db 100644 --- a/homeassistant/components/shopping_list/config_flow.py +++ b/homeassistant/components/shopping_list/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the shopping list integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 06bb692621a..13d133ee5b9 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,11 +1,10 @@ """Intents for the Shopping List integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent -from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED, NoMatchingShoppingListItem +from .common import NoMatchingShoppingListItem, _get_shopping_data +from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_COMPLETE_ITEM = "HassShoppingListCompleteItem" @@ -31,7 +30,7 @@ class AddItemIntent(intent.IntentHandler): """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) item = slots["item"]["value"].strip() - await intent_obj.hass.data[DOMAIN].async_add(item) + await _get_shopping_data(intent_obj.hass).async_add(item) response = intent_obj.create_response() intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) @@ -52,7 +51,9 @@ class CompleteItemIntent(intent.IntentHandler): item = slots["item"]["value"].strip() try: - complete_items = await intent_obj.hass.data[DOMAIN].async_complete(item) + complete_items = await _get_shopping_data(intent_obj.hass).async_complete( + item + ) except NoMatchingShoppingListItem: complete_items = [] @@ -74,14 +75,16 @@ class ListTopItemsIntent(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" - items = intent_obj.hass.data[DOMAIN].items[-5:] + items = _get_shopping_data(intent_obj.hass).items[-5:] response: intent.IntentResponse = intent_obj.create_response() if not items: response.async_set_speech("There are no items on your shopping list") else: - items_list = ", ".join(itm["name"] for itm in reversed(items)) + items_list = ", ".join(str(itm["name"]) for itm in reversed(items)) response.async_set_speech( - f"These are the top {min(len(items), 5)} items on your shopping list: {items_list}" + "These are the top" + f" {min(len(items), 5)} items on your" + f" shopping list: {items_list}" ) return response diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 2952c283082..61b9c0b8048 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -8,22 +8,20 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NoMatchingShoppingListItem, ShoppingData -from .const import DOMAIN +from .common import NoMatchingShoppingListItem, ShoppingData, ShoppingListConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ShoppingListConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the shopping_list todo platform.""" - shopping_data = hass.data[DOMAIN] + shopping_data = config_entry.runtime_data entity = ShoppingTodoListEntity(shopping_data, unique_id=config_entry.entry_id) async_add_entities([entity], True) diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index d1bc3fa9968..21522862394 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -1,21 +1,18 @@ """The sia integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS -from .hub import SIAHub +from .const import PLATFORMS +from .hub import SIAConfigEntry, SIAHub -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SIAConfigEntry) -> bool: """Set up sia from a config entry.""" - hub: SIAHub = SIAHub(hass, entry) + hub = SIAHub(hass, entry) hub.async_setup_hub() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = hub try: if hub.sia_client: await hub.sia_client.async_start(reuse_port=True) @@ -23,14 +20,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"SIA Server at port {entry.data[CONF_PORT]} could not start." ) from exc + + entry.runtime_data = hub await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SIAConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) - await hub.async_shutdown() + await entry.runtime_data.async_shutdown() return unload_ok diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index a3bed652876..ffb03b93da8 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -1,7 +1,5 @@ """Module for SIA Alarm Control Panels.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index e1b40dc2e55..2efbac9335a 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -1,7 +1,5 @@ """Module for SIA Binary Sensors.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import dataclass import logging diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index a23978145e7..d15961243b5 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -1,7 +1,5 @@ """Config flow for sia integration.""" -from __future__ import annotations - from collections.abc import Mapping from copy import deepcopy import logging @@ -16,12 +14,7 @@ from pysiaalarm import ( ) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback @@ -36,7 +29,7 @@ from .const import ( DOMAIN, TITLE, ) -from .hub import SIAHub +from .hub import SIAConfigEntry, SIAHub _LOGGER = logging.getLogger(__name__) @@ -100,7 +93,7 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SIAConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" return SIAOptionsFlowHandler(config_entry) @@ -139,7 +132,7 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): async def async_handle_data_and_route( self, user_input: dict[str, Any] ) -> ConfigFlowResult: - """Handle the user_input, check if configured and route to the right next step or create entry.""" + """Handle user_input, check if configured and route to the right next step.""" self._update_data(user_input) self._async_abort_entries_match({CONF_PORT: self._data[CONF_PORT]}) @@ -155,7 +148,8 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): def _update_data(self, user_input: dict[str, Any]) -> None: """Parse the user_input and store in data and options attributes. - If there is a port in the input or no data, assume it is fully new and overwrite. + If there is a port in the input or no data, assume + it is fully new and overwrite. Add the default options and overwrite the zones in options. """ if not self._data or user_input.get(CONF_PORT): @@ -179,7 +173,9 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: SIAConfigEntry + + def __init__(self, config_entry: SIAConfigEntry) -> None: """Initialize SIA options flow.""" self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None @@ -189,7 +185,7 @@ class SIAOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the SIA options.""" - self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + self.hub = self.config_entry.runtime_data assert self.hub is not None assert self.hub.sia_accounts is not None self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] diff --git a/homeassistant/components/sia/const.py b/homeassistant/components/sia/const.py index 20a0afa9edf..38b3f60d4c0 100644 --- a/homeassistant/components/sia/const.py +++ b/homeassistant/components/sia/const.py @@ -1,7 +1,5 @@ """Constants for the sia integration.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform diff --git a/homeassistant/components/sia/entity.py b/homeassistant/components/sia/entity.py index 48af8e0beb4..e0c8ded61be 100644 --- a/homeassistant/components/sia/entity.py +++ b/homeassistant/components/sia/entity.py @@ -1,7 +1,5 @@ """Module for SIA Base Entity.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass import logging @@ -118,7 +116,7 @@ class SIABaseEntity(RestoreEntity): @callback def async_handle_event(self, sia_event: SIAEvent) -> None: - """Listen to dispatcher events for this port and account and update state and attributes. + """Listen to dispatcher events for this port and account, update state. If the event is for either the zone or the 0 zone (hub zone), then handle it further. diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 591e4aadad7..f54602a6677 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -1,14 +1,12 @@ """The sia hub.""" -from __future__ import annotations - from copy import deepcopy import logging from typing import Any from pysiaalarm.aio import CommunicationsProtocol, SIAAccount, SIAClient, SIAEvent -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -28,6 +26,8 @@ from .utils import get_event_data_from_sia_event _LOGGER = logging.getLogger(__name__) +type SIAConfigEntry = ConfigEntry[SIAHub] + DEFAULT_TIMEBAND = (80, 40) @@ -37,11 +37,11 @@ class SIAHub: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SIAConfigEntry, ) -> None: """Create the SIAHub.""" - self._hass: HomeAssistant = hass - self._entry: ConfigEntry = entry + self._hass = hass + self._entry = entry self._port: int = entry.data[CONF_PORT] self._title: str = entry.title self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) @@ -51,7 +51,7 @@ class SIAHub: @callback def async_setup_hub(self) -> None: - """Add a device to the device_registry, register shutdown listener, load reactions.""" + """Add a device to the device_registry, register shutdown listener.""" self.update_accounts() device_registry = dr.async_get(self._hass) for acc in self._accounts: @@ -74,10 +74,14 @@ class SIAHub: await self.sia_client.async_stop() async def async_create_and_fire_event(self, event: SIAEvent) -> None: - """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. - - The created event is handled by default for only a small subset for each platform (there are about 320 SIA Codes defined, only 22 of those are used in the alarm_control_panel), a user can choose to build other automation or even entities on the same event for SIA codes not handled by the built-in platforms. + """Create an event on HA dispatcher and then on HA's bus. + The created event is handled by default for only a + small subset for each platform (there are about 320 + SIA Codes defined, only 22 of those are used in the + alarm_control_panel), a user can choose to build other + automation or even entities on the same event for SIA + codes not handled by the built-in platforms. """ _LOGGER.debug( "Adding event to dispatch and bus for code %s for port %s and account %s", @@ -109,7 +113,8 @@ class SIAHub: if self.sia_client is not None: self.sia_client.accounts = self.sia_accounts return - # the new client class method creates a subclass based on protocol, hence the type ignore + # the new client class method creates a subclass + # based on protocol, hence the type ignore self.sia_client = SIAClient( host="", port=self._port, @@ -119,7 +124,7 @@ class SIAHub: ) def _load_options(self) -> None: - """Store attributes to avoid property call overhead since they are called frequently.""" + """Store attributes to avoid property call overhead.""" options = dict(self._entry.options) for acc in self._accounts: acc_id = acc[CONF_ACCOUNT] @@ -131,16 +136,17 @@ class SIAHub: @staticmethod async def async_config_entry_updated( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SIAConfigEntry ) -> None: """Handle signals of config entry being updated. - First, update the accounts, this will reflect any changes with ignore_timestamps. - Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. - + First, update the accounts, this will reflect any + changes with ignore_timestamps. Second, unload + underlying platforms, and then setup platforms, this + reflects any changes in number of zones. """ - if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): + if config_entry.state is not ConfigEntryState.LOADED: return - hub.update_accounts() + config_entry.runtime_data.update_accounts() await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py index 90b8b41c320..0254669c2b9 100644 --- a/homeassistant/components/sia/utils.py +++ b/homeassistant/components/sia/utils.py @@ -1,7 +1,5 @@ """Helper functions for the SIA integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 667d4a50602..c3472653057 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -1,7 +1,5 @@ """Sensor for SigFox devices.""" -from __future__ import annotations - import datetime from http import HTTPStatus import json diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index 9636192f6e1..8952225af93 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -1,7 +1,5 @@ """Person detection using Sighthound cloud service.""" -from __future__ import annotations - import io import logging from pathlib import Path diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 64ba7361aeb..5c01cf26697 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==12.1.1", "simplehound==0.3"] + "requirements": ["Pillow==12.2.0", "simplehound==0.3"] } diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 06de7d91583..d690d0d0d5d 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -1,7 +1,5 @@ """Signal Messenger for notify component.""" -from __future__ import annotations - import logging from typing import Any @@ -99,7 +97,7 @@ class SignalNotificationService(BaseNotificationService): self._signal_cli_rest_api = signal_cli_rest_api def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to one or more recipients. Additionally a file can be attached.""" + """Send a message to one or more recipients.""" _LOGGER.debug("Sending signal message") diff --git a/homeassistant/components/simplefin/__init__.py b/homeassistant/components/simplefin/__init__.py index 1fe2f2a6189..5d8271140d7 100644 --- a/homeassistant/components/simplefin/__init__.py +++ b/homeassistant/components/simplefin/__init__.py @@ -1,7 +1,5 @@ """The SimpleFIN integration.""" -from __future__ import annotations - from simplefin4py import SimpleFin from homeassistant.const import Platform diff --git a/homeassistant/components/simplefin/const.py b/homeassistant/components/simplefin/const.py index 9052971e6a5..6236e87c53d 100644 --- a/homeassistant/components/simplefin/const.py +++ b/homeassistant/components/simplefin/const.py @@ -1,7 +1,5 @@ """Constants for the SimpleFIN integration.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/simplefin/coordinator.py b/homeassistant/components/simplefin/coordinator.py index 08e9732c6b7..cb22490b7e0 100644 --- a/homeassistant/components/simplefin/coordinator.py +++ b/homeassistant/components/simplefin/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the SimpleFIN integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any @@ -45,6 +43,8 @@ class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]): except SimpleFinPaymentRequiredError as err: LOGGER.warning( - "There is a billing issue with your SimpleFin account, contact Simplefin to address this issue" + "There is a billing issue with your SimpleFin" + " account, contact SimpleFin to address" + " this issue" ) raise UpdateFailed from err diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py index 183a198040b..a219961d1f2 100644 --- a/homeassistant/components/simplefin/sensor.py +++ b/homeassistant/components/simplefin/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/simplefin/strings.json b/homeassistant/components/simplefin/strings.json index 24128264765..3a62c55d84d 100644 --- a/homeassistant/components/simplefin/strings.json +++ b/homeassistant/components/simplefin/strings.json @@ -14,7 +14,7 @@ "step": { "user": { "data": { - "api_token": "Setup token" + "access_url": "Setup token" }, "description": "Please enter a SimpleFIN setup token." } diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 4e954e89938..3427a047926 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -1,7 +1,5 @@ """Config flow for simplepush integration.""" -from __future__ import annotations - from typing import Any from simplepush import UnknownError, send @@ -69,6 +67,8 @@ class SimplePushFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_DEVICE_KEY): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): str, vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): str, diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index e21a62a6a12..9682d8cd122 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -1,7 +1,5 @@ """Simplepush notification service.""" -from __future__ import annotations - import logging from typing import Any @@ -98,6 +96,7 @@ class SimplePushNotificationService(BaseNotificationService): event=event, ) + # pylint: disable-next=home-assistant-action-swallowed-exception except BadRequest: _LOGGER.error("Bad request. Title or message are too long") except UnknownError: diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d9ab3e3b4f1..4b3d1d712be 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,10 +1,8 @@ """Support for SimpliSafe alarm systems.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine -from typing import Any, cast +from typing import Any from simplipy import API from simplipy.errors import ( @@ -39,7 +37,7 @@ from simplipy.websocket import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_ID, @@ -88,6 +86,8 @@ from .const import ( from .coordinator import SimpliSafeDataUpdateCoordinator from .typing import SystemType +type SimpliSafeConfigEntry = ConfigEntry[SimpliSafe] + ATTR_CATEGORY = "category" ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" @@ -223,10 +223,15 @@ def _async_get_system_for_service_call( ] system_id = int(system_id_str) + entry: SimpliSafeConfigEntry | None for entry_id in base_station_device_entry.config_entries: - if (simplisafe := hass.data[DOMAIN].get(entry_id)) is None: + if ( + (entry := hass.config_entries.async_get_entry(entry_id)) is None + or entry.domain != DOMAIN + or entry.state is not ConfigEntryState.LOADED + ): continue - return cast(SystemType, simplisafe.systems[system_id]) + return entry.runtime_data.systems[system_id] raise ValueError(f"No system for device ID: {device_id}") @@ -286,7 +291,7 @@ def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> hass.config_entries.async_update_entry(entry, **entry_updates) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Set up SimpliSafe as config entry.""" _async_standardize_config_entry(hass, entry) @@ -310,8 +315,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SimplipyError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = simplisafe + entry.runtime_data = simplisafe await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -370,6 +374,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ): if hass.services.has_service(DOMAIN, service): continue + # pylint: disable-next=home-assistant-service-registered-in-setup-entry async_register_admin_service(hass, DOMAIN, service, method, schema=schema) current_options = {**entry.options} @@ -396,11 +401,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Unload a SimpliSafe config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of SimpliSafe, deregister any services diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index c5a1b2bc708..310b82214c4 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for SimpliSafe alarm control panels.""" -from __future__ import annotations - from simplipy.errors import SimplipyError from simplipy.system import SystemStates from simplipy.system.v3 import SystemV3 @@ -28,12 +26,11 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe +from . import SimpliSafe, SimpliSafeConfigEntry from .const import ( ATTR_ALARM_DURATION, ATTR_ALARM_VOLUME, @@ -44,7 +41,6 @@ from .const import ( ATTR_EXIT_DELAY_HOME, ATTR_LIGHT, ATTR_VOICE_PROMPT_VOLUME, - DOMAIN, LOGGER, ) from .entity import SimpliSafeEntity @@ -104,11 +100,11 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data async_add_entities( [SimpliSafeAlarm(simplisafe, system) for system in simplisafe.systems.values()], True, diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 4cd02431148..9e69c014b2d 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -1,6 +1,6 @@ """Support for SimpliSafe binary sensors.""" -from __future__ import annotations +from typing import TYPE_CHECKING, cast from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 @@ -11,13 +11,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity SUPPORTED_BATTERY_SENSOR_TYPES = [ @@ -59,11 +58,11 @@ TRIGGERED_SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data sensors: list[BatteryBinarySensor | TriggeredBinarySensor] = [] @@ -72,18 +71,22 @@ async def async_setup_entry( LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) for sensor in system.sensors.values(): if sensor.type in TRIGGERED_SENSOR_TYPES: sensors.append( TriggeredBinarySensor( simplisafe, system, - sensor, + cast(SensorV3, sensor), TRIGGERED_SENSOR_TYPES[sensor.type], ) ) if sensor.type in SUPPORTED_BATTERY_SENSOR_TYPES: - sensors.append(BatteryBinarySensor(simplisafe, system, sensor)) + sensors.append( + BatteryBinarySensor(simplisafe, system, cast(DeviceV3, sensor)) + ) sensors.extend( BatteryBinarySensor(simplisafe, system, lock) diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index 129209354c3..ada6fd17d0c 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -1,7 +1,5 @@ """Buttons for the SimpliSafe integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -9,14 +7,12 @@ from simplipy.errors import SimplipyError from simplipy.system import System from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN +from . import SimpliSafe, SimpliSafeConfigEntry from .entity import SimpliSafeEntity from .typing import SystemType @@ -47,11 +43,11 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe buttons based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 6494b84981b..ef35b67d3a3 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the SimpliSafe component.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, NamedTuple @@ -14,16 +12,12 @@ from simplipy.util.auth import ( ) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_CODE, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from . import SimpliSafeConfigEntry from .const import DOMAIN, LOGGER CONF_AUTH_CODE = "auth_code" @@ -68,7 +62,7 @@ class SimpliSafeFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SimpliSafeConfigEntry, ) -> SimpliSafeOptionsFlowHandler: """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler() diff --git a/homeassistant/components/simplisafe/coordinator.py b/homeassistant/components/simplisafe/coordinator.py index bde2a939882..48a79cd6fb0 100644 --- a/homeassistant/components/simplisafe/coordinator.py +++ b/homeassistant/components/simplisafe/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for SimpliSafe.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/simplisafe/diagnostics.py b/homeassistant/components/simplisafe/diagnostics.py index e63e1551740..d6ccb77f5e3 100644 --- a/homeassistant/components/simplisafe/diagnostics.py +++ b/homeassistant/components/simplisafe/diagnostics.py @@ -1,11 +1,8 @@ """Diagnostics support for SimpliSafe.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, CONF_CODE, @@ -16,8 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import SimpliSafe -from .const import DOMAIN +from . import SimpliSafeConfigEntry CONF_CREDIT_CARD = "creditCard" CONF_EXPIRES = "expires" @@ -53,10 +49,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SimpliSafeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - simplisafe: SimpliSafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data return async_redact_data( { diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py index eff3f8d3998..0107bb16484 100644 --- a/homeassistant/components/simplisafe/entity.py +++ b/homeassistant/components/simplisafe/entity.py @@ -1,7 +1,5 @@ """Support for SimpliSafe alarm systems.""" -from __future__ import annotations - from collections.abc import Iterable from simplipy.device import Device, DeviceTypes diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index a0626898a21..c39f588bdae 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,8 +1,6 @@ """Support for SimpliSafe locks.""" -from __future__ import annotations - -from typing import Any +from typing import TYPE_CHECKING, Any from simplipy.device.lock import Lock, LockStates from simplipy.errors import SimplipyError @@ -10,13 +8,12 @@ from simplipy.system.v3 import SystemV3 from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED, WebsocketEvent from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity ATTR_LOCK_LOW_BATTERY = "lock_low_battery" @@ -32,11 +29,11 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe locks based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data locks: list[SimpliSafeLock] = [] for system in simplisafe.systems.values(): @@ -44,6 +41,8 @@ async def async_setup_entry( LOGGER.warning("Skipping lock setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) locks.extend( SimpliSafeLock(simplisafe, system, lock) for lock in system.locks.values() ) @@ -93,8 +92,8 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): @callback def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._attr_is_jammed = self._device.state == LockStates.JAMMED - self._attr_is_locked = self._device.state == LockStates.LOCKED + self._attr_is_jammed = self._device.state is LockStates.JAMMED + self._attr_is_locked = self._device.state is LockStates.LOCKED self._attr_extra_state_attributes.update( { diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index b82162f0fe7..18686656d28 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -1,6 +1,6 @@ """Support for SimpliSafe freeze sensor.""" -from __future__ import annotations +from typing import TYPE_CHECKING, cast from simplipy.device import DeviceTypes from simplipy.device.sensor.v3 import SensorV3 @@ -11,23 +11,22 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data sensors: list[SimplisafeFreezeSensor] = [] for system in simplisafe.systems.values(): @@ -35,10 +34,12 @@ async def async_setup_entry( LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) sensors.extend( - SimplisafeFreezeSensor(simplisafe, system, sensor) + SimplisafeFreezeSensor(simplisafe, system, cast(SensorV3, sensor)) for sensor in system.sensors.values() - if sensor.type == DeviceTypes.TEMPERATURE + if sensor.type is DeviceTypes.TEMPERATURE ) async_add_entities(sensors) diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 47f8d6b5a87..123961cdddc 100644 --- a/homeassistant/components/sinch/notify.py +++ b/homeassistant/components/sinch/notify.py @@ -1,7 +1,5 @@ """Support for Sinch notifications.""" -from __future__ import annotations - import logging from typing import Any @@ -91,6 +89,7 @@ class SinchNotificationService(BaseNotificationService): _LOGGER.debug( 'Successfully sent SMS to "%s" (batch_id: %s)', target, batch_id ) + # pylint: disable-next=home-assistant-action-swallowed-exception except ErrorResponseException as ex: _LOGGER.error( "Caught ErrorResponseException. Response code: %s (%s)", diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 65d7848c618..4d5f021de01 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -1,7 +1,5 @@ """Component to interface with various sirens/chimes.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, TypedDict, cast, final diff --git a/homeassistant/components/siren/conditions.yaml b/homeassistant/components/siren/conditions.yaml index 41145760d92..edbf8c6ff34 100644 --- a/homeassistant/components/siren/conditions.yaml +++ b/homeassistant/components/siren/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_off: *condition_common is_on: *condition_common diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index e20c3421736..e28698e5d41 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is on" @@ -37,21 +45,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "toggle": { "description": "Toggles a siren on/off.", @@ -87,6 +80,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::trigger_for_name%]" } }, "name": "Siren turned off" @@ -96,6 +92,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::trigger_for_name%]" } }, "name": "Siren turned on" diff --git a/homeassistant/components/siren/triggers.yaml b/homeassistant/components/siren/triggers.yaml index 798b9dcd897..d61b6202e4d 100644 --- a/homeassistant/components/siren/triggers.yaml +++ b/homeassistant/components/siren/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: turned_off: *trigger_common turned_on: *trigger_common diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py index c89d8d11d54..7f1c0bc3fc2 100644 --- a/homeassistant/components/sisyphus/light.py +++ b/homeassistant/components/sisyphus/light.py @@ -1,7 +1,5 @@ """Support for the light on the Sisyphus Kinetic Art Table.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sisyphus/media_player.py b/homeassistant/components/sisyphus/media_player.py index 3884a83928a..065ad0fd834 100644 --- a/homeassistant/components/sisyphus/media_player.py +++ b/homeassistant/components/sisyphus/media_player.py @@ -1,7 +1,5 @@ """Support for track controls on the Sisyphus Kinetic Art Table.""" -from __future__ import annotations - import aiohttp from sisyphus_control import Track diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 7507175b321..82dac6c0f6d 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -1,7 +1,5 @@ """Support for Sky Hub.""" -from __future__ import annotations - import logging from pyskyqhub.skyq_hub import SkyQHub diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py index 1ecd6c3716e..ffd4bf848cc 100644 --- a/homeassistant/components/sky_remote/remote.py +++ b/homeassistant/components/sky_remote/remote.py @@ -64,6 +64,7 @@ class SkyRemote(RemoteEntity): ) try: self._remote.send_keys(command) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError as err: _LOGGER.error("Invalid command: %s. Error: %s", command, err) return diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 108539c1cef..514c3142997 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -1,7 +1,5 @@ """Support for Skybeacon temperature/humidity Bluetooth LE sensors.""" -from __future__ import annotations - import logging import threading from uuid import UUID @@ -17,6 +15,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( + ATTR_MODEL, CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP, @@ -32,7 +31,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" -ATTR_MODEL = "model" BLE_TEMP_HANDLE = 0x24 BLE_TEMP_UUID = "0000ff92-0000-1000-8000-00805f9b34fb" diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 5baa4ad83ad..6f3d977b74e 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -1,20 +1,16 @@ """Support for the Skybell HD Doorbell.""" -from __future__ import annotations - import asyncio from aioskybell import Skybell from aioskybell.exceptions import SkybellAuthenticationException, SkybellException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -25,7 +21,7 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SkybellConfigEntry) -> bool: """Set up Skybell from a config entry.""" email = entry.data[CONF_EMAIL] password = entry.data[CONF_PASSWORD] @@ -53,14 +49,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in device_coordinators ] ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators + entry.runtime_data = device_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SkybellConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index cc42da48b26..62e8cd655a2 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor support for the Skybell HD Doorbell.""" -from __future__ import annotations - from aioskybell.helpers import const as CONST from homeassistant.components.binary_sensor import ( @@ -9,12 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator from .entity import SkybellEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -32,14 +28,14 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell binary sensor.""" async_add_entities( SkybellBinarySensor(coordinator, sensor) for sensor in BINARY_SENSOR_TYPES - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data ) diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 4ee873f8350..737e259cab5 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,20 +1,16 @@ """Camera support for the Skybell HD Doorbell.""" -from __future__ import annotations - from aiohttp import web from haffmpeg.camera import CameraMjpeg from homeassistant.components.camera import Camera, CameraEntityDescription from homeassistant.components.ffmpeg import get_ffmpeg_manager -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator from .entity import SkybellEntity CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( @@ -31,13 +27,13 @@ CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell camera.""" entities = [] for description in CAMERA_TYPES: - for coordinator in hass.data[DOMAIN][entry.entry_id]: + for coordinator in entry.runtime_data: if description.key == "avatar": entities.append(SkybellCamera(coordinator, description)) else: diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py index 9893d0dd93a..8c5eafd4b21 100644 --- a/homeassistant/components/skybell/config_flow.py +++ b/homeassistant/components/skybell/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Skybell integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py index 48e67c63ac9..499363191f8 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -10,14 +10,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER +type SkybellConfigEntry = ConfigEntry[list[SkybellDataUpdateCoordinator]] + class SkybellDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for the Skybell integration.""" - config_entry: ConfigEntry + config_entry: SkybellConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: SkybellDevice + self, + hass: HomeAssistant, + config_entry: SkybellConfigEntry, + device: SkybellDevice, ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py index f3b0c077212..af82aa84fcd 100644 --- a/homeassistant/components/skybell/entity.py +++ b/homeassistant/components/skybell/entity.py @@ -1,7 +1,5 @@ """Entity representing a Skybell HD Doorbell.""" -from __future__ import annotations - from aioskybell import SkybellDevice from homeassistant.const import ATTR_CONNECTIONS diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 3f924f68da8..53704c9c09b 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -1,7 +1,5 @@ """Light/LED support for the Skybell HD Doorbell.""" -from __future__ import annotations - from typing import Any from aioskybell.helpers.const import BRIGHTNESS, RGB_COLOR @@ -13,23 +11,22 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import SkybellConfigEntry from .entity import SkybellEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell switch.""" async_add_entities( SkybellLight(coordinator, LightEntityDescription(key="light")) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data ) diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index a67fdae3b35..f398f9c4bb9 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,7 +1,5 @@ """Sensor support for Skybell Doorbells.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -14,13 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .entity import DOMAIN, SkybellEntity +from .coordinator import SkybellConfigEntry +from .entity import SkybellEntity @dataclass(frozen=True, kw_only=True) @@ -89,13 +87,13 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell sensor.""" async_add_entities( SkybellSensor(coordinator, description) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data for description in SENSOR_TYPES if coordinator.device.owner or description.key not in CONST.ATTR_OWNER_STATS ) diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 858363043ca..711a998ad82 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,15 +1,12 @@ """Switch support for the Skybell HD Doorbell.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import SkybellConfigEntry from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -30,13 +27,13 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SkyBell switch.""" async_add_entities( SkybellSwitch(coordinator, description) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data for description in SWITCH_TYPES ) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 899b46ee7e8..1572ae29644 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -1,7 +1,6 @@ """The slack integration.""" -from __future__ import annotations - +from dataclasses import dataclass import logging from aiohttp.client_exceptions import ClientError @@ -30,6 +29,17 @@ PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type SlackConfigEntry = ConfigEntry[SlackData] + + +@dataclass +class SlackData: + """Runtime data for the Slack integration.""" + + client: AsyncWebClient + url: str + user_id: str + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Slack component.""" @@ -37,7 +47,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SlackConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) slack = AsyncWebClient( @@ -52,19 +62,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False raise ConfigEntryNotReady("Error while setting up integration") from ex - data = { - DATA_CLIENT: slack, - ATTR_URL: res[ATTR_URL], - ATTR_USER_ID: res[ATTR_USER_ID], - } - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data | {SLACK_DATA: data} + entry.runtime_data = SlackData( + client=slack, + url=res[ATTR_URL], + user_id=res[ATTR_USER_ID], + ) hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, - hass.data[DOMAIN][entry.entry_id], + entry.data + | { + SLACK_DATA: { + DATA_CLIENT: slack, + ATTR_URL: res[ATTR_URL], + ATTR_USER_ID: res[ATTR_USER_ID], + } + }, hass.data[DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 551e9832b2b..71925ddb3df 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Slack integration.""" -from __future__ import annotations - import logging from slack_sdk.errors import SlackApiError diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 30218360054..040cb58aa0c 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -1,14 +1,10 @@ """The slack integration.""" -from __future__ import annotations - -from slack_sdk.web.async_client import AsyncWebClient - -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN +from . import SlackConfigEntry, SlackData +from .const import DEFAULT_NAME, DOMAIN class SlackEntity(Entity): @@ -16,16 +12,16 @@ class SlackEntity(Entity): def __init__( self, - data: dict[str, AsyncWebClient], + data: SlackData, description: EntityDescription, - entry: ConfigEntry, + entry: SlackConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client: AsyncWebClient = data[DATA_CLIENT] + self._client = data.client self.entity_description = description - self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" + self._attr_unique_id = f"{data.user_id}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=str(data[ATTR_URL]), + configuration_url=data.url, entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 4c7f52e581f..76c19e81752 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -1,7 +1,5 @@ """Slack platform for notify component.""" -from __future__ import annotations - import asyncio import logging import os @@ -279,6 +277,7 @@ class SlackNotificationService(BaseNotificationService): try: DATA_SCHEMA(data) + # pylint: disable-next=home-assistant-action-swallowed-exception except vol.Invalid as err: _LOGGER.error("Invalid message data: %s", err) data = {} @@ -351,8 +350,11 @@ class SlackNotificationService(BaseNotificationService): channel_name = channel_name.lstrip("#") # Get channel list - # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test - # response = await self._client.conversations_list(types="public_channel,private_channel") + # Multiple types is not working. Tested here: + # https://api.slack.com/methods/conversations.list/test + # response = await self._client.conversations_list( + # types="public_channel,private_channel" + # ) # # Workaround for the types parameter not working channels = [] diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 042ab00916e..965fd2bbd66 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -1,7 +1,5 @@ """Slack platform for sensor component.""" -from __future__ import annotations - from slack_sdk.web.async_client import AsyncWebClient from homeassistant.components.sensor import ( @@ -9,25 +7,25 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA +from . import SlackConfigEntry +from .const import ATTR_SNOOZE from .entity import SlackEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SlackConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Slack select.""" + """Set up the Slack sensor.""" async_add_entities( [ SlackSensorEntity( - hass.data[DOMAIN][entry.entry_id][SLACK_DATA], + entry.runtime_data, SensorEntityDescription( key="do_not_disturb_until", translation_key="do_not_disturb_until", diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py index 7619d7d265f..453508c6586 100644 --- a/homeassistant/components/slack/utils.py +++ b/homeassistant/components/slack/utils.py @@ -24,12 +24,14 @@ async def upload_file_to_slack( Args: client (AsyncWebClient): The Slack WebClient instance. channel_ids (list[str | None]): List of channel IDs to upload the file to. - file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used. + file_content (Union[bytes, str, None]): Content of the + file (local or remote). If None, file_path is used. filename (str): The file's name. title (str | None): Title of the file in Slack. message (str): Initial comment to accompany the file. thread_ts (str | None): Thread timestamp for threading messages. - file_path (str | None): Path to the local file to be read if file_content is None. + file_path (str | None): Path to the local file to be + read if file_content is None. Raises: SlackApiError: If the Slack API call fails. diff --git a/homeassistant/components/sleep_as_android/__init__.py b/homeassistant/components/sleep_as_android/__init__.py index 8dd08ba0388..7e57b3f5899 100644 --- a/homeassistant/components/sleep_as_android/__init__.py +++ b/homeassistant/components/sleep_as_android/__init__.py @@ -1,7 +1,5 @@ """The Sleep as Android integration.""" -from __future__ import annotations - from http import HTTPStatus from aiohttp.web import Request, Response diff --git a/homeassistant/components/sleep_as_android/config_flow.py b/homeassistant/components/sleep_as_android/config_flow.py index 595612cc601..fa3f3a4cf55 100644 --- a/homeassistant/components/sleep_as_android/config_flow.py +++ b/homeassistant/components/sleep_as_android/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Sleep as Android integration.""" -from __future__ import annotations - from homeassistant.helpers import config_entry_flow from .const import DOMAIN diff --git a/homeassistant/components/sleep_as_android/diagnostics.py b/homeassistant/components/sleep_as_android/diagnostics.py index 2f49e818ece..5995daf2768 100644 --- a/homeassistant/components/sleep_as_android/diagnostics.py +++ b/homeassistant/components/sleep_as_android/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Sleep as Android integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/sleep_as_android/entity.py b/homeassistant/components/sleep_as_android/entity.py index 5984bb45efd..53b16bac6ca 100644 --- a/homeassistant/components/sleep_as_android/entity.py +++ b/homeassistant/components/sleep_as_android/entity.py @@ -1,7 +1,5 @@ """Base entity for Sleep as Android integration.""" -from __future__ import annotations - from abc import abstractmethod from homeassistant.const import CONF_WEBHOOK_ID diff --git a/homeassistant/components/sleep_as_android/event.py b/homeassistant/components/sleep_as_android/event.py index 4c50b915e01..5b1ec5b68db 100644 --- a/homeassistant/components/sleep_as_android/event.py +++ b/homeassistant/components/sleep_as_android/event.py @@ -1,7 +1,5 @@ """Event platform for Sleep as Android integration.""" -from __future__ import annotations - from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/sleep_as_android/sensor.py b/homeassistant/components/sleep_as_android/sensor.py index cd7662104a6..f0bdec3e51a 100644 --- a/homeassistant/components/sleep_as_android/sensor.py +++ b/homeassistant/components/sleep_as_android/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Sleep as Android integration.""" -from __future__ import annotations - from datetime import datetime from enum import StrEnum diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 8eb703b7f5f..742980259bf 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -1,7 +1,5 @@ """Support for SleepIQ from SleepNumber.""" -from __future__ import annotations - import logging from typing import Any @@ -18,11 +16,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER from .coordinator import ( + SleepIQConfigEntry, SleepIQData, SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator, @@ -64,13 +63,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> bool: """Set up the SleepIQ config entry.""" conf = entry.data email = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] - client_session = async_get_clientsession(hass) + client_session = async_create_clientsession(hass) gateway = AsyncSleepIQ(client_session=client_session) @@ -104,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await pause_coordinator.async_config_entry_first_refresh() await sleep_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SleepIQData( + entry.runtime_data = SleepIQData( data_coordinator=coordinator, pause_coordinator=pause_coordinator, sleep_data_coordinator=sleep_data_coordinator, @@ -116,11 +115,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> bool: """Unload the config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_migrate_unique_ids( diff --git a/homeassistant/components/sleepiq/binary_sensor.py b/homeassistant/components/sleepiq/binary_sensor.py index 99fff9c49b0..501e2a824dc 100644 --- a/homeassistant/components/sleepiq/binary_sensor.py +++ b/homeassistant/components/sleepiq/binary_sensor.py @@ -6,22 +6,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .const import ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQSleeperEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed binary sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( IsInBedBinarySensor(data.data_coordinator, bed, sleeper) for bed in data.client.beds.values() diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index 74b1bc0789f..150ee6c4c1c 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -1,7 +1,5 @@ """Support for SleepIQ buttons.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -9,12 +7,10 @@ from typing import Any from asyncsleepiq import SleepIQBed from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData +from .coordinator import SleepIQConfigEntry from .entity import SleepIQEntity @@ -43,11 +39,11 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number buttons.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepNumberButton(bed, ed) diff --git a/homeassistant/components/sleepiq/config_flow.py b/homeassistant/components/sleepiq/config_flow.py index 0a473404eb9..ac9bb83f9f4 100644 --- a/homeassistant/components/sleepiq/config_flow.py +++ b/homeassistant/components/sleepiq/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure SleepIQ component.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 0efb8e94ebe..5328144288f 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -1,5 +1,7 @@ """Define constants for the SleepIQ component.""" +from homeassistant.const import PRESSURE + DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" @@ -11,7 +13,6 @@ FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" ICON_OCCUPIED = "mdi:bed" IS_IN_BED = "is_in_bed" -PRESSURE = "pressure" SLEEP_NUMBER = "sleep_number" FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 0baeca03fe5..4e923eef795 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -18,16 +18,18 @@ UPDATE_INTERVAL = timedelta(seconds=60) LONGER_UPDATE_INTERVAL = timedelta(minutes=5) SLEEP_DATA_UPDATE_INTERVAL = timedelta(hours=1) # Sleep data doesn't change frequently +type SleepIQConfigEntry = ConfigEntry[SleepIQData] + class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" @@ -45,18 +47,23 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): bed.foundation.update_foundation_status() for bed in self.client.beds.values() ] - await asyncio.gather(*tasks) + try: + await asyncio.gather(*tasks) + except SleepIQTimeoutException as err: + raise UpdateFailed(f"Timed out fetching SleepIQ data: {err}") from err + except SleepIQAPIException as err: + raise UpdateFailed(f"Failed to fetch SleepIQ data: {err}") from err class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): - """SleepIQ data update coordinator.""" + """SleepIQ pause update coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" @@ -70,20 +77,25 @@ class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): self.client = client async def _async_update_data(self) -> None: - await asyncio.gather( - *[bed.fetch_pause_mode() for bed in self.client.beds.values()] - ) + try: + await asyncio.gather( + *[bed.fetch_pause_mode() for bed in self.client.beds.values()] + ) + except SleepIQTimeoutException as err: + raise UpdateFailed(f"Timed out fetching SleepIQ pause data: {err}") from err + except SleepIQAPIException as err: + raise UpdateFailed(f"Failed to fetch SleepIQ pause data: {err}") from err class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]): """SleepIQ sleep health data coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/sleepiq/light.py b/homeassistant/components/sleepiq/light.py index 542c212df27..9b273df1ea4 100644 --- a/homeassistant/components/sleepiq/light.py +++ b/homeassistant/components/sleepiq/light.py @@ -6,12 +6,10 @@ from typing import Any from asyncsleepiq import SleepIQBed, SleepIQLight from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity _LOGGER = logging.getLogger(__name__) @@ -19,11 +17,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed lights.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepIQLightEntity(data.data_coordinator, bed, light) for bed in data.client.beds.values() diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 1a99f47c38c..d3aef952f21 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -1,7 +1,5 @@ """Support for SleepIQ SleepNumber firmness number entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast @@ -21,7 +19,6 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -29,13 +26,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, CORE_CLIMATE_TIMER, - DOMAIN, ENTITY_TYPES, FIRMNESS, FOOT_WARMING_TIMER, ICON_OCCUPIED, ) -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, sleeper_for_side @@ -63,7 +59,9 @@ def _get_actuator_name(bed: SleepIQBed, actuator: SleepIQActuator) -> str: if actuator.side: return ( "SleepNumber" - f" {bed.name} {actuator.side_full} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" + f" {bed.name} {actuator.side_full}" + f" {actuator.actuator_full}" + f" {ENTITY_TYPES[ACTUATOR]}" ) return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" @@ -88,7 +86,7 @@ async def _async_set_foot_warmer_time( foot_warmer: SleepIQFootWarmer, time: int ) -> None: temperature = FootWarmingTemps(foot_warmer.temperature) - if temperature != FootWarmingTemps.OFF: + if temperature is not FootWarmingTemps.OFF: await foot_warmer.turn_on(temperature, time) foot_warmer.timer = time @@ -107,7 +105,7 @@ async def _async_set_core_climate_time( core_climate: SleepIQCoreClimate, time: int ) -> None: temperature = CoreTemps(core_climate.temperature) - if temperature != CoreTemps.OFF: + if temperature is not CoreTemps.OFF: await core_climate.turn_on(temperature, time) core_climate.timer = time @@ -160,6 +158,8 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { set_value_fn=_async_set_foot_warmer_time, get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, ), CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( key=CORE_CLIMATE_TIMER, @@ -172,7 +172,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { set_value_fn=_async_set_core_climate_time, get_name_fn=_get_core_climate_name, get_unique_id_fn=_get_core_climate_unique_id, - native_unit_of_measurement=UnitOfTime.SECONDS, + native_unit_of_measurement=UnitOfTime.MINUTES, device_class=NumberDeviceClass.DURATION, ), } @@ -180,11 +180,11 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SleepIQNumberEntity] = [] for bed in data.client.beds.values(): diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index d4bc9fda3a4..78dac974b06 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,7 +1,5 @@ """Support for SleepIQ foundation preset selection.""" -from __future__ import annotations - from asyncsleepiq import ( CoreTemps, FootWarmingTemps, @@ -13,22 +11,21 @@ from asyncsleepiq import ( ) from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .const import CORE_CLIMATE, FOOT_WARMER +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ foundation preset select entities.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SleepIQBedEntity] = [] for bed in data.client.beds.values(): entities.extend( @@ -60,7 +57,7 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select self._attr_name = f"SleepNumber {bed.name} Foundation Preset" self._attr_unique_id = f"{bed.id}_preset" - if preset.side != Side.NONE: + if preset.side is not Side.NONE: self._attr_name += f" {preset.side_full}" self._attr_unique_id += f"_{preset.side.value}" self._attr_options = preset.options @@ -167,7 +164,7 @@ class SleepIQCoreTempSelectEntity( temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] timer = self.core_climate.timer or 240 - if temperature == CoreTemps.OFF: + if temperature is CoreTemps.OFF: await self.core_climate.turn_off() else: await self.core_climate.turn_on(temperature, timer) diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 5d22897d97b..950803189be 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -1,7 +1,5 @@ """Support for SleepIQ sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,23 +11,20 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import PRESSURE, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, HEART_RATE, HRV, - PRESSURE, RESPIRATORY_RATE, SLEEP_DURATION, SLEEP_NUMBER, SLEEP_SCORE, ) from .coordinator import ( - SleepIQData, + SleepIQConfigEntry, SleepIQDataUpdateCoordinator, SleepIQSleepDataCoordinator, ) @@ -112,11 +107,11 @@ SLEEP_HEALTH_SENSORS: tuple[SleepIQSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SensorEntity] = [] diff --git a/homeassistant/components/sleepiq/switch.py b/homeassistant/components/sleepiq/switch.py index 8363782c064..a476b9581fe 100644 --- a/homeassistant/components/sleepiq/switch.py +++ b/homeassistant/components/sleepiq/switch.py @@ -1,28 +1,24 @@ """Support for SleepIQ switches.""" -from __future__ import annotations - from typing import Any from asyncsleepiq import SleepIQBed from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData, SleepIQPauseUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQPauseUpdateCoordinator from .entity import SleepIQBedEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number switches.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepNumberPrivateSwitch(data.pause_coordinator, bed) for bed in data.client.beds.values() diff --git a/homeassistant/components/slide/cover.py b/homeassistant/components/slide/cover.py index d4927775a97..88bfc278e86 100644 --- a/homeassistant/components/slide/cover.py +++ b/homeassistant/components/slide/cover.py @@ -1,7 +1,5 @@ """Support for Slide slides.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 7d2027a985a..18d80b5a53a 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -1,7 +1,5 @@ """Component for the Slide local API.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index 3d5de33303d..b7c75078473 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -1,7 +1,5 @@ """Support for Slide button.""" -from __future__ import annotations - from goslideapi.goslideapi import ( AuthenticationFailed, ClientConnectionError, diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index e49a750934e..da54a534fb4 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -1,7 +1,5 @@ """Config flow for slide_local integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/slide_local/const.py b/homeassistant/components/slide_local/const.py index 9dc6d4ac925..b98c4b1a4f6 100644 --- a/homeassistant/components/slide_local/const.py +++ b/homeassistant/components/slide_local/const.py @@ -3,7 +3,6 @@ API_LOCAL = "api_local" ATTR_TOUCHGO = "touchgo" CONF_INVERT_POSITION = "invert_position" -CONF_VERIFY_SSL = "verify_ssl" DOMAIN = "slide_local" SLIDES = "slides" SLIDES_LOCAL = "slides_local" diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py index e4c8179d494..0a8a27be4d4 100644 --- a/homeassistant/components/slide_local/coordinator.py +++ b/homeassistant/components/slide_local/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for slide_local integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -102,7 +100,8 @@ class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self.config_entry.options.get(CONF_INVERT_POSITION, False): # For slide 0->open, 1->closed; for HA 0->closed, 1->open - # Value has therefore to be inverted, unless CONF_INVERT_POSITION is true + # Value has therefore to be inverted, + # unless CONF_INVERT_POSITION is true data["pos"] = 1 - data["pos"] if oldpos is None or oldpos == data["pos"]: diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py index 29ff7d2ddb4..afc8805de21 100644 --- a/homeassistant/components/slide_local/cover.py +++ b/homeassistant/components/slide_local/cover.py @@ -1,7 +1,5 @@ """Support for Slide covers.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py index 6a70720a14a..2464dbe51f1 100644 --- a/homeassistant/components/slide_local/diagnostics.py +++ b/homeassistant/components/slide_local/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for slide_local.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/slide_local/entity.py b/homeassistant/components/slide_local/entity.py index 51269649add..5b57fc03341 100644 --- a/homeassistant/components/slide_local/entity.py +++ b/homeassistant/components/slide_local/entity.py @@ -20,8 +20,8 @@ class SlideEntity(CoordinatorEntity[SlideCoordinator]): manufacturer="Innovation in Motion", connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])}, name=coordinator.data["device_name"], - sw_version=coordinator.api_version, - hw_version=coordinator.data["board_rev"], + sw_version=str(coordinator.api_version), + hw_version=str(coordinator.data["board_rev"]), serial_number=coordinator.data["mac"], configuration_url=f"http://{coordinator.host}", ) diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index e83924c87ee..2a41d3ed366 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -1,7 +1,5 @@ """Support for Slide switch.""" -from __future__ import annotations - from typing import Any from goslideapi.goslideapi import ( diff --git a/homeassistant/components/slimproto/__init__.py b/homeassistant/components/slimproto/__init__.py index a5ab10ac32b..66813772e5a 100644 --- a/homeassistant/components/slimproto/__init__.py +++ b/homeassistant/components/slimproto/__init__.py @@ -1,25 +1,23 @@ """SlimProto Player integration.""" -from __future__ import annotations - -from aioslimproto import SlimServer +from aioslimproto.server import SlimServer from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN - PLATFORMS = [Platform.MEDIA_PLAYER] +type SlimProtoConfigEntry = ConfigEntry[SlimServer] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: SlimProtoConfigEntry) -> bool: """Set up from a config entry.""" slimserver = SlimServer() await slimserver.start() - hass.data[DOMAIN] = slimserver + entry.runtime_data = slimserver # initialize platform(s) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -37,15 +35,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: SlimProtoConfigEntry, + device_entry: dr.DeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SlimProtoConfigEntry) -> bool: """Unload a config entry.""" unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_success: - await hass.data.pop(DOMAIN).stop() + await entry.runtime_data.stop() return unload_success diff --git a/homeassistant/components/slimproto/config_flow.py b/homeassistant/components/slimproto/config_flow.py index 24457493f9b..bf0da342967 100644 --- a/homeassistant/components/slimproto/config_flow.py +++ b/homeassistant/components/slimproto/config_flow.py @@ -1,7 +1,5 @@ """Config flow for SlimProto Player integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 417444961fe..2b4cf74e0eb 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -1,7 +1,5 @@ """MediaPlayer platform for SlimProto Player integration.""" -from __future__ import annotations - import asyncio from typing import Any @@ -19,12 +17,12 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow +from . import SlimProtoConfigEntry from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT STATE_MAPPING = { @@ -38,11 +36,11 @@ STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SlimProtoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SlimProto MediaPlayer(s) from Config Entry.""" - slimserver: SlimServer = hass.data[DOMAIN] + slimserver = config_entry.runtime_data added_ids = set() async def async_add_player(player: SlimClient) -> None: diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index f97b2ee25b5..02b4a3cbfad 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -1,7 +1,5 @@ """The SMA integration.""" -from __future__ import annotations - import logging from pysma import SMAWebConnect diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index b5d23d9e944..8ddf07cdb45 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the sma integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -42,14 +40,17 @@ async def validate_input( data: dict[str, Any] | None = None, ) -> dict[str, Any]: """Validate the user input allows us to connect.""" - session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]) - protocol = "https" if user_input[CONF_SSL] else "http" host = data[CONF_HOST] if data is not None else user_input[CONF_HOST] - url = URL.build(scheme=protocol, host=host) + url = str(URL.build(scheme=protocol, host=host)) sma = SMAWebConnect( - session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP] + session=async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]), + url=url, + **{ + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_GROUP: user_input[CONF_GROUP], + }, ) # new_session raises SmaAuthenticationException on failure @@ -257,7 +258,8 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): entry, data_updates={CONF_MAC: self._data[CONF_MAC]} ) - # Finally, check if the hostname (which represents the SMA serial number) is unique + # Finally, check if the hostname + # (which represents the SMA serial number) is unique serial_number = discovery_info.hostname.lower() # Example hostname: sma12345678-01 # Remove 'sma' prefix and strip everything after the dash (including the dash) diff --git a/homeassistant/components/sma/coordinator.py b/homeassistant/components/sma/coordinator.py index 5fd00ad9f50..5360905e99a 100644 --- a/homeassistant/components/sma/coordinator.py +++ b/homeassistant/components/sma/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the SMA integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -74,13 +72,11 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except SmaAuthenticationException as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err async def _async_update_data(self) -> SMACoordinatorData: @@ -94,13 +90,11 @@ class SMADataUpdateCoordinator(DataUpdateCoordinator[SMACoordinatorData]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except SmaAuthenticationException as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err return SMACoordinatorData( diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py index 9c17cb0d2a9..e154461c76f 100644 --- a/homeassistant/components/sma/diagnostics.py +++ b/homeassistant/components/sma/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for SMA.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 3f90014eb90..180f318cf0a 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -1,7 +1,5 @@ """SMA Solar Webconnect interface.""" -from __future__ import annotations - from pysma.sensor import Sensor from homeassistant.components.sensor import ( diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 8a662a889aa..a7687f9a0be 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -67,6 +67,14 @@ } } }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to SMA device" + }, + "invalid_auth": { + "message": "Invalid authentication for SMA device" + } + }, "selector": { "group": { "options": { diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 372441ec586..4289b9bd2f9 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -1,4 +1,5 @@ """The Smappee integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from pysmappee import Smappee, helper, mqtt import voluptuous as vol diff --git a/homeassistant/components/smappee/api.py b/homeassistant/components/smappee/api.py index 1a036b1072f..20065726eda 100644 --- a/homeassistant/components/smappee/api.py +++ b/homeassistant/components/smappee/api.py @@ -36,6 +36,8 @@ class ConfigEntrySmappeeApi(api.SmappeeApi): None, None, token=self.session.token, + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data farm=platform_to_farm[hass.data[DOMAIN][CONF_PLATFORM]], ) diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 06dcaa62853..8998a7a55a7 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring a Smappee appliance binary sensor.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index 01b69a76b28..10de2adbfaf 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -135,7 +135,8 @@ class SmappeeFlowHandler( errors={}, ) - # Environment chosen, request additional host information for LOCAL or OAuth2 flow for CLOUD + # Environment chosen, request additional host information + # for LOCAL or OAuth2 flow for CLOUD # Ask for host detail if user_input["environment"] == ENV_LOCAL: return await self.async_step_local() @@ -160,7 +161,8 @@ class SmappeeFlowHandler( ip_address = user_input["host"] serial_number = None - # Attempt 1: try to use the local api (older generation) to resolve host to serialnumber + # Attempt 1: try to use the local api (older generation) + # to resolve host to serialnumber smappee_api = api.api.SmappeeLocalApi(ip=ip_address) logon = await self.hass.async_add_executor_job(smappee_api.logon) if logon is not None: @@ -171,16 +173,19 @@ class SmappeeFlowHandler( if config_item["key"] == "mdnsHostName": serial_number = config_item["value"] else: - # Attempt 2: try to use the local mqtt broker (newer generation) to resolve host to serialnumber + # Attempt 2: try to use the local mqtt broker + # (newer generation) to resolve host to serialnumber smappee_mqtt = mqtt.SmappeeLocalMqtt() connect = await self.hass.async_add_executor_job(smappee_mqtt.start_attempt) if not connect: return self.async_abort(reason="cannot_connect") - serial_number = await self.hass.async_add_executor_job( - smappee_mqtt.start_and_wait_for_config - ) - await self.hass.async_add_executor_job(smappee_mqtt.stop) + def _get_config_and_stop() -> str | None: + result = smappee_mqtt.start_and_wait_for_config() + smappee_mqtt.stop() + return result + + serial_number = await self.hass.async_add_executor_job(_get_config_and_stop) if serial_number is None: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 759dfb34013..168e221cc0c 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring a Smappee energy sensor.""" -from __future__ import annotations - from dataclasses import dataclass, field from homeassistant.components.sensor import ( diff --git a/homeassistant/components/smarla/config_flow.py b/homeassistant/components/smarla/config_flow.py index 30bc2474511..ea25e41fd3e 100644 --- a/homeassistant/components/smarla/config_flow.py +++ b/homeassistant/components/smarla/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Swing2Sleep Smarla integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/smarla/quality_scale.yaml b/homeassistant/components/smarla/quality_scale.yaml index 7753996a280..5625249a8a6 100644 --- a/homeassistant/components/smarla/quality_scale.yaml +++ b/homeassistant/components/smarla/quality_scale.yaml @@ -48,7 +48,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py index 5c90ef227e2..30fa95b20a3 100644 --- a/homeassistant/components/smarla/sensor.py +++ b/homeassistant/components/smarla/sensor.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from typing import Any from pysmarlaapi.federwiege.services.classes import Property from pysmarlaapi.federwiege.services.types import SpringStatus @@ -23,12 +23,10 @@ from .entity import SmarlaBaseEntity, SmarlaEntityDescription PARALLEL_UPDATES = 0 -_VT = TypeVar("_VT") - @dataclass(frozen=True, kw_only=True) -class SmarlaSensorEntityDescription( - SmarlaEntityDescription, SensorEntityDescription, Generic[_VT] +class SmarlaSensorEntityDescription[_VT]( + SmarlaEntityDescription, SensorEntityDescription ): """Class describing Swing2Sleep Smarla sensor entities.""" @@ -110,7 +108,7 @@ async def async_setup_entry( async_add_entities(SmarlaSensor(federwiege, desc) for desc in SENSORS) -class SmarlaSensor(SmarlaBaseEntity, SensorEntity, Generic[_VT]): +class SmarlaSensor[_VT](SmarlaBaseEntity, SensorEntity): """Representation of Smarla sensor.""" entity_description: SmarlaSensorEntityDescription[_VT] diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index d55c44824df..5aa79964070 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -5,20 +5,24 @@ import logging from smart_meter_texas import Account from smart_meter_texas.exceptions import SmartMeterTexasAuthError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN -from .coordinator import SmartMeterTexasCoordinator, SmartMeterTexasData +from .coordinator import ( + SmartMeterTexasConfigEntry, + SmartMeterTexasCoordinator, + SmartMeterTexasData, +) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SmartMeterTexasConfigEntry +) -> bool: """Set up Smart Meter Texas from a config entry.""" username = entry.data[CONF_USERNAME] @@ -43,11 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # too long to update. coordinator = SmartMeterTexasCoordinator(hass, entry, smart_meter_texas_data) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_SMART_METER: smart_meter_texas_data, - } + entry.runtime_data = coordinator entry.async_create_background_task( hass, coordinator.async_refresh(), "smart_meter_texas-coordinator-refresh" @@ -58,10 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartMeterTexasConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smart_meter_texas/const.py b/homeassistant/components/smart_meter_texas/const.py index defe49f0be4..9c499811f10 100644 --- a/homeassistant/components/smart_meter_texas/const.py +++ b/homeassistant/components/smart_meter_texas/const.py @@ -5,9 +5,6 @@ from datetime import timedelta SCAN_INTERVAL = timedelta(hours=1) DEBOUNCE_COOLDOWN = 1800 # Seconds -DATA_COORDINATOR = "coordinator" -DATA_SMART_METER = "smart_meter_data" - DOMAIN = "smart_meter_texas" METER_NUMBER = "meter_number" diff --git a/homeassistant/components/smart_meter_texas/coordinator.py b/homeassistant/components/smart_meter_texas/coordinator.py index b489c0db01e..b1a26a6ee53 100644 --- a/homeassistant/components/smart_meter_texas/coordinator.py +++ b/homeassistant/components/smart_meter_texas/coordinator.py @@ -52,15 +52,18 @@ class SmartMeterTexasData: return self.meters -class SmartMeterTexasCoordinator(DataUpdateCoordinator[SmartMeterTexasData]): +type SmartMeterTexasConfigEntry = ConfigEntry[SmartMeterTexasCoordinator] + + +class SmartMeterTexasCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Smart Meter Texas data.""" - config_entry: ConfigEntry + config_entry: SmartMeterTexasConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartMeterTexasConfigEntry, smart_meter_texas_data: SmartMeterTexasData, ) -> None: """Initialize the coordinator.""" @@ -74,10 +77,9 @@ class SmartMeterTexasCoordinator(DataUpdateCoordinator[SmartMeterTexasData]): hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True ), ) - self._smart_meter_texas_data = smart_meter_texas_data + self.smart_meter_texas_data = smart_meter_texas_data - async def _async_update_data(self) -> SmartMeterTexasData: + async def _async_update_data(self) -> None: """Fetch latest data.""" _LOGGER.debug("Fetching latest data") - await self._smart_meter_texas_data.read_meters() - return self._smart_meter_texas_data + await self.smart_meter_texas_data.read_meters() diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index ecddd5c80c4..9071ad138e0 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -9,39 +9,31 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DATA_COORDINATOR, - DATA_SMART_METER, - DOMAIN, - ELECTRIC_METER, - ESIID, - METER_NUMBER, -) -from .coordinator import SmartMeterTexasCoordinator +from .const import ELECTRIC_METER, ESIID, METER_NUMBER +from .coordinator import SmartMeterTexasConfigEntry, SmartMeterTexasCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SmartMeterTexasConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smart Meter Texas sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - meters = hass.data[DOMAIN][config_entry.entry_id][DATA_SMART_METER].meters + coordinator = config_entry.runtime_data + meters = coordinator.smart_meter_texas_data.meters async_add_entities( [SmartMeterTexasSensor(meter, coordinator) for meter in meters], False ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing +# pylint: disable-next=home-assistant-invalid-inheritance # needs fixing class SmartMeterTexasSensor( CoordinatorEntity[SmartMeterTexasCoordinator], RestoreEntity, SensorEntity ): diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 47fc16bf879..82d8e751498 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -1,7 +1,5 @@ """Support for SmartThings Cloud.""" -from __future__ import annotations - from collections.abc import Callable import contextlib from copy import deepcopy @@ -158,7 +156,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) def _handle_max_connections() -> None: _LOGGER.debug( - "We hit the limit of max connections or we could not remove the old one, so retrying" + "We hit the limit of max connections or we could" + " not remove the old one, so retrying" ) hass.config_entries.async_schedule_reload(entry.entry_id) @@ -337,7 +336,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle config entry migration.""" if entry.version < 3: - # We keep the old data around, so we can use that to clean up the webhook in the future + # We keep the old data around, so we can use that + # to clean up the webhook in the future hass.config_entries.async_update_entry( entry, version=3, data={OLD_DATA: dict(entry.data)} ) @@ -379,7 +379,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "energySaved_meter", }: return { - "new_unique_id": f"{device_id}_{MAIN}_{Capability.POWER_CONSUMPTION_REPORT}_{Attribute.POWER_CONSUMPTION}_{attribute}", + "new_unique_id": ( + f"{device_id}_{MAIN}" + f"_{Capability.POWER_CONSUMPTION_REPORT}" + f"_{Attribute.POWER_CONSUMPTION}" + f"_{attribute}" + ), } if attribute in { "X Coordinate", @@ -392,7 +397,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Z Coordinate": "z_coordinate", }[attribute] return { - "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}", + "new_unique_id": ( + f"{device_id}_{MAIN}" + f"_{Capability.THREE_AXIS}" + f"_{Attribute.THREE_AXIS}" + f"_{new_attribute}" + ), } if attribute in { Attribute.MACHINE_STATE, @@ -404,16 +414,27 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if capability is None: return None return { - "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + "new_unique_id": ( + f"{device_id}_{MAIN}" + f"_{capability}" + f"_{attribute}_{attribute}" + ), } return None return { - "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + "new_unique_id": ( + f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}" + ), } if entity_entry.domain == "switch": return { - "new_unique_id": f"{entity_entry.unique_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + "new_unique_id": ( + f"{entity_entry.unique_id}_{MAIN}" + f"_{Capability.SWITCH}" + f"_{Attribute.SWITCH}" + f"_{Attribute.SWITCH}" + ), } return None diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 947abde50f7..30a6bb8168e 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary sensors through the SmartThings cloud API.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -330,7 +328,10 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{capability}_{attribute}_{attribute}" + ) if ( entity_description.category_device_class and (category := get_main_component_category(device)) diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py index 61aaeab13f6..e2cbbefaa23 100644 --- a/homeassistant/components/smartthings/button.py +++ b/homeassistant/components/smartthings/button.py @@ -1,7 +1,5 @@ """Support for button entities through the SmartThings cloud API.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -169,7 +167,11 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): super().__init__(client, device, capabilities) self.entity_description = entity_description self.button_capability = capability - self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{entity_description.key}" + f"_{entity_description.command}" + ) if entity_description.command_identifier is not None: self._attr_unique_id += f"_{entity_description.command_identifier}" diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 526c5840881..f5a88679e69 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -1,7 +1,5 @@ """Support for climate devices through the SmartThings cloud API.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -436,8 +434,9 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] - # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" or "fan" mode the AirConditioner - # new mode has to be "wind" or "fan" + # If new hvac_mode is HVAC_MODE_FAN_ONLY and + # AirConditioner supports "wind" or "fan" mode, + # the AirConditioner new mode has to be "wind" or "fan" if hvac_mode == HVACMode.FAN_ONLY: for fan_mode in (WIND, FAN): if fan_mode in self.get_attribute_value( diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 1925d973ef4..5d0edcdf04b 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -69,7 +69,9 @@ SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { Attribute.DUST_LEVEL: Capability.DUST_SENSOR, Attribute.FINE_DUST_LEVEL: Capability.DUST_SENSOR, Attribute.ENERGY: Capability.ENERGY_METER, - Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: ( + Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT + ), Attribute.FORMALDEHYDE_LEVEL: Capability.FORMALDEHYDE_MEASUREMENT, Attribute.GAS_METER: Capability.GAS_METER, Attribute.GAS_METER_CALORIFIC: Capability.GAS_METER, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b68409443d..45e6bd0f6c2 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -1,7 +1,5 @@ """Support for covers through the SmartThings cloud API.""" -from __future__ import annotations - from typing import Any from pysmartthings import Attribute, Capability, Command, SmartThings diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index 04517112802..f329f41c0fa 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for SmartThings.""" -from __future__ import annotations - import asyncio from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b25838ad8c9..9b912f0b007 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -1,7 +1,5 @@ """Support for SmartThings Cloud.""" -from __future__ import annotations - from typing import Any from pysmartthings import ( diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py index 0439e6391f4..071ca5fde22 100644 --- a/homeassistant/components/smartthings/event.py +++ b/homeassistant/components/smartthings/event.py @@ -1,7 +1,5 @@ """Support for events through the SmartThings cloud API.""" -from __future__ import annotations - from typing import cast from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index c5a2c5748a0..e551b0450e9 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -1,7 +1,5 @@ """Support for fans through the SmartThings cloud API.""" -from __future__ import annotations - import math from typing import Any diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 29754f1cbed..536a7fba80b 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -246,6 +246,9 @@ "power_freeze": { "default": "mdi:snowflake" }, + "purify": { + "default": "mdi:air-purifier" + }, "rinse_plus": { "default": "mdi:water-plus" }, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 426fb6f9b85..d5b5003029a 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -1,7 +1,5 @@ """Support for lights through the SmartThings cloud API.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from typing import Any, cast @@ -74,8 +72,10 @@ async def async_setup_entry( for device in entry_data.devices.values() for component in device.status if ( - Capability.SWITCH in device.status[MAIN] - and any(capability in device.status[MAIN] for capability in CAPABILITIES) + Capability.SWITCH in device.status[component] + and any( + capability in device.status[component] for capability in CAPABILITIES + ) and Capability.SAMSUNG_CE_LAMP not in device.status[component] ) ] @@ -304,6 +304,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): ) or [] ) + # If "off" is in supported levels, the switch doesn't control the lamp + self._use_switch = "off" not in levels color_modes = set() if "off" not in levels or len(levels) > 2: color_modes.add(ColorMode.BRIGHTNESS) @@ -318,7 +320,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): if ATTR_BRIGHTNESS in kwargs: await self.async_set_level(kwargs[ATTR_BRIGHTNESS]) return - if self.supports_capability(Capability.SWITCH): + if self._use_switch and self.supports_capability(Capability.SWITCH): await self.execute_device_command(Capability.SWITCH, Command.ON) # if no switch, turn on via brightness level else: @@ -326,7 +328,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the lamp off.""" - if self.supports_capability(Capability.SWITCH): + if self._use_switch and self.supports_capability(Capability.SWITCH): await self.execute_device_command(Capability.SWITCH, Command.OFF) return await self.execute_device_command( @@ -346,9 +348,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): # remove 'off' for brightness mapping if "off" in levels: levels = [level for level in levels if level != "off"] - level = percentage_to_ordered_list_item( - levels, int(round(brightness * 100 / 255)) - ) + level = percentage_to_ordered_list_item(levels, round(brightness * 100 / 255)) await self.execute_device_command( Capability.SAMSUNG_CE_LAMP, Command.SET_BRIGHTNESS_LEVEL, @@ -356,7 +356,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): ) # turn on switch separately if needed if ( - self.supports_capability(Capability.SWITCH) + self._use_switch + and self.supports_capability(Capability.SWITCH) and not self.is_on and brightness > 0 ): @@ -387,7 +388,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity): @property def is_on(self) -> bool | None: """Return true if lamp is on.""" - if self.supports_capability(Capability.SWITCH): + if self._use_switch and self.supports_capability(Capability.SWITCH): state = self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) if state is None: return None diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index f56ecd5d565..eead099d0a0 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -1,7 +1,5 @@ """Support for locks through the SmartThings cloud API.""" -from __future__ import annotations - from typing import Any from pysmartthings import Attribute, Capability, Command diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5cc4530e97a..703fc92a852 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -38,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.7.3"] + "requirements": ["pysmartthings==4.0.1"] } diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index 335e8255ae4..8274fbdbfb5 100644 --- a/homeassistant/components/smartthings/media_player.py +++ b/homeassistant/components/smartthings/media_player.py @@ -1,7 +1,5 @@ """Support for media players through the SmartThings cloud API.""" -from __future__ import annotations - from typing import Any from pysmartthings import Attribute, Capability, Category, Command, SmartThings @@ -20,6 +18,26 @@ from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity +MEDIA_SOURCE_ID_TO_HA_KEY: dict[str, str] = { + "AM": "am", + "BT": "bluetooth", + "CD": "cd", + "D.IN": "digital_input", + "HDMI": "hdmi", + "HDMI1": "hdmi1", + "HDMI2": "hdmi2", + "HDMI3": "hdmi3", + "HDMI4": "hdmi4", + "HDMI5": "hdmi5", + "HDMI6": "hdmi6", + "USB": "usb", + "WIFI": "wifi", + "digitalTv": "digital_tv", + "dtv": "digital_tv", + "melon": "melon", + "youtube": "youtube", +} + MEDIA_PLAYER_CAPABILITIES = ( Capability.AUDIO_MUTE, Capability.AUDIO_VOLUME, @@ -32,6 +50,7 @@ DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = { Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER, Category.TELEVISION: MediaPlayerDeviceClass.TV, Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER, + Category.PROJECTOR: MediaPlayerDeviceClass.PROJECTOR, } VALUE_TO_STATE = { @@ -74,6 +93,7 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): """Define a SmartThings media player.""" _attr_name = None + _attr_translation_key = "media_player" def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the media_player class.""" @@ -89,6 +109,7 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): Capability.MEDIA_PLAYBACK_REPEAT, Capability.MEDIA_PLAYBACK_SHUFFLE, Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE, Capability.SWITCH, }, ) @@ -97,6 +118,43 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): device.device.components[MAIN].user_category or device.device.components[MAIN].manufacturer_category, ) + self._source_to_smartthings_id: dict[str, str] = {} + + def _update_attr(self) -> None: + """Update the attributes.""" + self._build_source_map() + + def _build_source_map(self) -> None: + """Build the source mapping from HA key to SmartThings ID.""" + raw_sources = self._get_raw_source_list() + if not raw_sources: + self._source_to_smartthings_id = {} + return + self._source_to_smartthings_id = { + MEDIA_SOURCE_ID_TO_HA_KEY.get(source_id, source_id): source_id + for source_id in raw_sources + } + + def _get_raw_source_list(self) -> list[str] | None: + """Get the raw source list from the device.""" + if self.supports_capability(Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE): + sources_map = self.get_attribute_value( + Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE, + Attribute.SUPPORTED_INPUT_SOURCES_MAP, + ) + if not sources_map: + return None + return [source["id"] for source in sources_map] + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Attribute.SUPPORTED_INPUT_SOURCES, + ) + return None def _determine_features(self) -> MediaPlayerEntityFeature: flags = ( @@ -122,7 +180,9 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): flags |= ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) - if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + if self.supports_capability( + Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE + ) or self.supports_capability(Capability.MEDIA_INPUT_SOURCE): flags |= MediaPlayerEntityFeature.SELECT_SOURCE if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): flags |= MediaPlayerEntityFeature.SHUFFLE_SET @@ -211,11 +271,19 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select source.""" - await self.execute_device_command( - Capability.MEDIA_INPUT_SOURCE, - Command.SET_INPUT_SOURCE, - argument=source, - ) + smartthings_source = self._source_to_smartthings_id.get(source, source) + if self.supports_capability(Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE): + await self.execute_device_command( + Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + argument=smartthings_source, + ) + else: + await self.execute_device_command( + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + argument=smartthings_source, + ) async def async_set_shuffle(self, shuffle: bool) -> None: """Set shuffle mode.""" @@ -311,29 +379,30 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): @property def source(self) -> str | None: """Input source.""" - if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): - return self.get_attribute_value( + if self.supports_capability(Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE): + raw = self.get_attribute_value( + Capability.SAMSUNG_VD_MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + elif self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + raw = self.get_attribute_value( Capability.MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE ) - if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): - return self.get_attribute_value( + elif self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + raw = self.get_attribute_value( Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, Attribute.INPUT_SOURCE ) - return None + else: + raw = None + if raw is None: + return None + return MEDIA_SOURCE_ID_TO_HA_KEY.get(raw, raw) @property def source_list(self) -> list[str] | None: """List of input sources.""" - if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): - return self.get_attribute_value( - Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES - ) - if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): - return self.get_attribute_value( - Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, - Attribute.SUPPORTED_INPUT_SOURCES, - ) - return None + if not self._source_to_smartthings_id: + return None + return list(self._source_to_smartthings_id) @property def shuffle(self) -> bool | None: diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index 1f4779eab81..b181fa16c68 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -1,7 +1,5 @@ """Support for number entities through the SmartThings cloud API.""" -from __future__ import annotations - from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode @@ -62,7 +60,12 @@ class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the instance.""" super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}" + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}" + f"_{Capability.CUSTOM_WASHER_RINSE_CYCLES}" + f"_{Attribute.WASHER_RINSE_CYCLES}" + f"_{Attribute.WASHER_RINSE_CYCLES}" + ) @property def options(self) -> list[int]: @@ -114,7 +117,12 @@ class SmartThingsHoodNumberEntity(SmartThingsEntity, NumberEntity): super().__init__( client, device, {Capability.SAMSUNG_CE_HOOD_FAN_SPEED}, component="hood" ) - self._attr_unique_id = f"{device.device.device_id}_hood_{Capability.SAMSUNG_CE_HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}" + self._attr_unique_id = ( + f"{device.device.device_id}_hood" + f"_{Capability.SAMSUNG_CE_HOOD_FAN_SPEED}" + f"_{Attribute.HOOD_FAN_SPEED}" + f"_{Attribute.HOOD_FAN_SPEED}" + ) @property def options(self) -> list[int]: @@ -171,7 +179,12 @@ class SmartThingsRefrigeratorTemperatureNumberEntity(SmartThingsEntity, NumberEn {Capability.THERMOSTAT_COOLING_SETPOINT}, component=component, ) - self._attr_unique_id = f"{device.device.device_id}_{component}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{Capability.THERMOSTAT_COOLING_SETPOINT}" + f"_{Attribute.COOLING_SETPOINT}" + f"_{Attribute.COOLING_SETPOINT}" + ) unit = self._internal_state[Capability.THERMOSTAT_COOLING_SETPOINT][ Attribute.COOLING_SETPOINT ].unit diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 2b387859f22..85b51f1924a 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -25,6 +25,8 @@ async def async_setup_entry( class SmartThingsScene(Scene): """Define a SmartThings scene.""" + _attr_has_entity_name = True + def __init__(self, scene: STScene, client: SmartThings) -> None: """Init the scene class.""" self.client = client diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index b91cd641080..903a44a9426 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -1,7 +1,5 @@ """Support for select entities through the SmartThings cloud API.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast @@ -167,13 +165,15 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { command=Command.SET_AMOUNT, entity_category=EntityCategory.CONFIG, ), - Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT: SmartThingsSelectDescription( - key=Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT, - translation_key="flexible_detergent_amount", - options_attribute=Attribute.SUPPORTED_AMOUNT, - status_attribute=Attribute.AMOUNT, - command=Command.SET_AMOUNT, - entity_category=EntityCategory.CONFIG, + Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT: ( + SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT, + translation_key="flexible_detergent_amount", + options_attribute=Attribute.SUPPORTED_AMOUNT, + status_attribute=Attribute.AMOUNT, + command=Command.SET_AMOUNT, + entity_category=EntityCategory.CONFIG, + ) ), Capability.SAMSUNG_CE_LAMP: SmartThingsSelectDescription( key=Capability.SAMSUNG_CE_LAMP, @@ -363,7 +363,12 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): capabilities.update(extra_capabilities) super().__init__(client, device, capabilities, component=component) self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{entity_description.key}" + f"_{entity_description.status_attribute}" + f"_{entity_description.status_attribute}" + ) @property def options(self) -> list[str]: diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1f303013182..94dcaa448a3 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1,7 +1,5 @@ """Support for sensors through the SmartThings cloud API.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime @@ -29,6 +27,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -396,7 +395,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.COMPLETION_TIME, translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=dt_util.parse_datetime, + value_fn=lambda value: dt_util.parse_datetime(value) if value else None, ) ], }, @@ -449,7 +448,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.COMPLETION_TIME, translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=dt_util.parse_datetime, + value_fn=lambda value: dt_util.parse_datetime(value) if value else None, ) ], }, @@ -567,7 +566,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.GAS_METER_TIME, translation_key="gas_meter_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=dt_util.parse_datetime, + value_fn=lambda value: dt_util.parse_datetime(value) if value else None, ) ], Attribute.GAS_METER_VOLUME: [ @@ -728,7 +727,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.COMPLETION_TIME, translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=dt_util.parse_datetime, + value_fn=lambda value: dt_util.parse_datetime(value) if value else None, component_fn=lambda component: component == "cavity-01", component_translation_key={ "cavity-01": "oven_completion_time_cavity_01", @@ -1198,7 +1197,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.COMPLETION_TIME, translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=dt_util.parse_datetime, + value_fn=lambda value: dt_util.parse_datetime(value) if value else None, component_fn=lambda component: component == "sub", component_translation_key={ "sub": "washer_sub_completion_time", @@ -1280,12 +1279,43 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.MIRRORHAPPY40050_COPPER_WATER_METER: { + Attribute.ENERGY_USAGE_DAY: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY_USAGE_DAY, + translation_key="water_usage_day", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.GALLONS, + ) + ], + Attribute.ENERGY_USAGE_MONTH: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY_USAGE_MONTH, + translation_key="water_usage_month", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.GALLONS, + ) + ], + Attribute.POWER_CURRENT: [ + SmartThingsSensorEntityDescription( + key=Attribute.POWER_CURRENT, + translation_key="water_usage_current", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ) + ], + }, } UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "Celsius": UnitOfTemperature.CELSIUS, + "Fahrenheit": UnitOfTemperature.FAHRENHEIT, "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, @@ -1319,7 +1349,9 @@ async def async_setup_entry( capability in device.status[MAIN] for capability in capability_list ) - for capability_list in description.capability_ignore_list + for capability_list in ( + description.capability_ignore_list + ) ) ) and ( @@ -1401,7 +1433,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) super().__init__(client, device, capabilities_to_subscribe, component=component) - self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{entity_description.key}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{capability}_{attribute}" + f"_{entity_description.key}" + ) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6deaefceae4..ada4d98244c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -197,6 +197,42 @@ "name": "[%key:component::light::title%]" } }, + "media_player": { + "media_player": { + "state_attributes": { + "source": { + "state": { + "am": "AM", + "analog1": "Analog 1", + "analog2": "Analog 2", + "analog3": "Analog 3", + "aux": "AUX", + "bluetooth": "Bluetooth", + "cd": "CD", + "coaxial": "Coaxial", + "digital": "Digital", + "digital_input": "Digital input", + "digital_tv": "Digital TV", + "fm": "FM", + "hdmi": "HDMI", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "melon": "Melon", + "network": "Network", + "optical": "Optical", + "phono": "Phono", + "usb": "USB", + "wifi": "Wi-Fi", + "youtube": "YouTube" + } + } + } + } + }, "number": { "cool_select_plus_temperature": { "name": "CoolSelect+ temperature" @@ -952,6 +988,15 @@ "water_filter_usage": { "name": "Water filter usage" }, + "water_usage_current": { + "name": "Current water usage" + }, + "water_usage_day": { + "name": "Water usage today" + }, + "water_usage_month": { + "name": "Water usage this month" + }, "x_coordinate": { "name": "X coordinate" }, @@ -1014,6 +1059,9 @@ "power_freeze": { "name": "Power freeze" }, + "purify": { + "name": "Purify" + }, "rinse_plus": { "name": "Rinse plus" }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index fbf6ebd630f..39e90bdb3e0 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -1,7 +1,5 @@ """Support for switches through the SmartThings cloud API.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast @@ -80,13 +78,22 @@ SWITCH = SmartThingsSwitchEntityDescription( CAPABILITY_TO_COMMAND_SWITCHES: dict[ Capability | str, SmartThingsCommandSwitchEntityDescription ] = { - Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: SmartThingsCommandSwitchEntityDescription( - key=Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING, - translation_key="display_lighting", - status_attribute=Attribute.LIGHTING, - command=Command.SET_LIGHTING_LEVEL, + Capability.CUSTOM_SPI_MODE: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_SPI_MODE, + translation_key="purify", + status_attribute=Attribute.SPI_MODE, + command=Command.SET_SPI_MODE, entity_category=EntityCategory.CONFIG, ), + Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: ( + SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING, + translation_key="display_lighting", + status_attribute=Attribute.LIGHTING, + command=Command.SET_LIGHTING_LEVEL, + entity_category=EntityCategory.CONFIG, + ) + ), Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription( key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT, translation_key="wrinkle_prevent", @@ -94,21 +101,25 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[ command=Command.SET_DRYER_WRINKLE_PREVENT, entity_category=EntityCategory.CONFIG, ), - Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK: SmartThingsCommandSwitchEntityDescription( - key=Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK, - translation_key="auto_cycle_link", - status_attribute=Attribute.STEAM_CLOSET_AUTO_CYCLE_LINK, - command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK, - entity_category=EntityCategory.CONFIG, + Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK: ( + SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK, + translation_key="auto_cycle_link", + status_attribute=Attribute.STEAM_CLOSET_AUTO_CYCLE_LINK, + command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK, + entity_category=EntityCategory.CONFIG, + ) ), - Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS: SmartThingsCommandSwitchEntityDescription( - key=Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS, - translation_key="bypass_mode", - status_attribute=Attribute.BYPASS_MODE, - entity_category=EntityCategory.CONFIG, - on_key="enabled", - off_key="disabled", - command=Command.SET_BYPASS_MODE, + Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS: ( + SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS, + translation_key="bypass_mode", + status_attribute=Attribute.BYPASS_MODE, + entity_category=EntityCategory.CONFIG, + on_key="enabled", + off_key="disabled", + command=Command.SET_BYPASS_MODE, + ) ), } CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { @@ -159,17 +170,21 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio off_command=Command.DEACTIVATE, entity_category=EntityCategory.CONFIG, ), - Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE: SmartThingsSwitchEntityDescription( - key=Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE, - translation_key="sanitize", - status_attribute=Attribute.STATUS, - entity_category=EntityCategory.CONFIG, + Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE: ( + SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE, + translation_key="sanitize", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ) ), - Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: SmartThingsSwitchEntityDescription( - key=Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE, - translation_key="keep_fresh_mode", - status_attribute=Attribute.STATUS, - entity_category=EntityCategory.CONFIG, + Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: ( + SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE, + translation_key="keep_fresh_mode", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ) ), Capability.CUSTOM_DO_NOT_DISTURB_MODE: SmartThingsSwitchEntityDescription( key=Capability.CUSTOM_DO_NOT_DISTURB_MODE, @@ -188,13 +203,15 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio on_command=Command.ENABLE_SOUND_DETECTION, off_command=Command.DISABLE_SOUND_DETECTION, ), - Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS: SmartThingsSwitchEntityDescription( - key=Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS, - translation_key="empty_dustbin", - status_attribute=Attribute.OPERATING_STATE, - on_key="emptying", - on_command=Command.START_EMPTYING, - off_command=Command.STOP_EMPTYING, + Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS: ( + SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS, + translation_key="empty_dustbin", + status_attribute=Attribute.OPERATING_STATE, + on_key="emptying", + on_command=Command.START_EMPTYING, + off_command=Command.STOP_EMPTYING, + ) ), } DISHWASHER_WASHING_OPTIONS_TO_SWITCHES: dict[ @@ -256,12 +273,14 @@ DISHWASHER_WASHING_OPTIONS_TO_SWITCHES: dict[ command=Command.SET_SANITIZE, entity_category=EntityCategory.CONFIG, ), - Attribute.SANITIZING_WASH: SmartThingsDishwasherWashingOptionSwitchEntityDescription( - key=Attribute.SANITIZING_WASH, - translation_key="sanitizing_wash", - status_attribute=Attribute.SANITIZING_WASH, - command=Command.SET_SANITIZING_WASH, - entity_category=EntityCategory.CONFIG, + Attribute.SANITIZING_WASH: ( + SmartThingsDishwasherWashingOptionSwitchEntityDescription( + key=Attribute.SANITIZING_WASH, + translation_key="sanitizing_wash", + status_attribute=Attribute.SANITIZING_WASH, + command=Command.SET_SANITIZING_WASH, + entity_category=EntityCategory.CONFIG, + ) ), Attribute.SPEED_BOOSTER: SmartThingsDishwasherWashingOptionSwitchEntityDescription( key=Attribute.SPEED_BOOSTER, @@ -422,7 +441,12 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): ) self.entity_description = entity_description self.switch_capability = capability - self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" + self._attr_unique_id = ( + f"{device.device.device_id}_{component}" + f"_{capability}" + f"_{entity_description.status_attribute}" + f"_{entity_description.status_attribute}" + ) if ( translation_keys := entity_description.component_translation_key ) is not None and ( diff --git a/homeassistant/components/smartthings/time.py b/homeassistant/components/smartthings/time.py index de4057d4ac1..e7f3477a9a9 100644 --- a/homeassistant/components/smartthings/time.py +++ b/homeassistant/components/smartthings/time.py @@ -1,7 +1,5 @@ """Time platform for SmartThings.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import time @@ -69,7 +67,12 @@ class SmartThingsDnDTime(SmartThingsEntity, TimeEntity): """Initialize the time entity.""" super().__init__(client, device, {Capability.CUSTOM_DO_NOT_DISTURB_MODE}) self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_DO_NOT_DISTURB_MODE}_{entity_description.attribute}_{entity_description.attribute}" + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}" + f"_{Capability.CUSTOM_DO_NOT_DISTURB_MODE}" + f"_{entity_description.attribute}" + f"_{entity_description.attribute}" + ) async def async_set_value(self, value: time) -> None: """Set the time value.""" @@ -89,7 +92,9 @@ class SmartThingsDnDTime(SmartThingsEntity, TimeEntity): Command.SET_DO_NOT_DISTURB_MODE, { **payload, - self.entity_description.attribute: f"{value.hour:02d}{value.minute:02d}", + self.entity_description.attribute: ( + f"{value.hour:02d}{value.minute:02d}" + ), }, ) diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py index bb226918596..db8bc3577b8 100644 --- a/homeassistant/components/smartthings/update.py +++ b/homeassistant/components/smartthings/update.py @@ -1,7 +1,5 @@ """Support for update entities through the SmartThings cloud API.""" -from __future__ import annotations - from typing import Any from awesomeversion import AwesomeVersion diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py index 6c7fe681b95..edcd67ea3c8 100644 --- a/homeassistant/components/smartthings/vacuum.py +++ b/homeassistant/components/smartthings/vacuum.py @@ -1,7 +1,5 @@ """SmartThings vacuum platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py index 4279d528f8b..14b35d03e32 100644 --- a/homeassistant/components/smartthings/valve.py +++ b/homeassistant/components/smartthings/valve.py @@ -1,7 +1,5 @@ """Support for valves through the SmartThings cloud API.""" -from __future__ import annotations - from pysmartthings import Attribute, Capability, Category, Command, SmartThings from homeassistant.components.valve import ( diff --git a/homeassistant/components/smartthings/water_heater.py b/homeassistant/components/smartthings/water_heater.py index 4b1aaaa5549..6d46b534d9c 100644 --- a/homeassistant/components/smartthings/water_heater.py +++ b/homeassistant/components/smartthings/water_heater.py @@ -1,7 +1,5 @@ """Support for water heaters through the SmartThings cloud API.""" -from __future__ import annotations - from typing import Any from pysmartthings import Attribute, Capability, Command, SmartThings diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index d3ce8a1461c..2fe1456eb1b 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - import logging from typing import Any @@ -95,7 +93,10 @@ async def async_setup_entry( class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): - """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" + """A binary sensor indicating whether the spa is online. + + Indicates if it is connected to the cloud. + """ _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY # This seems to be very noisy and not generally useful, so disable by default. diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 3e533d4a051..58bb30a944b 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -1,7 +1,5 @@ """Platform for climate integration.""" -from __future__ import annotations - from typing import Any from smarttub import Spa diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py index cf96d7082a1..81d451c1059 100644 --- a/homeassistant/components/smarttub/config_flow.py +++ b/homeassistant/components/smarttub/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the SmartTub integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 0a364ce3cbd..ab79d600e64 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -101,5 +101,5 @@ class SmartTubExternalSensorBase(SmartTubEntity): @property def sensor(self) -> SpaSensor: - """Convenience property to access the smarttub.SpaSensor instance for this sensor.""" + """Access the smarttub.SpaSensor instance for this sensor.""" return self.coordinator.data[self.spa.id][ATTR_SENSORS][self.sensor_address] diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 735229079a4..43b7e4fbd53 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -7,6 +7,7 @@ import smarttub import voluptuous as vol from homeassistant.components.sensor import SensorEntity +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -19,7 +20,6 @@ from .entity import SmartTubOnboardSensorBase # the desired duration, in hours, of the cycle ATTR_DURATION = "duration" ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated" -ATTR_MODE = "mode" # the hour of the day at which to start the cycle (0-23) ATTR_START_HOUR = "start_hour" diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index 82236a154f0..84197fa4770 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Salda Smarty XP/XV Ventilation Unit Binary Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py index 78638561088..612250de392 100644 --- a/homeassistant/components/smarty/button.py +++ b/homeassistant/components/smarty/button.py @@ -1,7 +1,5 @@ """Platform to control a Salda Smarty XP/XV ventilation unit.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/smarty/coordinator.py b/homeassistant/components/smarty/coordinator.py index a55c9f2e78f..a7075ef5147 100644 --- a/homeassistant/components/smarty/coordinator.py +++ b/homeassistant/components/smarty/coordinator.py @@ -36,8 +36,8 @@ class SmartyCoordinator(DataUpdateCoordinator[None]): async def _async_setup(self) -> None: if not await self.hass.async_add_executor_job(self.client.update): raise UpdateFailed("Failed to update Smarty data") - self.software_version = self.client.get_software_version() - self.configuration_version = self.client.get_configuration_version() + self.software_version = str(self.client.get_software_version()) + self.configuration_version = str(self.client.get_configuration_version()) async def _async_update_data(self) -> None: """Fetch data from Smarty.""" diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 07dec85ae47..9d316c8867f 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -1,7 +1,5 @@ """Platform to control a Salda Smarty XP/XV ventilation unit.""" -from __future__ import annotations - import logging import math from typing import Any diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index fe35f741380..404782cc251 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -1,7 +1,5 @@ """Support for Salda Smarty XP/XV Ventilation Unit Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/smarty/switch.py b/homeassistant/components/smarty/switch.py index 5781bb11680..bc9b33fcaf7 100644 --- a/homeassistant/components/smarty/switch.py +++ b/homeassistant/components/smarty/switch.py @@ -1,7 +1,5 @@ """Platform to control a Salda Smarty XP/XV ventilation unit.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 07f53b02d98..a3cdd5d9a8f 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -23,7 +23,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool # Setting unique id where missing if entry.unique_id is None: - unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" + unique_id = ( + f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}" + f"-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" + ) hass.config_entries.async_update_entry(entry, unique_id=unique_id) coordinator = SMHIDataUpdateCoordinator(hass, entry) diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 387edfc6e11..40ef93ed4eb 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure SMHI component.""" -from __future__ import annotations - from typing import Any from pysmhi import SmhiForecastException, SMHIPointForecast diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index d5a3c9ed154..2e983d40d21 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the SMHI integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 1f0b94cddbd..3ab5a432f44 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -1,7 +1,5 @@ """Support for the Swedish weather institute weather base entities.""" -from __future__ import annotations - from abc import abstractmethod from homeassistant.core import callback diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py index c1d294167fc..5abd4c84f56 100644 --- a/homeassistant/components/smhi/sensor.py +++ b/homeassistant/components/smhi/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for SMHI integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index fc940ca3e5f..c3c6c482361 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -5,20 +5,24 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { - "wrong_location": "Location Sweden only" + "wrong_location": "Only locations in Sweden are supported" }, "step": { "reconfigure": { "data": { - "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]" + "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "Choose a location in Sweden." }, "title": "Reconfigure your location in Sweden" }, "user": { "data": { - "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]" + "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "[%key:component::smhi::config::step::reconfigure::data_description::location%]" }, "title": "Location in Sweden" } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 1025607ef31..dc202c6173d 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -1,7 +1,5 @@ """Support for the Swedish weather institute weather service.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, Final diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index a6d7bbd14ea..b478a5db5f7 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -1,23 +1,28 @@ """SMLIGHT SLZB Zigbee device integration.""" -from __future__ import annotations - 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.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import ( SmConfigEntry, SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator, SmlightData, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.INFRARED, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, @@ -25,6 +30,12 @@ PLATFORMS: list[Platform] = [ ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the SMLIGHT services.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index aaba15e19f2..84a4eabf569 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -1,7 +1,5 @@ """Support for SLZB-06 binary sensors.""" -from __future__ import annotations - from _collections_abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index e2c2e5454ba..f8ce32bf354 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -1,7 +1,5 @@ """Support for SLZB buttons.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 39750bdc422..babfda0bdde 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -1,7 +1,5 @@ """Config flow for SMLIGHT Zigbee integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 33ea8d75703..5713e9f049d 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Smlight.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/smlight/diagnostics.py b/homeassistant/components/smlight/diagnostics.py index 3812175e673..afc56bce580 100644 --- a/homeassistant/components/smlight/diagnostics.py +++ b/homeassistant/components/smlight/diagnostics.py @@ -1,7 +1,5 @@ """Collect diagnostics for SMLIGHT devices.""" -from __future__ import annotations - from typing import Any from pysmlight.const import Actions diff --git a/homeassistant/components/smlight/entity.py b/homeassistant/components/smlight/entity.py index 7e6213cbdf1..3d0c21ce78c 100644 --- a/homeassistant/components/smlight/entity.py +++ b/homeassistant/components/smlight/entity.py @@ -1,7 +1,5 @@ """Base class for all SMLIGHT entities.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -27,5 +25,8 @@ class SmEntity(CoordinatorEntity[SmBaseDataUpdateCoordinator]): connections={(CONNECTION_NETWORK_MAC, mac)}, manufacturer=ATTR_MANUFACTURER, model=coordinator.data.info.model, - sw_version=f"core: {coordinator.data.info.sw_version} / zigbee: {coordinator.data.info.zb_version}", + sw_version=( + f"core: {coordinator.data.info.sw_version}" + f" / zigbee: {coordinator.data.info.zb_version}" + ), ) diff --git a/homeassistant/components/smlight/icons.json b/homeassistant/components/smlight/icons.json index 7ddb037408e..cebdb70511a 100644 --- a/homeassistant/components/smlight/icons.json +++ b/homeassistant/components/smlight/icons.json @@ -33,5 +33,10 @@ "default": "mdi:shield-lock" } } + }, + "services": { + "play_rtttl": { + "service": "mdi:music-note" + } } } diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py new file mode 100644 index 00000000000..26ecd6f0779 --- /dev/null +++ b/homeassistant/components/smlight/infrared.py @@ -0,0 +1,58 @@ +"""Infrared platform for SLZB-Ultima.""" + +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload + +from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator +from .entity import SmEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize infrared for SLZB-Ultima device.""" + coordinator = entry.runtime_data.data + + if coordinator.data.info.has_peripherals: + async_add_entities([SmInfraredEntity(coordinator)]) + + +class SmInfraredEntity(SmEntity, InfraredEmitterEntity): + """Representation of a SLZB-Ultima infrared emitter.""" + + _attr_translation_key = "infrared_emitter" + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima infrared.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" + + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command.""" + # pysmlight's IRPayload.from_raw_timings expects positive durations, + # so strip the sign from the signed pulse/space timings. + timings = [abs(t) for t in command.get_raw_timings()] + + freq = command.modulation + + try: + await self.coordinator.async_execute_command( + self.coordinator.client.actions.send_ir_code, + IRPayload.from_raw_timings(timings, freq=freq), + ) + except (SmlightError, ValueError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_ir_code_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/smlight/light.py b/homeassistant/components/smlight/light.py index 669f6ef03af..d00678f820a 100644 --- a/homeassistant/components/smlight/light.py +++ b/homeassistant/components/smlight/light.py @@ -125,6 +125,7 @@ class SmLightEntity(SmEntity, LightEntity): effect_name: str = kwargs[ATTR_EFFECT] try: idx = self.entity_description.effect_list.index(effect_name) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError: _LOGGER.warning("Unknown effect: %s", effect_name) return diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml index 5c6d7364704..ae4b26608b0 100644 --- a/homeassistant/components/smlight/quality_scale.yaml +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - This integration does not provide additional actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - This integration does not provide additional actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -31,10 +25,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - This integration does not provide actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -56,7 +47,7 @@ rules: discovery-update-info: done discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: todo docs-supported-devices: done docs-supported-functions: done diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index c055a43fce9..f73d8a81fde 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -1,7 +1,5 @@ """Support for SLZB-06 sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/smlight/services.py b/homeassistant/components/smlight/services.py new file mode 100644 index 00000000000..9c7fe0cb6d9 --- /dev/null +++ b/homeassistant/components/smlight/services.py @@ -0,0 +1,135 @@ +"""SMLIGHT services.""" + +from pysmlight.exceptions import SmlightError +from pysmlight.models import BuzzerPayload +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import SmConfigEntry + +SERVICE_PLAY_RTTTL = "play_rtttl" + +ATTR_BPM = "bpm" +ATTR_DURATION = "duration" +ATTR_OCTAVE = "octave" +ATTR_NOTES = "notes" + +RTTTL_VALID_BPMS: list[int] = [ + 25, + 28, + 31, + 35, + 40, + 45, + 50, + 56, + 63, + 70, + 80, + 90, + 100, + 112, + 125, + 140, + 160, + 180, + 200, + 225, + 250, + 285, + 320, + 355, + 400, + 450, + 500, + 565, + 635, + 715, + 800, + 900, +] + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for SMLIGHT.""" + + async def async_play_rtttl(call: ServiceCall) -> None: + """Play RTTTL tone.""" + notes = call.data[ATTR_NOTES] + octave: int = call.data[ATTR_OCTAVE] + bpm: int | None = call.data.get(ATTR_BPM) + duration: int | None = call.data.get(ATTR_DURATION) + + header: list[str] = [] + + if duration is not None: + header.append(f"d={duration}") + header.append(f"o={octave}") + if bpm is not None: + header.append(f"b={bpm}") + tone = f"S:{','.join(header)}:{notes}" + + target_entry_ids = await async_extract_config_entry_ids(call) + target_entries: list[SmConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_device_found", + ) + + for target_entry in target_entries: + coordinator = target_entry.runtime_data.data + client = coordinator.client + + if not coordinator.data.info.has_peripherals: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_supported_buzzer", + ) + + try: + await coordinator.async_execute_command( + client.actions.buzzer, BuzzerPayload(code=tone) + ) + except SmlightError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="play_tone_failed", + translation_placeholders={ + "device_name": target_entry.title, + "error": str(err), + }, + ) from err + + schema = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_DURATION): vol.All( + vol.Coerce(int), vol.In([1, 2, 4, 8, 16, 32]) + ), + vol.Required(ATTR_OCTAVE): vol.All( + vol.Coerce(int), vol.Range(min=4, max=7) + ), + vol.Optional(ATTR_BPM): vol.All(vol.Coerce(int), vol.In(RTTTL_VALID_BPMS)), + vol.Required(ATTR_NOTES): cv.string, + } + ) + + hass.services.async_register( + DOMAIN, + SERVICE_PLAY_RTTTL, + async_play_rtttl, + schema=schema, + ) diff --git a/homeassistant/components/smlight/services.yaml b/homeassistant/components/smlight/services.yaml new file mode 100644 index 00000000000..404640906f9 --- /dev/null +++ b/homeassistant/components/smlight/services.yaml @@ -0,0 +1,73 @@ +play_rtttl: + fields: + device_id: + required: true + selector: + device: + filter: + - integration: smlight + model: SLZB-Ultima3 + - integration: smlight + model: SLZB-Ultima4 + duration: + required: false + example: 4 + selector: + select: + options: + - "1" + - "2" + - "4" + - "8" + - "16" + - "32" + octave: + required: true + example: 5 + selector: + number: + min: 4 + max: 7 + bpm: + required: false + example: 63 + selector: + select: + options: + - "25" + - "28" + - "31" + - "35" + - "40" + - "45" + - "50" + - "56" + - "63" + - "70" + - "80" + - "90" + - "100" + - "112" + - "125" + - "140" + - "160" + - "180" + - "200" + - "225" + - "250" + - "285" + - "320" + - "355" + - "400" + - "450" + - "500" + - "565" + - "635" + - "715" + - "800" + - "900" + notes: + required: true + example: "8d,8d#,8e,c6,8e,c6,8e" + selector: + text: diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 6fbac239207..0a6af2ca81a 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -79,6 +79,11 @@ "name": "Zigbee restart" } }, + "infrared": { + "infrared_emitter": { + "name": "IR emitter" + } + }, "light": { "ambilight": { "name": "Ambilight" @@ -159,6 +164,18 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." + }, + "no_device_found": { + "message": "No valid SMLIGHT device found for the given targets." + }, + "not_supported_buzzer": { + "message": "The selected SMLIGHT device does not support the buzzer feature. This feature is only available on Ultima models." + }, + "play_tone_failed": { + "message": "Failed to play tone on {device_name}: {error}." + }, + "send_ir_code_failed": { + "message": "Failed to send IR code: {error}." } }, "issues": { @@ -166,5 +183,33 @@ "description": "Your SMLIGHT SLZB-06x device is running an unsupported core firmware version. Please update it to the latest version to enjoy all the features of this integration.", "title": "SLZB core firmware update required" } + }, + "services": { + "play_rtttl": { + "description": "Play an RTTTL melody on the SMLIGHT device buzzer.", + "fields": { + "bpm": { + "description": "Tempo in beats per minute. Omit to use the RTTTL default of 63.", + "name": "BPM" + }, + "device_id": { + "description": "The SMLIGHT device.", + "name": "Device" + }, + "duration": { + "description": "Default note duration (1, 2, 4, 8, 16 or 32). Omit to use the RTTTL default of 4.", + "name": "Duration" + }, + "notes": { + "description": "The note sequence in RTTTL format, e.g. 8d,8d#,8e,c6.", + "name": "Notes" + }, + "octave": { + "description": "Default octave (4–7).", + "name": "Octave" + } + }, + "name": "Play RTTTL tone" + } } } diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 17c4a0d7dce..130897636a8 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -1,7 +1,5 @@ """Support for SLZB-06 switches.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index d7aed0ecb4d..c5fb90e2cb7 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -1,7 +1,5 @@ """Support updates for SLZB-06 ESP32 and Zigbee firmwares.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -243,6 +241,7 @@ class SmUpdateEntity(SmEntity, UpdateEntity): ): await self.coordinator.async_refresh() await asyncio.sleep(1) + # pylint: disable-next=home-assistant-action-swallowed-exception except TimeoutError: LOGGER.warning( "Timeout waiting for %s to reboot after update", diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index 1fa077a24fb..b0bfc2e0292 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -9,7 +9,6 @@ ATTR_HTML: Final = "html" ATTR_SENDER_NAME: Final = "sender_name" CONF_ENCRYPTION: Final = "encryption" -CONF_DEBUG: Final = "debug" CONF_SERVER: Final = "server" CONF_SENDER_NAME: Final = "sender_name" diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 96b1a7e8e9f..b3c811a5f98 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -1,7 +1,5 @@ """Mail (SMTP) notification service.""" -from __future__ import annotations - from email.mime.application import MIMEApplication from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart @@ -26,6 +24,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.const import ( + CONF_DEBUG, CONF_PASSWORD, CONF_PORT, CONF_RECIPIENT, @@ -46,7 +45,6 @@ from homeassistant.util.ssl import create_client_context from .const import ( ATTR_HTML, ATTR_IMAGES, - CONF_DEBUG, CONF_ENCRYPTION, CONF_SENDER_NAME, CONF_SERVER, diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 0888f339a7d..e56d28fa7bd 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1,6 +1,5 @@ """Snapcast Integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -8,7 +7,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS -from .coordinator import SnapcastUpdateCoordinator +from .coordinator import SnapcastConfigEntry, SnapcastUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -20,7 +19,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SnapcastConfigEntry) -> bool: """Set up Snapcast from a config entry.""" coordinator = SnapcastUpdateCoordinator(hass, entry) @@ -32,16 +31,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SnapcastConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - snapcast_data = hass.data[DOMAIN].pop(entry.entry_id) # disconnect from server - await snapcast_data.disconnect() + await entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index b37921fd374..bb74ffada99 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -1,7 +1,5 @@ """Snapcast config flow.""" -from __future__ import annotations - import logging import socket diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 963f12887fc..68100ec8c85 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Snapcast server.""" -from __future__ import annotations - import logging from snapcast.control.server import Snapserver @@ -13,13 +11,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type SnapcastConfigEntry = ConfigEntry[SnapcastUpdateCoordinator] + class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for pushed data from Snapcast server.""" - config_entry: ConfigEntry + config_entry: SnapcastConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SnapcastConfigEntry) -> None: """Initialize coordinator.""" host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] diff --git a/homeassistant/components/snapcast/entity.py b/homeassistant/components/snapcast/entity.py index cceeb6227fd..f7121ce44de 100644 --- a/homeassistant/components/snapcast/entity.py +++ b/homeassistant/components/snapcast/entity.py @@ -1,7 +1,5 @@ """Coordinator entity for Snapcast server.""" -from __future__ import annotations - from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import SnapcastUpdateCoordinator diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index bccded10176..e7e22ad7b10 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -1,7 +1,5 @@ """Support for interacting with Snapcast clients.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -17,14 +15,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CLIENT_PREFIX, CLIENT_SUFFIX, DOMAIN -from .coordinator import SnapcastUpdateCoordinator +from .coordinator import SnapcastConfigEntry, SnapcastUpdateCoordinator from .entity import SnapcastCoordinatorEntity STREAM_STATUS = { @@ -38,13 +35,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SnapcastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - # Fetch coordinator from global data - coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data _known_client_ids: set[str] = set() @@ -253,7 +249,7 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): @property def group_members(self) -> list[str] | None: - """List of player entities which are currently grouped together for synchronous playback.""" + """List of players currently grouped for synchronous playback.""" if self._current_group is None: return None @@ -302,7 +298,8 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): # Validate client belongs to the same server if not client.unique_id.startswith(unique_id_prefix): raise ServiceValidationError( - f"Entity '{client.entity_id}' does not belong to the same Snapcast server." + f"Entity '{client.entity_id}' does not belong" + " to the same Snapcast server." ) # Extract client ID and join it to the current group @@ -311,7 +308,8 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): await self._current_group.add_client(identifier) except KeyError as e: raise ServiceValidationError( - f"Client with identifier '{identifier}' does not exist on the server." + f"Client with identifier '{identifier}'" + " does not exist on the server." ) from e self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/services.py b/homeassistant/components/snapcast/services.py index 6e2e1d60a21..89fc4e07d65 100644 --- a/homeassistant/components/snapcast/services.py +++ b/homeassistant/components/snapcast/services.py @@ -1,7 +1,5 @@ """Support for interacting with Snapcast clients.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 1f94a1c4fae..40b294eed2d 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -1,7 +1,5 @@ """Support for fetching WiFi associations through SNMP.""" -from __future__ import annotations - import binascii import logging from typing import TYPE_CHECKING @@ -104,7 +102,7 @@ class SnmpScanner(DeviceScanner): @classmethod async def create(cls, config): - """Asynchronously test the target device before fully initializing the scanner.""" + """Test the target device before fully initializing.""" host = config[CONF_HOST] try: @@ -127,7 +125,7 @@ class SnmpScanner(DeviceScanner): return instance async def async_init(self, hass: HomeAssistant) -> None: - """Make a one-off read to check if the target device is reachable and readable.""" + """Check if the target device is reachable and readable.""" self.request_args = await async_create_request_cmd_args( hass, self._auth_data, @@ -148,7 +146,7 @@ class SnmpScanner(DeviceScanner): return None async def async_get_extra_attributes(self, device: str) -> dict: - """Return the extra attributes of the given device or an empty dictionary if we have none.""" + """Return extra attributes of the given device.""" for client in self.last_results: if client.get("mac") and device == client["mac"]: return {"mac": client["mac"]} diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 46e0dc83050..c33e5c4b7cd 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -1,7 +1,5 @@ """Support for displaying collected data over SNMP.""" -from __future__ import annotations - from datetime import timedelta import logging from struct import unpack diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 26fb7d5e99d..af70de813c1 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -1,7 +1,5 @@ """Support for SNMP enabled switch.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py index df0171b6610..e8df3f0ae26 100644 --- a/homeassistant/components/snmp/util.py +++ b/homeassistant/components/snmp/util.py @@ -1,7 +1,5 @@ """Support for displaying collected data over SNMP.""" -from __future__ import annotations - import logging from pysnmp.hlapi.v3arch.asyncio import ( diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index bf4dc07f96c..f25cecf66bb 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -1,7 +1,5 @@ """The Happiest Baby Snoo integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py index c4eaddcc1fe..c113f9e33c4 100644 --- a/homeassistant/components/snoo/binary_sensor.py +++ b/homeassistant/components/snoo/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Snoo Binary Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py index 986ef6a0071..c3beb7e3456 100644 --- a/homeassistant/components/snoo/config_flow.py +++ b/homeassistant/components/snoo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Happiest Baby Snoo integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py index 25f54344674..6d3bcb8aafc 100644 --- a/homeassistant/components/snoo/entity.py +++ b/homeassistant/components/snoo/entity.py @@ -1,7 +1,5 @@ """Base entity for the Snoo integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/snoo/select.py b/homeassistant/components/snoo/select.py index 44624ed1a2d..26e8d740716 100644 --- a/homeassistant/components/snoo/select.py +++ b/homeassistant/components/snoo/select.py @@ -1,7 +1,5 @@ """Support for Snoo Select.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py index e45b2b88592..8889a7407ea 100644 --- a/homeassistant/components/snoo/sensor.py +++ b/homeassistant/components/snoo/sensor.py @@ -1,7 +1,5 @@ """Support for Snoo Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/snoo/switch.py b/homeassistant/components/snoo/switch.py index 2ed322d5f6b..ced180e7cf8 100644 --- a/homeassistant/components/snoo/switch.py +++ b/homeassistant/components/snoo/switch.py @@ -1,7 +1,5 @@ """Support for Snoo Switches.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -82,6 +80,7 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity): True, ) except SnooCommandException as err: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="switch_on_failed", @@ -98,6 +97,7 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity): False, ) except SnooCommandException as err: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise HomeAssistantError( translation_domain=DOMAIN, translation_key="switch_off_failed", diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index c97c89c2f4a..d485723844f 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -1,22 +1,23 @@ """The Snooz component.""" -from __future__ import annotations - import logging from pysnooz.device import SnoozDevice -from homeassistant.components.bluetooth import async_ble_device_from_address -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS -from .models import SnoozConfigurationData +from .models import SnoozConfigEntry, SnoozConfigurationData -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> bool: """Set up Snooz device from a config entry.""" address: str = entry.data[CONF_ADDRESS] token: str = entry.data[CONF_TOKEN] @@ -26,38 +27,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not (ble_device := async_ble_device_from_address(hass, address)): raise ConfigEntryNotReady( - f"Could not find Snooz with address {address}. Try power cycling the device" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) device = SnoozDevice(ble_device, token) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SnoozConfigurationData( - ble_device, device, entry.title - ) + entry.runtime_data = SnoozConfigurationData(ble_device, device, entry.title) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: SnoozConfigEntry) -> None: """Handle options update.""" - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - # also called by fan entities, but do it here too for good measure - await data.device.async_disconnect() - - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.config_entries.async_entries(DOMAIN): - hass.data.pop(DOMAIN) + await entry.runtime_data.device.async_disconnect() return unload_ok diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index 185e875065b..e2748446db3 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Snooz component.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from typing import Any @@ -9,6 +7,7 @@ from typing import Any from pysnooz.advertisement import SnoozAdvertisementData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfo, @@ -96,6 +95,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self._create_snooz_entry(discovered) + await bluetooth.async_request_active_scan(self.hass) configured_addresses = self._async_current_ids(include_ignore=False) for info in async_discovered_service_info(self.hass): @@ -116,6 +116,8 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME): vol.In( [ d.device.display_name diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index ce804450cab..495ca7d656c 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -1,7 +1,5 @@ """Fan representation of a Snooz device.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta from typing import Any @@ -17,7 +15,6 @@ from pysnooz.commands import ( import voluptuous as vol from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -34,12 +31,12 @@ from .const import ( SERVICE_TRANSITION_OFF, SERVICE_TRANSITION_ON, ) -from .models import SnoozConfigurationData +from .models import SnoozConfigEntry, SnoozConfigurationData async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SnoozConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Snooz device from a config entry.""" @@ -67,9 +64,7 @@ async def async_setup_entry( "async_transition_off", ) - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([SnoozFan(data)]) + async_add_entities([SnoozFan(entry.runtime_data)]) class SnoozFan(FanEntity, RestoreEntity): @@ -167,9 +162,9 @@ class SnoozFan(FanEntity, RestoreEntity): async def _async_execute_command(self, command: SnoozCommandData) -> None: result = await self._device.async_execute_command(command) - if result.status == SnoozCommandResultStatus.SUCCESSFUL: + if result.status is SnoozCommandResultStatus.SUCCESSFUL: self._async_write_state_changed() - elif result.status != SnoozCommandResultStatus.CANCELLED: + elif result.status is not SnoozCommandResultStatus.CANCELLED: raise HomeAssistantError( f"Command {command} failed with status {result.status.name} after" f" {result.duration}" diff --git a/homeassistant/components/snooz/models.py b/homeassistant/components/snooz/models.py index d1c49fe9dc6..0ac7cfd2d99 100644 --- a/homeassistant/components/snooz/models.py +++ b/homeassistant/components/snooz/models.py @@ -5,6 +5,10 @@ from dataclasses import dataclass from bleak.backends.device import BLEDevice from pysnooz.device import SnoozDevice +from homeassistant.config_entries import ConfigEntry + +type SnoozConfigEntry = ConfigEntry[SnoozConfigurationData] + @dataclass class SnoozConfigurationData: diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index cd956856441..70621c6eba0 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -24,6 +24,11 @@ } } }, + "exceptions": { + "device_not_found": { + "message": "Could not find Snooz with address {address}: {reason}" + } + }, "services": { "transition_off": { "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index 3c1048c4e22..442e30d66b2 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -1,7 +1,5 @@ """The SolarEdge integration.""" -from __future__ import annotations - import socket from aiohttp import ClientError diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py index 893728e7e1c..5a54d33a853 100644 --- a/homeassistant/components/solaredge/config_flow.py +++ b/homeassistant/components/solaredge/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the SolarEdge platform.""" -from __future__ import annotations - import socket from typing import TYPE_CHECKING, Any @@ -161,6 +159,8 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN): data_schema_dict: dict[vol.Marker, Any] = {} if self.source != SOURCE_RECONFIGURE: data_schema_dict[ + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)) ] = str data_schema_dict[ diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 35a14091e68..e2e141402fb 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -22,6 +22,7 @@ DETAILS_UPDATE_DELAY = timedelta(hours=12) INVENTORY_UPDATE_DELAY = timedelta(hours=12) POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) +STORAGE_DATA_UPDATE_DELAY = timedelta(hours=4) MODULE_STATISTICS_UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index ed3bff8cea2..a897b9c1b14 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -1,10 +1,8 @@ """Provides the data update coordinators for SolarEdge.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Iterable -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any from aiosolaredge import SolarEdge @@ -38,6 +36,7 @@ from .const import ( MODULE_STATISTICS_UPDATE_DELAY, OVERVIEW_UPDATE_DELAY, POWER_FLOW_UPDATE_DELAY, + STORAGE_DATA_UPDATE_DELAY, ) if TYPE_CHECKING: @@ -116,7 +115,8 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService): data = value self.data[key] = data - # Sanity check the energy values. SolarEdge API sometimes report "lifetimedata" of zero, + # Sanity check the energy values. SolarEdge API sometimes + # reports "lifetimedata" of zero, # while values for last Year, Month and Day energy are still OK. # See https://github.com/home-assistant/core/issues/59285 . if set(energy_keys).issubset(self.data.keys()): @@ -224,9 +224,8 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): async def async_update_data(self) -> None: """Update the data from the SolarEdge Monitoring API.""" try: - now = datetime.now() - today = date.today() - midnight = datetime.combine(today, datetime.min.time()) + now = dt_util.now() + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) data = await self.api.get_energy_details( self.site_id, midnight, @@ -322,18 +321,100 @@ class SolarEdgePowerFlowDataService(SolarEdgeDataService): export = key.lower() in power_to if self.data[key]: self.data[key] *= -1 if export else 1 - self.attributes[key]["flow"] = "export" if export else "import" + self.data["grid_flow_direction"] = "export" if export else "import" if key == "STORAGE": charge = key.lower() in power_to if self.data[key]: self.data[key] *= -1 if charge else 1 - self.attributes[key]["flow"] = "charge" if charge else "discharge" - self.attributes[key]["soc"] = value["chargeLevel"] + self.data["storage_flow_direction"] = ( + "charge" if charge else "discharge" + ) + self.data["storage_level"] = value["chargeLevel"] LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes) +class SolarEdgeStorageDataService(SolarEdgeDataService): + """Get and update the latest storage data.""" + + @property + def update_interval(self) -> timedelta: + """Update interval.""" + return STORAGE_DATA_UPDATE_DELAY + + async def async_update_data(self) -> None: + """Update the data from the SolarEdge Monitoring API.""" + now = dt_util.now() + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + data = await self.api.get_storage_data( + self.site_id, + start_of_day, + now, + ) + storage_data = data.get("storageData") + if storage_data is None: + raise UpdateFailed("Storage data not available from API") + + batteries = storage_data.get("batteries") + if batteries is None: + raise UpdateFailed("Battery data not available from API") + + self.data = {} + self.attributes = {} + + if not batteries: + LOGGER.debug("No batteries found in storage data") + return + + # Aggregate totals across all batteries + total_charge_energy = 0.0 + total_discharge_energy = 0.0 + + for battery in batteries: + serial = battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serialNumber") + continue + + telemetries = battery.get("telemetries", []) + + if not telemetries: + continue + + latest = telemetries[-1] + + # Per-battery current values + self.data[f"{serial}_state_of_charge"] = latest.get( + "batteryPercentageState" + ) + self.data[f"{serial}_power"] = latest.get("power") + + # Compute daily charge/discharge delta from lifetime counters + if len(telemetries) >= 2: + first = telemetries[0] + charge_energy = latest.get("lifeTimeEnergyCharged", 0.0) - first.get( + "lifeTimeEnergyCharged", 0.0 + ) + discharge_energy = latest.get( + "lifeTimeEnergyDischarged", 0.0 + ) - first.get("lifeTimeEnergyDischarged", 0.0) + else: + charge_energy = 0.0 + discharge_energy = 0.0 + + total_charge_energy += charge_energy + total_discharge_energy += discharge_energy + + self.data[f"{serial}_charge_energy"] = charge_energy + self.data[f"{serial}_discharge_energy"] = discharge_energy + + self.data["charge_energy"] = total_charge_energy + self.data["discharge_energy"] = total_discharge_energy + + LOGGER.debug("Updated SolarEdge storage data: %s", self.data) + + class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): """Handle fetching SolarEdge Modules data and inserting statistics.""" @@ -374,8 +455,9 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from API endpoint and update statistics.""" equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment() - # We fetch last week's data from the API and refresh every 12h so we overwrite recent - # statistics. This is intended to allow adding any corrected/updated data from the API. + # We fetch last week's data from the API and refresh + # every 12h so we overwrite recent statistics. This is + # intended to allow adding any corrected/updated data. energy_data_list: list[EnergyData] = await self.api.async_get_energy_data( TimeUnit.WEEK ) @@ -466,9 +548,10 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): if statistic_id in current_stats: statistic_sum = current_stats[statistic_id][0]["sum"] else: - # If no statistics found right before start_time, try to get the last statistic - # but use it only if it's before start_time. - # This is needed if the integration hasn't run successfully for at least a week. + # If no statistics found right before start_time, + # try to get the last statistic but use it only + # if it's before start_time. This is needed if + # the integration hasn't run for at least a week. last_stat = await get_instance(self.hass).async_add_executor_job( get_last_statistics, self.hass, 1, statistic_id, True, {"sum"} ) diff --git a/homeassistant/components/solaredge/icons.json b/homeassistant/components/solaredge/icons.json index 14a6ab561ba..4e8954356e2 100644 --- a/homeassistant/components/solaredge/icons.json +++ b/homeassistant/components/solaredge/icons.json @@ -13,6 +13,9 @@ "energy_today": { "default": "mdi:solar-power" }, + "grid_flow_direction": { + "default": "mdi:transmission-tower" + }, "grid_power": { "default": "mdi:power-plug" }, @@ -25,6 +28,9 @@ "solar_power": { "default": "mdi:solar-power" }, + "storage_flow_direction": { + "default": "mdi:battery-charging" + }, "storage_power": { "default": "mdi:car-battery" } diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be160..6b3c54ffd5f 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,7 +1,5 @@ """Support for SolarEdge Monitoring API.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -22,7 +20,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN +from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, LOGGER from .coordinator import ( SolarEdgeDataService, SolarEdgeDetailsDataService, @@ -30,6 +28,7 @@ from .coordinator import ( SolarEdgeInventoryDataService, SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, + SolarEdgeStorageDataService, ) from .types import SolarEdgeConfigEntry @@ -153,6 +152,22 @@ SENSOR_TYPES = [ state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), + SolarEdgeSensorEntityDescription( + key="grid_flow_direction", + json_key="grid_flow_direction", + translation_key="grid_flow_direction", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["export", "import"], + ), + SolarEdgeSensorEntityDescription( + key="storage_flow_direction", + json_key="storage_flow_direction", + translation_key="storage_flow_direction", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["charge", "discharge"], + ), SolarEdgeSensorEntityDescription( key="purchased_energy", json_key="Purchased", @@ -200,13 +215,71 @@ SENSOR_TYPES = [ ), SolarEdgeSensorEntityDescription( key="storage_level", - json_key="STORAGE", + json_key="storage_level", translation_key="storage_level", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + SolarEdgeSensorEntityDescription( + key="storage_charge_energy", + json_key="charge_energy", + translation_key="storage_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="storage_discharge_energy", + json_key="discharge_energy", + translation_key="storage_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), +] + +# Per-battery sensor descriptions, created dynamically per serial number +BATTERY_SENSOR_TYPES = [ + SolarEdgeSensorEntityDescription( + key="battery_charge_energy", + json_key="charge_energy", + translation_key="battery_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_discharge_energy", + json_key="discharge_energy", + translation_key="battery_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_state_of_charge", + json_key="state_of_charge", + translation_key="battery_state_of_charge", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + SolarEdgeSensorEntityDescription( + key="battery_power", + json_key="power", + translation_key="battery_power", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), ] @@ -222,15 +295,43 @@ async def async_setup_entry( api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) + + # Set up and refresh base services first for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() - entities = [] + entities: list[SolarEdgeSensorEntity] = [] + + # Set up storage sensors only if inventory shows batteries are present + storage_result = sensor_factory.setup_storage_sensors() + if storage_result is not None: + if storage_result: + await sensor_factory.storage_service.coordinator.async_refresh() + entities.extend(storage_result) + else: + # Inventory fetch failed, register listener to retry when data arrives + def on_inventory_update() -> None: + """Handle inventory update to set up storage sensors.""" + result = sensor_factory.setup_storage_sensors() + if result is not None: + if result: + hass.async_create_task( + sensor_factory.storage_service.coordinator.async_refresh() + ) + async_add_entities(result) + # Success or confirmed no batteries - stop listening + unsub() + + unsub = sensor_factory.inventory_service.coordinator.async_add_listener( + on_inventory_update + ) + entry.async_on_unload(unsub) + for sensor_type in SENSOR_TYPES: - sensor = sensor_factory.create_sensor(sensor_type) - if sensor is not None: - entities.append(sensor) + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy"): + continue + entities.append(sensor_factory.create_sensor(sensor_type)) async_add_entities(entities) @@ -251,8 +352,17 @@ class SolarEdgeSensorFactory: inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) + storage = SolarEdgeStorageDataService(hass, config_entry, api, site_id) - self.all_services = (details, overview, inventory, flow, energy) + self.all_services: list[SolarEdgeDataService] = [ + details, + overview, + inventory, + flow, + energy, + ] + self.inventory_service = inventory + self.storage_service = storage self.services: dict[ str, @@ -277,8 +387,8 @@ class SolarEdgeSensorFactory: for key in ("power_consumption", "solar_power", "grid_power", "storage_power"): self.services[key] = (SolarEdgePowerFlowSensor, flow) - for key in ("storage_level",): - self.services[key] = (SolarEdgeStorageLevelSensor, flow) + for key in ("storage_level", "grid_flow_direction", "storage_flow_direction"): + self.services[key] = (SolarEdgeOverviewSensor, flow) for key in ( "purchased_energy", @@ -289,6 +399,56 @@ class SolarEdgeSensorFactory: ): self.services[key] = (SolarEdgeEnergyDetailsSensor, energy) + def setup_storage_sensors( + self, + ) -> list[SolarEdgeSensorEntity] | None: + """Set up storage sensors if batteries are available. + + Returns: + list: Storage sensor entities to add (empty if no batteries) + None: Inventory fetch failed, should retry later + """ + # Check if inventory data was successfully fetched + if not self.inventory_service.coordinator.last_update_success: + LOGGER.debug("Inventory data not available, will retry later") + return None + + battery_attr = self.inventory_service.attributes.get("batteries", {}) + inventory_batteries = battery_attr.get("batteries", []) + if not inventory_batteries: + LOGGER.debug("No batteries found in inventory, skipping storage sensors") + return [] + + # Set up storage service and add to services + self.storage_service.async_setup() + self.all_services.append(self.storage_service) + + for key in ("storage_charge_energy", "storage_discharge_energy"): + self.services[key] = (SolarEdgeStorageDataSensor, self.storage_service) + + # Create aggregate storage sensors + storage_entities: list[SolarEdgeSensorEntity] = [ + self.create_sensor(sensor_type) + for sensor_type in SENSOR_TYPES + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy") + ] + + # Create per-battery entities + for battery in inventory_batteries: + serial = battery.get("SN") or battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serial number in inventory") + continue + storage_entities.extend( + SolarEdgeBatterySensor(sensor_type, self.storage_service, serial) + for sensor_type in BATTERY_SENSOR_TYPES + ) + + LOGGER.debug( + "Storage sensors enabled, found %d batteries", len(inventory_batteries) + ) + return storage_entities + def create_sensor( self, sensor_type: SolarEdgeSensorEntityDescription ) -> SolarEdgeSensorEntity: @@ -316,17 +476,11 @@ class SolarEdgeSensorEntity( super().__init__(data_service.coordinator) self.entity_description = description self.data_service = data_service + self._attr_unique_id = f"{data_service.site_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge" ) - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - if not self.data_service.site_id: - return None - return f"{self.data_service.site_id}_{self.entity_description.key}" - class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API overview sensor.""" @@ -422,15 +576,39 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): return self.data_service.data.get(self.entity_description.json_key) -class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): - """Representation of an SolarEdge Monitoring API storage level sensor.""" - - _attr_device_class = SensorDeviceClass.BATTERY +class SolarEdgeStorageDataSensor(SolarEdgeSensorEntity): + """Representation of an SolarEdge aggregate storage data sensor.""" @property - def native_value(self) -> str | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" - attr = self.data_service.attributes.get(self.entity_description.json_key) - if attr and "soc" in attr: - return attr["soc"] - return None + return self.data_service.data.get(self.entity_description.json_key) + + +class SolarEdgeBatterySensor(SolarEdgeSensorEntity): + """Representation of a per-battery SolarEdge sensor.""" + + def __init__( + self, + description: SolarEdgeSensorEntityDescription, + data_service: SolarEdgeStorageDataService, + serial: str, + ) -> None: + """Initialize the per-battery sensor.""" + super().__init__(description, data_service) + self._serial = serial + self._attr_unique_id = f"{data_service.site_id}_{serial}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{data_service.site_id}_{serial}")}, + manufacturer="SolarEdge", + name=f"Battery {serial}", + serial_number=serial, + via_device=(DOMAIN, data_service.site_id), + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.data_service.data.get( + f"{self._serial}_{self.entity_description.json_key}" + ) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2dd02f70ade..c50f3b5a874 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -85,6 +85,18 @@ "batteries": { "name": "Batteries" }, + "battery_charge_energy": { + "name": "Charge energy today" + }, + "battery_discharge_energy": { + "name": "Discharge energy today" + }, + "battery_power": { + "name": "Power" + }, + "battery_state_of_charge": { + "name": "State of charge" + }, "consumption_energy": { "name": "Consumed energy" }, @@ -106,6 +118,13 @@ "gateways": { "name": "Gateways" }, + "grid_flow_direction": { + "name": "Grid flow direction", + "state": { + "export": "Export", + "import": "Import" + } + }, "grid_power": { "name": "Grid power" }, @@ -139,6 +158,19 @@ "solar_power": { "name": "Solar power" }, + "storage_charge_energy": { + "name": "Storage charge energy today" + }, + "storage_discharge_energy": { + "name": "Storage discharge energy today" + }, + "storage_flow_direction": { + "name": "Storage flow direction", + "state": { + "charge": "Charge", + "discharge": "Discharge" + } + }, "storage_level": { "name": "Storage level" }, diff --git a/homeassistant/components/solaredge/types.py b/homeassistant/components/solaredge/types.py index 33192763acc..6b85aea22cf 100644 --- a/homeassistant/components/solaredge/types.py +++ b/homeassistant/components/solaredge/types.py @@ -1,7 +1,5 @@ """Typing for the SolarEdge Monitoring API.""" -from __future__ import annotations - from typing import TypedDict from aiosolaredge import SolarEdge diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index f362a5e029f..cde9f6ac271 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -1,7 +1,5 @@ """Support for SolarEdge-local Monitoring API.""" -from __future__ import annotations - from contextlib import suppress import dataclasses from datetime import timedelta diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index 510d5409f5a..b1858f7d069 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_HAS_PWD +from .const import CONF_HAS_PWD, DEFAULT_TIMEOUT from .coordinator import ( SolarLogBasicDataCoordinator, SolarlogConfigEntry, @@ -47,34 +47,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> basic_coordinator = SolarLogBasicDataCoordinator(hass, entry, solarlog) - solarLogData = SolarlogIntegrationData( + solar_log_data = SolarlogIntegrationData( api=solarlog, basic_data_coordinator=basic_coordinator, ) await basic_coordinator.async_config_entry_first_refresh() - entry.runtime_data = solarLogData + entry.runtime_data = solar_log_data - if basic_coordinator.solarlog.extended_data: - timeout = entry.data.get(CONF_TIMEOUT, 0) - if timeout <= 150: - # Increase timeout for next try, skip setup of LongtimeDataCoordinator, - # if timeout was not the issue (assumed when timeout > 150) - timeout = timeout + 30 - new = {**entry.data} - new[CONF_TIMEOUT] = timeout - hass.config_entries.async_update_entry(entry, data=new) + _LOGGER.debug( + "Basic coordinator setup successful, extended data available: %s", + solar_log_data.api.extended_data, + ) - entry.runtime_data.longtime_data_coordinator = ( - SolarLogLongtimeDataCoordinator(hass, entry, solarlog, timeout) - ) - await entry.runtime_data.longtime_data_coordinator.async_config_entry_first_refresh() + if solar_log_data.api.extended_data: + timeout = entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - entry.runtime_data.device_data_coordinator = SolarLogDeviceDataCoordinator( - hass, entry, solarlog + _LOGGER.debug("Setup of LongtimeDataCoordinator, saved timeout is %s", timeout) + + entry.runtime_data.longtime_data_coordinator = SolarLogLongtimeDataCoordinator( + hass, entry, solarlog, timeout ) - await entry.runtime_data.device_data_coordinator.async_config_entry_first_refresh() + await entry.runtime_data.longtime_data_coordinator.async_config_entry_first_refresh() + + _LOGGER.debug("Setup of DeviceDataCoordinator") + + device_coordinator = SolarLogDeviceDataCoordinator(hass, entry, solarlog) + entry.runtime_data.device_data_coordinator = device_coordinator + await device_coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 767079ea1f8..1980f30c087 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -13,9 +13,9 @@ from solarlog_cli.solarlog_exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT -from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN +from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_TIMEOUT, DOMAIN class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): @@ -137,6 +137,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): if not user_input[CONF_HAS_PWD] or user_input.get(CONF_PASSWORD, "") == "": user_input[CONF_PASSWORD] = "" user_input[CONF_HAS_PWD] = False + user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT return self.async_update_reload_and_abort( reconfigure_entry, data_updates=user_input ) @@ -145,6 +146,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): reconfigure_entry.data[CONF_HOST], user_input.get(CONF_PASSWORD, "") ): # if password has been provided, only save if extended data is available + user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT return self.async_update_reload_and_abort( reconfigure_entry, data_updates=user_input, diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 3e814705589..b033b720bc9 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,10 +1,9 @@ """Constants for the Solar-Log integration.""" -from __future__ import annotations - DOMAIN = "solarlog" # Default config for solarlog. DEFAULT_HOST = "http://solar-log" +DEFAULT_TIMEOUT = 30 CONF_HAS_PWD = "has_password" diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index cc3028a3e7c..96ddd148a39 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for solarlog integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging @@ -15,6 +13,7 @@ from solarlog_cli.solarlog_exceptions import ( from solarlog_cli.solarlog_models import EnergyData, InverterData, SolarlogData from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -76,7 +75,8 @@ class SolarLogBasicDataCoordinator(DataUpdateCoordinator[SolarlogData]): ) from ex except SolarLogAuthenticationError as ex: if await self.renew_authentication(): - # login was successful, update availability of extended data, retry data update + # login was successful, update availability + # of extended data, retry data update await self.solarlog.test_extended_data_available() raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -87,6 +87,7 @@ class SolarLogBasicDataCoordinator(DataUpdateCoordinator[SolarlogData]): translation_key="auth_failed", ) from ex except SolarLogUpdateError as ex: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -154,6 +155,7 @@ class SolarLogDeviceDataCoordinator(DataUpdateCoordinator[dict[int, InverterData translation_key="auth_failed", ) from ex except (SolarLogConnectionError, SolarLogUpdateError) as ex: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -236,6 +238,37 @@ class SolarLogLongtimeDataCoordinator(DataUpdateCoordinator[EnergyData]): self.solarlog = api self.connection_timeout = timeout + async def _async_setup(self) -> None: + """Do initialization logic.""" + _LOGGER.debug("Start SolarLogLongtimeDataCoordinator async_setup") + + try: + await self.solarlog.update_energy_data(timeout=self.connection_timeout) + except SolarLogAuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex + except (SolarLogConnectionError, SolarLogUpdateError) as ex: + if ( + isinstance(ex.__cause__, TimeoutError) + and self.connection_timeout <= 150 + ): + # Increase timeout for next try + self.connection_timeout = self.connection_timeout + 30 + _LOGGER.debug( + "Connection failed, increased timeout to %s for next try", + self.connection_timeout, + ) + new = {**self.config_entry.data} + new[CONF_TIMEOUT] = self.connection_timeout + self.hass.config_entries.async_update_entry(self.config_entry, data=new) + + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from ex + async def _async_update_data(self) -> EnergyData: """Update the energy data from the SolarLog device.""" _LOGGER.debug( @@ -252,6 +285,7 @@ class SolarLogLongtimeDataCoordinator(DataUpdateCoordinator[EnergyData]): translation_key="auth_failed", ) from ex except (SolarLogConnectionError, SolarLogUpdateError) as ex: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -260,7 +294,9 @@ class SolarLogLongtimeDataCoordinator(DataUpdateCoordinator[EnergyData]): if energy_data is None: energy_data = EnergyData(None, None) - self.config_entry.runtime_data.basic_data_coordinator.data.self_consumption_year = energy_data.self_consumption + ( + self.config_entry.runtime_data.basic_data_coordinator.data.self_consumption_year + ) = energy_data.self_consumption _LOGGER.debug("Energy data successfully updated") diff --git a/homeassistant/components/solarlog/diagnostics.py b/homeassistant/components/solarlog/diagnostics.py index 025f88b2ba6..a48651f0a3c 100644 --- a/homeassistant/components/solarlog/diagnostics.py +++ b/homeassistant/components/solarlog/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Solarlog.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py index c6840dbc485..e0ed33a7057 100644 --- a/homeassistant/components/solarlog/entity.py +++ b/homeassistant/components/solarlog/entity.py @@ -1,7 +1,5 @@ """Entities for SolarLog integration.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -52,7 +50,8 @@ class SolarLogInverterEntity(CoordinatorEntity[SolarLogDeviceDataCoordinator]): ) -> None: """Initialize the SolarLogInverter sensor.""" super().__init__(coordinator) - name = f"{coordinator.config_entry.entry_id}_{slugify(coordinator.solarlog.device_name(device_id))}" + device_name = coordinator.solarlog.device_name(device_id) + name = f"{coordinator.config_entry.entry_id}_{slugify(device_name)}" self._attr_unique_id = f"{name}_{description.key}" self._attr_device_info = DeviceInfo( manufacturer="Solar-Log", diff --git a/homeassistant/components/solarlog/models.py b/homeassistant/components/solarlog/models.py index e259d899356..3b98760a001 100644 --- a/homeassistant/components/solarlog/models.py +++ b/homeassistant/components/solarlog/models.py @@ -1,7 +1,5 @@ """The SolarLog integration models.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 7931f1aba90..c995a611e36 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,7 +1,5 @@ """Platform for solarlog sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -263,8 +261,9 @@ SOLARLOG_BASIC_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, . ), ) -"""SOLARLOG_LONGTIME_SENSOR_TYPES represent data points that may require longer timeout and -therefore are retrieved with different DataUpdateCoordinator.""" +"""SOLARLOG_LONGTIME_SENSOR_TYPES represent data points that +may require longer timeout and therefore are retrieved with +different DataUpdateCoordinator.""" SOLARLOG_LONGTIME_SENSOR_TYPES: tuple[SolarLogLongtimeSensorEntityDescription, ...] = ( SolarLogLongtimeSensorEntityDescription( key="self_consumption_year", @@ -336,39 +335,43 @@ async def async_setup_entry( ) -> None: """Add solarlog entry.""" - solarLogIntegrationData: SolarlogIntegrationData = entry.runtime_data + solar_log_integration_data: SolarlogIntegrationData = entry.runtime_data entities: list[SensorEntity] = [ SolarLogBasicCoordinatorSensor( - solarLogIntegrationData.basic_data_coordinator, sensor + solar_log_integration_data.basic_data_coordinator, sensor ) for sensor in SOLARLOG_BASIC_SENSOR_TYPES ] - if solarLogIntegrationData.longtime_data_coordinator is not None: + if solar_log_integration_data.longtime_data_coordinator is not None: entities.extend( SolarLogLongtimeCoordinatorSensor( - solarLogIntegrationData.longtime_data_coordinator, sensor + solar_log_integration_data.longtime_data_coordinator, sensor ) for sensor in SOLARLOG_LONGTIME_SENSOR_TYPES ) - # add battery sensors only if respective data is available (otherwise no battery attached to solarlog) - if solarLogIntegrationData.basic_data_coordinator.data.battery_data is not None: + # add battery sensors only if respective data is + # available (otherwise no battery attached to solarlog) + if ( + solar_log_integration_data.basic_data_coordinator.data.battery_data + is not None + ): entities.extend( SolarLogBatterySensor( - solarLogIntegrationData.basic_data_coordinator, sensor + solar_log_integration_data.basic_data_coordinator, sensor ) for sensor in SOLARLOG_BATTERY_SENSOR_TYPES ) - if solarLogIntegrationData.device_data_coordinator is not None: - device_data = solarLogIntegrationData.device_data_coordinator.data + if solar_log_integration_data.device_data_coordinator is not None: + device_data = solar_log_integration_data.device_data_coordinator.data if device_data: entities.extend( SolarLogInverterSensor( - solarLogIntegrationData.device_data_coordinator, + solar_log_integration_data.device_data_coordinator, sensor, device_id, ) @@ -379,15 +382,15 @@ async def async_setup_entry( def _async_add_new_device(device_id: int) -> None: async_add_entities( SolarLogInverterSensor( - solarLogIntegrationData.device_data_coordinator, + solar_log_integration_data.device_data_coordinator, sensor, device_id, ) for sensor in SOLARLOG_INVERTER_SENSOR_TYPES - if solarLogIntegrationData.device_data_coordinator is not None + if solar_log_integration_data.device_data_coordinator is not None ) - solarLogIntegrationData.device_data_coordinator.new_device_callbacks.append( + solar_log_integration_data.device_data_coordinator.new_device_callbacks.append( _async_add_new_device ) @@ -428,10 +431,10 @@ class SolarLogBatterySensor(SolarLogBasicCoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - if ( - battery_data - := self.coordinator.config_entry.runtime_data.basic_data_coordinator.data.battery_data - ) is None: + basic_data = ( + self.coordinator.config_entry.runtime_data.basic_data_coordinator.data + ) + if (battery_data := basic_data.battery_data) is None: return None return self.entity_description.value_fn(battery_data) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index ba66e5988d3..e038fbdc530 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -145,7 +145,7 @@ "config_entry_not_ready": { "message": "Error while loading the config entry." }, - "update_error": { + "update_failed": { "message": "Error while updating data from the API." } } diff --git a/homeassistant/components/solarman/__init__.py b/homeassistant/components/solarman/__init__.py index d9054ee8753..3c4ea52c809 100644 --- a/homeassistant/components/solarman/__init__.py +++ b/homeassistant/components/solarman/__init__.py @@ -1,7 +1,5 @@ """Home Assistant integration for SOLARMAN devices.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/solarman/coordinator.py b/homeassistant/components/solarman/coordinator.py index 77dbbd80e45..e5a5c0dc338 100644 --- a/homeassistant/components/solarman/coordinator.py +++ b/homeassistant/components/solarman/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for solarman integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/solarman/entity.py b/homeassistant/components/solarman/entity.py index 0a0920a75b1..838d0e5b1f1 100644 --- a/homeassistant/components/solarman/entity.py +++ b/homeassistant/components/solarman/entity.py @@ -1,7 +1,5 @@ """Base entity for the Solarman integration.""" -from __future__ import annotations - from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index 5a6ee0b1fca..8b9a497742b 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -1,7 +1,5 @@ """Config flow for solax integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 8e9c16a8e6d..8538d9a2fa5 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -1,7 +1,5 @@ """Support for Solax inverter via local API.""" -from __future__ import annotations - from solax.units import Units from homeassistant.components.sensor import ( diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 127b51338ee..0b4cb11ac13 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -1,6 +1,7 @@ """Support for Soma Smartshades.""" -from __future__ import annotations +from dataclasses import dataclass +from typing import Any from api.soma_api import SomaApi import voluptuous as vol @@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import API, DEVICES, DOMAIN, HOST, PORT +from .const import DOMAIN, HOST, PORT CONFIG_SCHEMA = vol.Schema( vol.All( @@ -26,6 +27,17 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) + +@dataclass +class SomaData: + """Runtime data for the Soma integration.""" + + api: SomaApi + devices: list[dict[str, Any]] + + +type SomaConfigEntry = ConfigEntry[SomaData] + PLATFORMS = [Platform.COVER, Platform.SENSOR] @@ -45,18 +57,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SomaConfigEntry) -> bool: """Set up Soma from a config entry.""" - hass.data[DOMAIN] = {} - api = await hass.async_add_executor_job(SomaApi, entry.data[HOST], entry.data[PORT]) - devices = await hass.async_add_executor_job(api.list_devices) - hass.data[DOMAIN] = {API: api, DEVICES: devices["shades"]} + + def _setup_api() -> tuple[SomaApi, dict[str, Any]]: + api = SomaApi(entry.data[HOST], entry.data[PORT]) + return api, api.list_devices() + + api, devices = await hass.async_add_executor_job(_setup_api) + entry.runtime_data = SomaData(api, devices["shades"]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SomaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/soma/const.py b/homeassistant/components/soma/const.py index b34596abe93..20f5d60b2c4 100644 --- a/homeassistant/components/soma/const.py +++ b/homeassistant/components/soma/const.py @@ -3,6 +3,3 @@ DOMAIN = "soma" HOST = "host" PORT = "port" -API = "api" - -DEVICES = "devices" diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 15aa21b1f48..6b92c64ed69 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -1,7 +1,5 @@ """Support for Soma Covers.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( @@ -11,29 +9,29 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API, DEVICES, DOMAIN +from . import SomaConfigEntry from .entity import SomaEntity from .utils import is_api_response_success async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma cover platform.""" - api = hass.data[DOMAIN][API] - devices = hass.data[DOMAIN][DEVICES] + data = config_entry.runtime_data + api = data.api entities: list[SomaTilt | SomaShade] = [] - for device in devices: - # Assume a shade device if the type is not present in the api response (Connect <2.2.6) + for device in data.devices: + # Assume a shade device if the type is not present + # in the api response (Connect <2.2.6) if "type" in device and device["type"].lower() == "tilt": entities.append(SomaTilt(device, api)) else: diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py index 4b2fcee5405..08e7f12228e 100644 --- a/homeassistant/components/soma/entity.py +++ b/homeassistant/components/soma/entity.py @@ -1,7 +1,5 @@ """Support for Soma Smartshades.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 839f28e9a65..b992d1f8b1d 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -3,13 +3,12 @@ from datetime import timedelta from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle -from .const import API, DEVICES, DOMAIN +from . import SomaConfigEntry from .entity import SomaEntity MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) @@ -17,16 +16,14 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma sensor platform.""" - devices = hass.data[DOMAIN][DEVICES] + data = config_entry.runtime_data - async_add_entities( - [SomaSensor(sensor, hass.data[DOMAIN][API]) for sensor in devices], True - ) + async_add_entities([SomaSensor(sensor, data.api) for sensor in data.devices], True) class SomaSensor(SomaEntity, SensorEntity): diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index fdbaaf9f427..4e7028ec6c9 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -1,6 +1,8 @@ """Component for the Somfy MyLink device supporting the Synergy API.""" +from dataclasses import dataclass import logging +from typing import Any from somfy_mylink_synergy import SomfyMyLinkSynergy @@ -9,15 +11,23 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS +from .const import CONF_SYSTEM_ID, PLATFORMS _LOGGER = logging.getLogger(__name__) +type SomfyMyLinkConfigEntry = ConfigEntry[SomfyMyLinkRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class SomfyMyLinkRuntimeData: + """Runtime data for Somfy MyLink.""" + + somfy_mylink: SomfyMyLinkSynergy + mylink_status: dict[str, Any] + + +async def async_setup_entry(hass: HomeAssistant, entry: SomfyMyLinkConfigEntry) -> bool: """Set up Somfy MyLink from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - config = entry.data somfy_mylink = SomfyMyLinkSynergy( config[CONF_SYSTEM_ID], config[CONF_HOST], config[CONF_PORT] @@ -42,18 +52,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - hass.data[DOMAIN][entry.entry_id] = { - DATA_SOMFY_MYLINK: somfy_mylink, - MYLINK_STATUS: mylink_status, - } + entry.runtime_data = SomfyMyLinkRuntimeData( + somfy_mylink=somfy_mylink, + mylink_status=mylink_status, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SomfyMyLinkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 91cfae87347..0c519905486 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Somfy MyLink integration.""" -from __future__ import annotations - from copy import deepcopy import logging from typing import Any @@ -10,7 +8,6 @@ from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -22,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import SomfyMyLinkConfigEntry from .const import ( CONF_REVERSE, CONF_REVERSED_TARGET_IDS, @@ -30,7 +28,6 @@ from .const import ( CONF_TARGET_NAME, DEFAULT_PORT, DOMAIN, - MYLINK_STATUS, ) _LOGGER = logging.getLogger(__name__) @@ -119,7 +116,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SomfyMyLinkConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -128,7 +125,9 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: SomfyMyLinkConfigEntry + + def __init__(self, config_entry: SomfyMyLinkConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @@ -136,9 +135,7 @@ class OptionsFlowHandler(OptionsFlowWithReload): @callback def _async_callback_targets(self): """Return the list of targets.""" - return self.hass.data[DOMAIN][self.config_entry.entry_id][MYLINK_STATUS][ - "result" - ] + return self.config_entry.runtime_data.mylink_status["result"] @callback def _async_get_target_name(self, target_id) -> str: diff --git a/homeassistant/components/somfy_mylink/const.py b/homeassistant/components/somfy_mylink/const.py index 8669c73fb9b..a4740ba4b55 100644 --- a/homeassistant/components/somfy_mylink/const.py +++ b/homeassistant/components/somfy_mylink/const.py @@ -10,8 +10,6 @@ CONF_TARGET_ID = "target_id" DEFAULT_PORT = 44100 -DATA_SOMFY_MYLINK = "somfy_mylink_data" -MYLINK_STATUS = "mylink_status" DOMAIN = "somfy_mylink" PLATFORMS = [Platform.COVER] diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 5b888ea4b96..e731bbac698 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -4,19 +4,13 @@ import logging from typing import Any from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import ( - CONF_REVERSED_TARGET_IDS, - DATA_SOMFY_MYLINK, - DOMAIN, - MANUFACTURER, - MYLINK_STATUS, -) +from . import SomfyMyLinkConfigEntry +from .const import CONF_REVERSED_TARGET_IDS, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -28,15 +22,14 @@ MYLINK_COVER_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomfyMyLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover and configure Somfy covers.""" reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {}) - data = hass.data[DOMAIN][config_entry.entry_id] - mylink_status = data[MYLINK_STATUS] - somfy_mylink = data[DATA_SOMFY_MYLINK] + mylink_status = config_entry.runtime_data.mylink_status + somfy_mylink = config_entry.runtime_data.somfy_mylink cover_list = [] for cover in mylink_status["result"]: diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 6d561dd9f22..0cc1afc4ff5 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -1,7 +1,5 @@ """The Sonarr component.""" -from __future__ import annotations - from dataclasses import fields from aiopyarr.models.host_configuration import PyArrHostConfiguration diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 278d3fbd7bb..881b3813b09 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Sonarr.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -21,9 +19,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + CONF_MORE_OPTIONS, CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DEFAULT_UPCOMING_DAYS, @@ -93,6 +93,11 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) + user_input[CONF_VERIFY_SSL] = more_options.get( + CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL + ) + # aiopyarr defaults to the service port if one isn't given # this is counter to standard practice where http = 80 # and https = 443. @@ -103,9 +108,6 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_REAUTH: user_input = {**self._get_reauth_entry().data, **user_input} - if CONF_VERIFY_SSL not in user_input: - user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL - try: await _validate_input(self.hass, user_input) except ArrAuthenticationException: @@ -127,29 +129,33 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): title=parsed.host or "Sonarr", data=user_input ) - data_schema = self._get_user_data_schema() return self.async_show_form( step_id="user", - data_schema=vol.Schema(data_schema), + data_schema=self._get_user_data_schema(), errors=errors, ) - def _get_user_data_schema(self) -> dict[vol.Marker, type]: + def _get_user_data_schema(self) -> vol.Schema: """Get the data schema to display user form.""" if self.source == SOURCE_REAUTH: - return {vol.Required(CONF_API_KEY): str} + return vol.Schema({vol.Required(CONF_API_KEY): str}) - data_schema: dict[vol.Marker, type] = { - vol.Required(CONF_URL): str, - vol.Required(CONF_API_KEY): str, - } - - if self.show_advanced_options: - data_schema[vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL)] = ( - bool - ) - - return data_schema + return vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): bool, + } + ), + SectionConfig(collapsed=True), + ), + } + ) class SonarrOptionsFlowHandler(OptionsFlowWithReload): diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py index ef850117046..0a4f28bcc8c 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -9,6 +9,7 @@ DOMAIN: Final = "sonarr" CONF_BASE_PATH = "base_path" CONF_DAYS = "days" CONF_INCLUDED = "include_paths" +CONF_MORE_OPTIONS = "more_options" CONF_UNIT = "unit" CONF_UPCOMING_DAYS = "upcoming_days" CONF_WANTED_MAX_ITEMS = "wanted_max_items" diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 3e50527f285..5bea999821d 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Sonarr integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from typing import TypeVar, cast diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 7dc0d0ca147..3ea95e307ba 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -1,7 +1,5 @@ """Base Entity for Sonarr.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/sonarr/helpers.py b/homeassistant/components/sonarr/helpers.py index e0943139ef4..4b553dfdc63 100644 --- a/homeassistant/components/sonarr/helpers.py +++ b/homeassistant/components/sonarr/helpers.py @@ -46,7 +46,8 @@ def format_queue_item(item: Any, base_url: str | None = None) -> dict[str, Any]: if episode := getattr(item, "episode", None): result["episode_number"] = getattr(episode, "episodeNumber", None) result["episode_title"] = getattr(episode, "title", None) - # Add formatted identifier like the sensor uses (if we have both season and episode) + # Add formatted identifier like the sensor uses + # (if we have both season and episode) if result["season_number"] is not None and result["episode_number"] is not None: result["episode_identifier"] = ( f"S{result['season_number']:02d}E{result['episode_number']:02d}" @@ -197,7 +198,8 @@ def format_diskspace( Args: disks: List of disk space objects from Sonarr. - space_unit: Unit for space values (bytes, kb, kib, mb, mib, gb, gib, tb, tib, pb, pib). + space_unit: Unit for space values + (bytes, kb, kib, mb, mib, gb, gib, tb, tib, pb, pib). Returns: Dictionary of disk information keyed by path. @@ -244,7 +246,9 @@ def format_upcoming_item( "series_id": episode.seriesId, "season_number": episode.seasonNumber, "episode_number": episode.episodeNumber, - "episode_identifier": f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}", + "episode_identifier": ( + f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" + ), "title": episode.title, "air_date": str(getattr(episode, "airDate", None)), "air_date_utc": str(getattr(episode, "airDateUtc", None)), @@ -341,7 +345,9 @@ def format_episode(episode: SonarrEpisode) -> dict[str, Any]: "tvdb_id": getattr(episode, "tvdbId", None), "season_number": episode.seasonNumber, "episode_number": episode.episodeNumber, - "episode_identifier": f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}", + "episode_identifier": ( + f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" + ), "title": episode.title, "air_date": str(getattr(episode, "airDate", None)), "air_date_utc": str(getattr(episode, "airDateUtc", None)), diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 74e172580ef..f796c6126a6 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -1,7 +1,5 @@ """Support for Sonarr sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic @@ -31,7 +29,7 @@ from .entity import SonarrEntity @dataclass(frozen=True) -class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): +class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): # noqa: UP046 """Mixin for Sonarr sensor.""" attributes_fn: Callable[[SonarrDataT], dict[str, str]] diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 0316e034d70..ef2c6388354 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -18,8 +18,15 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "url": "[%key:common::config_flow::data::url%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + "url": "[%key:common::config_flow::data::url%]" + }, + "sections": { + "more_options": { + "data": { + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "name": "More options" + } } } } diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index e71454f0aa8..70c8a80b2d0 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure songpal component.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any from urllib.parse import urlparse diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 1bde8a40c70..d012e9d5e72 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -1,7 +1,5 @@ """Support for Songpal-enabled (Sony) media devices.""" -from __future__ import annotations - import asyncio from collections import OrderedDict import logging @@ -345,7 +343,8 @@ class SongpalEntity(MediaPlayerEntity): def sound_mode_list(self) -> list[str] | None: """Return list of available sound modes. - When active mode is None it means that sound mode is unavailable on the sound bar. + When active mode is None it means that sound mode is + unavailable on the sound bar. Can be due to incompatible sound bar or the sound bar is in a mode that does not support sound mode changes. """ diff --git a/homeassistant/components/songpal/services.py b/homeassistant/components/songpal/services.py index f5756799901..96e42b39250 100644 --- a/homeassistant/components/songpal/services.py +++ b/homeassistant/components/songpal/services.py @@ -1,7 +1,5 @@ """Support for Songpal-enabled (Sony) media devices.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 6f5a8033620..27d3d3eadc0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -1,6 +1,5 @@ """Support to embed Sonos.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import datetime @@ -219,13 +218,15 @@ class SonosDiscoveryManager: _ = IPv4Address(ip_address) except AddressValueError: _LOGGER.debug( - "Sonos integration only supports IPv4 addresses, invalid ip_address received: %s", + "Sonos integration only supports IPv4 addresses," + " invalid ip_address received: %s", ip_address, ) return soco = SoCo(ip_address) try: - # Cache now to avoid household ID lookup during first ZoneGroupState processing + # Cache now to avoid household ID lookup during + # first ZoneGroupState processing await self.hass.async_add_executor_job( getattr, soco, @@ -427,7 +428,8 @@ class SonosDiscoveryManager: ) -> None: """Add and maintain Sonos devices from a manual configuration.""" - # Loop through each configured host and verify that Soco attributes are available for it. + # Loop through each configured host and verify that + # Soco attributes are available for it. for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) @@ -458,8 +460,9 @@ class SonosDiscoveryManager: if self.hosts_in_error.pop(ip_addr, None): _LOGGER.warning("Connection reestablished to Sonos device %s", ip_addr) - # Each speaker has the topology for other online speakers, so add them in here if they were not - # configured. The metadata is already in Soco for these. + # Each speaker has the topology for other online + # speakers, so add them in here if they were not + # configured. The metadata is already in Soco. if new_hosts := { x.ip_address for x in visible_zones if x.ip_address not in self.hosts }: @@ -470,12 +473,14 @@ class SonosDiscoveryManager: _LOGGER.debug("Discarding %s from manual hosts", ip_addr) self.hosts.discard(ip_addr) - # Loop through each configured host that is not in error. Send a discovery message - # if a speaker does not already exist, or ping the speaker if it is unavailable. + # Loop through each configured host that is not in + # error. Send a discovery message if a speaker does + # not already exist, or ping if it is unavailable. for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) - # Skip hosts that are in error to avoid blocking call on soco.uuid in event loop + # Skip hosts that are in error to avoid blocking + # call on soco.uuid in event loop if self.hosts_in_error.get(ip_addr): continue known_speaker = next( @@ -551,7 +556,7 @@ class SonosDiscoveryManager: return uid = uid[5:] - if change == ssdp.SsdpChange.BYEBYE: + if change is ssdp.SsdpChange.BYEBYE: _LOGGER.debug( "ssdp:byebye received from %s", info.upnp.get("friendlyName", uid) ) diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index c318151454e..bc3f18d4423 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -1,12 +1,10 @@ """Class representing Sonos alarms.""" -from __future__ import annotations - from collections.abc import Iterator import logging from typing import TYPE_CHECKING, Any -from soco import SoCo +from soco import SoCo, SoCoException from soco.alarms import Alarm, Alarms from soco.events_base import Event as SonosEvent @@ -30,6 +28,7 @@ class SonosAlarms(SonosHouseholdCoordinator): super().__init__(*args) self.alarms: Alarms = Alarms() self.created_alarm_ids: set[str] = set() + self._household_mismatch_logged = False def __iter__(self) -> Iterator: """Return an iterator for the known alarms.""" @@ -76,21 +75,44 @@ class SonosAlarms(SonosHouseholdCoordinator): await self.async_update_entities(speaker.soco, event_id) @soco_error() - def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: - """Update cache of known alarms and return if cache has changed.""" - self.alarms.update(soco) + def update_cache( + self, + soco: SoCo, + update_id: int | None = None, + ) -> bool: + """Update cache of known alarms and return whether any were seen.""" + try: + self.alarms.update(soco) + except SoCoException as err: + err_msg = str(err) + # Only catch the specific household mismatch error + if "Alarm list UID" in err_msg and "does not match" in err_msg: + if not self._household_mismatch_logged: + _LOGGER.warning( + "Sonos alarms for %s cannot be updated" + " due to a household mismatch. " + "This is a known limitation in setups" + " with multiple households. " + "You can safely ignore this warning," + " or to silence it, remove the " + "affected household from your Sonos" + " system. Error: %s", + soco.player_name, + err_msg, + ) + self._household_mismatch_logged = True + return False + # Let all other exceptions bubble up to be handled by @soco_error() + raise if update_id and self.alarms.last_id < update_id: # Skip updates if latest query result is outdated or lagging return False - if ( self.last_processed_event_id and self.alarms.last_id <= self.last_processed_event_id ): - # Skip updates already processed return False - _LOGGER.debug( "Updating processed event %s from %s (was %s)", self.alarms.last_id, diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 8a4c3abe248..eb5b08e1774 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -1,7 +1,5 @@ """Entity representing a Sonos power sensor.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 82416bd1965..3142e72e685 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -1,7 +1,5 @@ """Const for Sonos.""" -from __future__ import annotations - import datetime from homeassistant.components.media_player import MediaClass, MediaType diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index fafa142273a..c51369082ea 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Sonos.""" -from __future__ import annotations - import time from typing import Any diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 5f7a2fb2d70..0b8be269077 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -1,7 +1,5 @@ """Entity representing a Sonos player.""" -from __future__ import annotations - from abc import abstractmethod import datetime import logging @@ -109,12 +107,15 @@ class SonosEntity(Entity): class SonosPollingEntity(SonosEntity): - """Representation of a Sonos entity which may not support updating by subscriptions.""" + """Representation of a Sonos entity without subscription support.""" @abstractmethod def poll_state(self) -> None: """Poll the device for the current state.""" + async def _async_fallback_poll(self) -> None: + """No-op: polling entities are already handled by HA's built-in poller.""" + def update(self) -> None: """Update the state using the built-in entity poller.""" if not self.available: diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index c1e1b4f80df..ecb7c8f2a9e 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -1,7 +1,5 @@ """Class representing Sonos favorites.""" -from __future__ import annotations - from collections.abc import Iterator import logging import re diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index e83b0132a0e..2b4df23b4cc 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -1,7 +1,5 @@ """Helper methods for common tasks.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from collections.abc import Callable @@ -95,7 +93,7 @@ def soco_error[_T: _SonosEntitiesType, **_P, _R]( def _find_target_identifier(instance: Any, fallback_soco: SoCo | None) -> str | None: - """Extract the best available target identifier from the provided instance object.""" + """Extract the best target identifier from the instance.""" if entity_id := getattr(instance, "entity_id", None): # SonosEntity instance return entity_id diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index 02c6fcdecab..e1e56696d30 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -1,7 +1,5 @@ """Class representing a Sonos household storage helper.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import logging @@ -83,7 +81,10 @@ class SonosHouseholdCoordinator: raise NotImplementedError def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: - """Update the cache of the household-level feature and return if cache has changed.""" + """Update the household-level feature cache. + + Return if cache has changed. + """ raise NotImplementedError def add_speaker(self, soco: SoCo) -> None: diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index d9730170c81..a08e63c4ca4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "bronze", "requirements": [ "defusedxml==0.7.1", - "soco==0.30.15", + "soco==0.31.1", "sonos-websocket==0.1.3" ], "ssdp": [ diff --git a/homeassistant/components/sonos/media.py b/homeassistant/components/sonos/media.py index 36074988de8..1e16fecc0b8 100644 --- a/homeassistant/components/sonos/media.py +++ b/homeassistant/components/sonos/media.py @@ -1,7 +1,5 @@ """Support for media metadata handling.""" -from __future__ import annotations - import datetime from typing import Any diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 768aaf529a1..20e5a51b6ac 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -1,7 +1,5 @@ """Support for media browsing.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from functools import partial @@ -19,8 +17,11 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.network import is_internal_request from .const import ( @@ -48,9 +49,11 @@ type GetBrowseImageUrlType = Callable[[str, str, str | None], str] def fix_image_url(url: str) -> str: - """Update the image url to fully encode characters to allow image display in media_browser UI. + """Update the image url to fully encode characters. - Images whose file path contains characters such as ',()+ are not loaded without escaping them. + This allows image display in media_browser UI. Images + whose file path contains characters such as ',()+ are + not loaded without escaping them. """ # Before parsing encode the plus sign; otherwise it'll be interpreted as a space. @@ -110,7 +113,8 @@ def _get_title(id_string: str) -> str: """Extract a suitable title from the content id string.""" if id_string.startswith("S:"): # Format is S://server/share/folder - # If just S: this will be in the mappings; otherwise use the last folder in path. + # If just S: this will be in the mappings; + # otherwise use the last folder in path. title = LIBRARY_TITLES_MAPPING.get( id_string, urllib.parse.unquote(id_string.rsplit("/", maxsplit=1)[-1]) ) @@ -208,6 +212,49 @@ async def async_browse_media( return response +async def async_search_media( + hass: HomeAssistant, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, + query: SearchMediaQuery, +) -> SearchMedia: + """Search media.""" + media_content_type = query.media_content_type or MediaType.TRACK + search_type = MEDIA_TYPES_TO_SONOS.get(media_content_type) + if search_type is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media_content_type", + translation_placeholders={ + "media_content_type": media_content_type, + }, + ) + items = await hass.async_add_executor_job( + partial( + media.library.get_music_library_information, + search_type, + search_term=query.search_query, + full_album_art_uri=True, + complete_result=True, + ) + ) + result = [] + for item in items: + with suppress(UnknownMediaType): + result.append( + item_payload( + item, + get_thumbnail_url=partial( + get_thumbnail_url_full, + media, + is_internal_request(hass), + get_browse_image_url, + ), + ) + ) + return SearchMedia(result=result) + + def build_item_response( media_library: MusicLibrary, payload: dict[str, str], get_thumbnail_url=None ) -> BrowseMedia | None: @@ -623,9 +670,10 @@ def get_media( ) matches = [result] else: - # When requesting media by album_artist, composer, genre use the browse interface - # to navigate the hierarchy. This occurs when invoked from media browser or service - # calls + # When requesting media by album_artist, composer, + # genre use the browse interface to navigate the + # hierarchy. This occurs when invoked from media + # browser or service calls # Example: A:ALBUMARTIST/Neil Young/Greatest Hits - get specific album # Example: A:ALBUMARTIST/Neil Young - get all albums # Others: composer, genre diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index f7a4420704b..53193973503 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,11 +1,11 @@ """Support to interface with Sonos players.""" -from __future__ import annotations - import datetime from functools import partial import logging +import os from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse from soco import SoCo, alarms from soco.core import ( @@ -37,10 +37,12 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.components.plex import PLEX_URI_SCHEME -from homeassistant.components.plex.services import ( # pylint: disable=hass-component-root-import +from homeassistant.components.plex.services import ( # pylint: disable=home-assistant-component-root-import process_plex_payload, ) from homeassistant.core import HomeAssistant, callback @@ -90,6 +92,7 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()} UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"] ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"] +ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"}) async def async_setup_entry( @@ -126,6 +129,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SHUFFLE_SET @@ -309,7 +313,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.soco.volume = int(round(volume * 100)) + self.soco.volume = round(volume * 100) @soco_error(UPNP_ERRORS_TO_IGNORE) def set_shuffle(self, shuffle: bool) -> None: @@ -459,6 +463,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if kwargs.get(ATTR_MEDIA_ANNOUNCE): volume = kwargs.get("extra", {}).get("volume") + ext = os.path.splitext(urlparse(media_id).path)[1].lower() + if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: + _LOGGER.warning( + "Sonos AudioClip announce only supports MP3 and WAV; " + "%s has extension %s and will be attempted as a clip anyway on %s", + media_id, + ext, + self.speaker.zone_name, + ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: assert self.speaker.websocket @@ -808,6 +821,18 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_content_type, ) + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the music library for media matching the query.""" + return await media_browser.async_search_media( + self.hass, + self.media, + self.get_browse_image_url, + query, + ) + async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] @@ -841,15 +866,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_unjoin_player(self) -> None: """Remove this player from any group. - Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to allow use of SonosSpeaker.unjoin_multi() - which optimizes the order in which speakers are removed from their groups. - Removing coordinators last better preserves playqueues on the speakers. + Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to + allow use of SonosSpeaker.unjoin_multi() which + optimizes the order in which speakers are removed + from their groups. Removing coordinators last better + preserves playqueues on the speakers. """ sonos_data = self.config_entry.runtime_data household_id = self.speaker.household_id async def async_process_unjoin(now: datetime.datetime) -> None: - """Process the unjoin with all remove requests within the coalescing period.""" + """Process the unjoin with all remove requests.""" unjoin_data = sonos_data.unjoin_data.pop(household_id) _LOGGER.debug( "Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers] diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 8e4b4fb5b42..d272c37af8c 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -1,7 +1,5 @@ """Entity representing a Sonos number control.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py index fa38bf20c9f..ac1fe8acebc 100644 --- a/homeassistant/components/sonos/select.py +++ b/homeassistant/components/sonos/select.py @@ -1,7 +1,5 @@ """Select entities for Sonos.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index fcb04a10e98..d9f7369d5a7 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -1,7 +1,5 @@ """Entity representing a Sonos battery level.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -197,6 +195,7 @@ class SonosFavoritesEntity(SensorEntity): """Representation of a Sonos favorites info entity.""" _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True _attr_name = "Sonos favorites" _attr_translation_key = "favorites" _attr_native_unit_of_measurement = "items" diff --git a/homeassistant/components/sonos/services.py b/homeassistant/components/sonos/services.py index 883835a7c86..b108b2315ad 100644 --- a/homeassistant/components/sonos/services.py +++ b/homeassistant/components/sonos/services.py @@ -1,14 +1,11 @@ """Support to interface with Sonos players.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.helpers import config_validation as cv, service -from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES from .const import ATTR_QUEUE_POSITION, DOMAIN from .media_player import SonosMediaPlayerEntity @@ -35,25 +32,11 @@ ATTR_WITH_GROUP = "with_group" def async_setup_services(hass: HomeAssistant) -> None: """Register Sonos services.""" - @service.verify_domain_control(DOMAIN) - async def async_service_handle(service_call: ServiceCall) -> None: - """Handle dispatched services.""" - platform_entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get( - (MEDIA_PLAYER_DOMAIN, DOMAIN), {} - ) - - entities = await service.async_extract_entities( - platform_entities.values(), service_call - ) - - if not entities: - return - - speakers: list[SonosSpeaker] = [] - for entity in entities: - assert isinstance(entity, SonosMediaPlayerEntity) - speakers.append(entity.speaker) - + async def async_handle_snapshot_restore( + entities: list[SonosMediaPlayerEntity], service_call: ServiceCall + ) -> None: + """Handle snapshot and restore services.""" + speakers = [entity.speaker for entity in entities] config_entry = speakers[0].config_entry # All speakers share the same entry if service_call.service == SERVICE_SNAPSHOT: @@ -65,16 +48,22 @@ def async_setup_services(hass: HomeAssistant) -> None: hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] ) - join_unjoin_schema = cv.make_entity_service_schema( - {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean} + service.async_register_batched_platform_entity_service( + hass, + DOMAIN, + SERVICE_SNAPSHOT, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}, + func=async_handle_snapshot_restore, ) - hass.services.async_register( - DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema - ) - - hass.services.async_register( - DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema + service.async_register_batched_platform_entity_service( + hass, + DOMAIN, + SERVICE_RESTORE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}, + func=async_handle_snapshot_restore, ) service.async_register_platform_entity_service( diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 5d596c5679f..130d873b6c8 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -1,22 +1,20 @@ snapshot: + target: + entity: + integration: sonos + domain: media_player fields: - entity_id: - selector: - entity: - integration: sonos - domain: media_player with_group: default: true selector: boolean: restore: + target: + entity: + integration: sonos + domain: media_player fields: - entity_id: - selector: - entity: - integration: sonos - domain: media_player with_group: default: true selector: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 78a7245ef9f..c5c520fa1d2 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1,7 +1,5 @@ """Base class for common speaker tasks.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Collection, Coroutine import contextlib @@ -165,6 +163,8 @@ class SonosSpeaker: self.dialog_level_enum: int | None = None self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None + self.tv_autoplay: str | None = None + self.tv_ungroup_autoplay: bool | None = None self.sub_enabled: bool | None = None self.sub_crossover: int | None = None self.sub_gain: int | None = None @@ -351,7 +351,7 @@ class SonosSpeaker: def log_subscription_result( self, result: Any, event: str, level: int = logging.DEBUG ) -> None: - """Log a message if a subscription action (create/renew/stop) results in an exception.""" + """Log if a subscription action results in an exception.""" if not isinstance(result, Exception): return @@ -938,12 +938,14 @@ class SonosSpeaker: for uid in group: speaker = self.data.discovered.get(uid) - if speaker: + entity_id = ( + entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid) + if speaker + else None + ) + if speaker and entity_id: self._group_members_missing.discard(uid) sonos_group.append(speaker) - entity_id = cast( - str, entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, uid) - ) sonos_group_entities.append(entity_id) else: self._group_members_missing.add(uid) @@ -964,7 +966,8 @@ class SonosSpeaker: new_members = set(sonos_group[1:]) removed_members = old_members - new_members for removed_speaker in removed_members: - # Only clear if this speaker was coordinated by self and in the same group + # Only clear if this speaker was coordinated + # by self and in the same group if ( removed_speaker.coordinator == self and removed_speaker.sonos_group is self.sonos_group @@ -1174,10 +1177,12 @@ class SonosSpeaker: if not with_group: return groups - # Unjoin non-coordinator speakers not contained in the desired snapshot group + # Unjoin non-coordinator speakers not contained in + # the desired snapshot group # - # If a coordinator is unjoined from its group, another speaker from the group - # will inherit the coordinator's playqueue and its own playqueue will be lost + # If a coordinator is unjoined from its group, + # another speaker from the group will inherit the + # coordinator's playqueue and its own will be lost speakers_to_unjoin = set() for speaker in speakers: if speaker.sonos_group == speaker.snapshot_group: @@ -1263,7 +1268,8 @@ class SonosSpeaker: await config_entry.runtime_data.topology_condition.wait() except TimeoutError: group_description = "; ".join( - f"{group[0].zone_name}: {', '.join(speaker.zone_name for speaker in group)}" + f"{group[0].zone_name}: " + f"{', '.join(speaker.zone_name for speaker in group)}" for group in groups ) raise HomeAssistantError( diff --git a/homeassistant/components/sonos/statistics.py b/homeassistant/components/sonos/statistics.py index ec3486d47e7..ea164eb0a7e 100644 --- a/homeassistant/components/sonos/statistics.py +++ b/homeassistant/components/sonos/statistics.py @@ -1,7 +1,5 @@ """Class to track subscription event statistics.""" -from __future__ import annotations - import logging from soco.data_structures_entry import from_didl_string diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 2362679dc7c..f2e01da70fa 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -96,6 +96,12 @@ }, "surround_mode": { "name": "Surround music full volume" + }, + "tv_autoplay": { + "name": "TV autoplay" + }, + "ungroup_on_autoplay": { + "name": "Ungroup on autoplay" } } }, @@ -118,6 +124,9 @@ "invalid_media": { "message": "Could not find media in library: {media_id}" }, + "invalid_media_content_type": { + "message": "Media content type {media_content_type} is not supported" + }, "invalid_sonos_playlist": { "message": "Could not find Sonos playlist: {name}" }, @@ -129,6 +138,9 @@ }, "timeout_unjoin": { "message": "Timeout while waiting for Sonos player to unjoin the group {group_description}" + }, + "toggle_failed": { + "message": "Could not toggle {entity_id}." } }, "issues": { @@ -173,10 +185,6 @@ "restore": { "description": "Restores a snapshot of a media player.", "fields": { - "entity_id": { - "description": "Name of entity that will be restored.", - "name": "Entity" - }, "with_group": { "description": "Whether the group layout and the state of other speakers in the group should also be restored.", "name": "[%key:component::sonos::services::snapshot::fields::with_group::name%]" @@ -197,10 +205,6 @@ "snapshot": { "description": "Takes a snapshot of a media player.", "fields": { - "entity_id": { - "description": "Name of entity that will be snapshot.", - "name": "Entity" - }, "with_group": { "description": "Whether the snapshot should include the group layout and the state of other speakers in the group.", "name": "With group" diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 653be229b22..40cdbfb4f81 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -1,7 +1,5 @@ """Entity representing a Sonos Alarm.""" -from __future__ import annotations - import datetime import logging from typing import Any, cast @@ -12,6 +10,7 @@ from soco.exceptions import SoCoSlaveException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -25,6 +24,7 @@ from .const import ( SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, + SOURCE_TV, ) from .entity import SonosEntity, SonosPollingEntity from .helpers import SonosConfigEntry, soco_error @@ -49,6 +49,8 @@ ATTR_STATUS_LIGHT = "status_light" ATTR_SUB_ENABLED = "sub_enabled" ATTR_SURROUND_ENABLED = "surround_enabled" ATTR_TOUCH_CONTROLS = "buttons_enabled" +ATTR_TV_AUTOPLAY = "tv_autoplay" +ATTR_TV_UNGROUP_AUTOPLAY = "ungroup_on_autoplay" ALL_FEATURES = ( ATTR_TOUCH_CONTROLS, @@ -72,6 +74,8 @@ POLL_REQUIRED = ( WEEKEND_DAYS = (0, 6) +_TV_SOURCE = (("Source", SOURCE_TV),) + # Mapping of model names to feature attributes that need to be substituted. # This is used to handle differences in attributes across Sonos models. MODEL_FEATURE_SUBSTITUTIONS: dict[str, dict[str, str]] = { @@ -119,11 +123,52 @@ async def async_setup_entry( features.append(feature_type) return features - async def _async_create_switches(speaker: SonosSpeaker) -> None: - entities = [] - available_features = await hass.async_add_executor_job( - available_soco_attributes, speaker + def _get_tv_autoplay_state(speaker: SonosSpeaker) -> str | None: + """Return initial TV autoplay RoomUUID, or None if not supported.""" + try: + result = speaker.soco.deviceProperties.GetAutoplayRoomUUID(_TV_SOURCE) + except (SoCoUPnPException, SoCoSlaveException, OSError) as err: + _LOGGER.debug( + "Unable to read %s state for %s: %s", + ATTR_TV_AUTOPLAY, + speaker.zone_name, + err, + ) + return None + return result.get("RoomUUID") + + def _get_tv_ungroup_autoplay_state(speaker: SonosSpeaker) -> bool | None: + """Return initial TV ungroup-on-autoplay state, or None if not supported.""" + try: + result = speaker.soco.deviceProperties.GetAutoplayLinkedZones(_TV_SOURCE) + except (SoCoUPnPException, SoCoSlaveException, OSError) as err: + _LOGGER.debug( + "Unable to read %s state for %s: %s", + ATTR_TV_UNGROUP_AUTOPLAY, + speaker.zone_name, + err, + ) + return None + # IncludeLinkedZones=0 means "don't include linked zones" = ungroup = ON + return result.get("IncludeLinkedZones") == "0" + + def _get_switch_state( + speaker: SonosSpeaker, + ) -> tuple[list[str], str | None, bool | None]: + """Return all switch state for entity creation.""" + return ( + available_soco_attributes(speaker), + _get_tv_autoplay_state(speaker), + _get_tv_ungroup_autoplay_state(speaker), ) + + async def _async_create_switches(speaker: SonosSpeaker) -> None: + entities: list[SonosPollingEntity] = [] + ( + available_features, + initial_autoplay, + initial_ungroup, + ) = await hass.async_add_executor_job(_get_switch_state, speaker) for feature_type in available_features: attribute_key = MODEL_FEATURE_SUBSTITUTIONS.get( speaker.model_name.upper(), {} @@ -142,6 +187,31 @@ async def async_setup_entry( config_entry=config_entry, ) ) + + if initial_autoplay is not None: + speaker.tv_autoplay = initial_autoplay + _LOGGER.debug( + "Creating %s switch on %s", + ATTR_TV_AUTOPLAY, + speaker.zone_name, + ) + entities.append( + SonosTVAutoplaySwitchEntity(speaker=speaker, config_entry=config_entry) + ) + + if initial_ungroup is not None: + speaker.tv_ungroup_autoplay = initial_ungroup + _LOGGER.debug( + "Creating %s switch on %s", + ATTR_TV_UNGROUP_AUTOPLAY, + speaker.zone_name, + ) + entities.append( + SonosTVUngroupAutoplaySwitchEntity( + speaker=speaker, config_entry=config_entry + ) + ) + async_add_entities(entities) config_entry.async_on_unload( @@ -213,6 +283,136 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): _LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc) +class SonosTVAutoplaySwitchEntity(SonosPollingEntity, SwitchEntity): + """Representation of a Sonos TV autoplay switch.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = ATTR_TV_AUTOPLAY + _attr_should_poll = True + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the switch.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{speaker.soco.uid}-{ATTR_TV_AUTOPLAY}" + + @soco_error() + def poll_state(self) -> None: + """Poll the current TV autoplay state from the device.""" + result = self.soco.deviceProperties.GetAutoplayRoomUUID(_TV_SOURCE) + self.speaker.tv_autoplay = result.get("RoomUUID") + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self.speaker.tv_autoplay is not None + + @property + def is_on(self) -> bool | None: + """Return True if TV autoplay is enabled.""" + if self.speaker.tv_autoplay is None: + return None + return bool(self.speaker.tv_autoplay) + + def turn_on(self, **kwargs: Any) -> None: + """Enable TV autoplay.""" + self._send_command(True) + + def turn_off(self, **kwargs: Any) -> None: + """Disable TV autoplay.""" + self._send_command(False) + + @soco_error() + def _send_command(self, enable: bool) -> None: + """Enable or disable TV autoplay on the device.""" + room_uuid = self.soco.uid if enable else "" + try: + self.soco.deviceProperties.SetAutoplayRoomUUID( + [("RoomUUID", room_uuid), *_TV_SOURCE] + ) + except SoCoUPnPException as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="toggle_failed", + translation_placeholders={"entity_id": self.entity_id}, + ) from exc + self.poll_state() + # Refresh ungroup state: the device may change it as a side effect + # (e.g. disabling TV autoplay automatically disables ungroup on autoplay). + try: + result = self.soco.deviceProperties.GetAutoplayLinkedZones(_TV_SOURCE) + self.speaker.tv_ungroup_autoplay = result.get("IncludeLinkedZones") == "0" + except SoCoUPnPException as exc: + _LOGGER.debug( + "Could not refresh %s state: %s", ATTR_TV_UNGROUP_AUTOPLAY, exc + ) + self.speaker.write_entity_states() + + +class SonosTVUngroupAutoplaySwitchEntity(SonosPollingEntity, SwitchEntity): + """Representation of a Sonos TV ungroup-on-autoplay switch. + + When enabled, the speaker leaves its group when it detects TV audio and + takes over playback alone. The device manages the dependency with TV autoplay + and will reflect the correct state via polling. + """ + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = ATTR_TV_UNGROUP_AUTOPLAY + _attr_should_poll = True + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the switch.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{speaker.soco.uid}-{ATTR_TV_UNGROUP_AUTOPLAY}" + + @soco_error() + def poll_state(self) -> None: + """Poll the current ungroup-on-autoplay state from the device.""" + result = self.soco.deviceProperties.GetAutoplayLinkedZones(_TV_SOURCE) + linked_zones = result.get("IncludeLinkedZones") + if linked_zones is None: + self.speaker.tv_ungroup_autoplay = None + return + # IncludeLinkedZones=0 means "don't include linked zones" = ungroup = ON + self.speaker.tv_ungroup_autoplay = linked_zones == "0" + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self.speaker.tv_ungroup_autoplay is not None + + @property + def is_on(self) -> bool | None: + """Return True if ungroup on autoplay is enabled.""" + return self.speaker.tv_ungroup_autoplay + + def turn_on(self, **kwargs: Any) -> None: + """Enable ungroup on autoplay.""" + self._send_command(True) + + def turn_off(self, **kwargs: Any) -> None: + """Disable ungroup on autoplay.""" + self._send_command(False) + + @soco_error() + def _send_command(self, enable: bool) -> None: + """Enable or disable ungroup on autoplay on the device.""" + try: + self.soco.deviceProperties.SetAutoplayLinkedZones( + # enable=True (ungroup) → IncludeLinkedZones=0 + # (don't include linked zones) + [("IncludeLinkedZones", "0" if enable else "1"), *_TV_SOURCE] + ) + except SoCoUPnPException as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="toggle_failed", + translation_placeholders={"entity_id": self.entity_id}, + ) from exc + self.poll_state() + self.speaker.write_entity_states() + + class SonosAlarmEntity(SonosEntity, SwitchEntity): """Representation of a Sonos Alarm entity.""" diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index 7aa76245aec..49a3e2dbd06 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -1,7 +1,5 @@ """Support for Sony projectors via SDCP network control.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index bb11ebfaa19..4623abc03da 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -1,6 +1,7 @@ """The soundtouch component.""" import logging +from typing import TYPE_CHECKING from libsoundtouch import soundtouch_device from libsoundtouch.device import SoundTouchDevice @@ -22,6 +23,11 @@ from .const import ( SERVICE_REMOVE_ZONE_SLAVE, ) +if TYPE_CHECKING: + from .media_player import SoundTouchMediaPlayer + +type SoundTouchConfigEntry = ConfigEntry[SoundTouchData] + _LOGGER = logging.getLogger(__name__) SERVICE_PLAY_EVERYWHERE_SCHEMA = vol.Schema({vol.Required("master"): cv.entity_id}) @@ -50,12 +56,12 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) class SoundTouchData: - """SoundTouch data stored in the Home Assistant data object.""" + """SoundTouch data stored in the config entry runtime data.""" def __init__(self, device: SoundTouchDevice) -> None: """Initialize the SoundTouch data object for a device.""" self.device = device - self.media_player = None + self.media_player: SoundTouchMediaPlayer | None = None async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -65,20 +71,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle the applying of a service.""" master_id = service.data.get("master") slaves_ids = service.data.get("slaves") + all_media_players = [ + entry.runtime_data.media_player + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.runtime_data.media_player is not None + ] slaves = [] if slaves_ids: slaves = [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id in slaves_ids + media_player + for media_player in all_media_players + if media_player.entity_id in slaves_ids ] master = next( iter( [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id == master_id + media_player + for media_player in all_media_players + if media_player.entity_id == master_id ] ), None, @@ -90,9 +101,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if service.service == SERVICE_PLAY_EVERYWHERE: slaves = [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id != master_id + media_player + for media_player in all_media_players + if media_player.entity_id != master_id ] await hass.async_add_executor_job(master.create_zone, slaves) elif service.service == SERVICE_CREATE_ZONE: @@ -130,7 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SoundTouchConfigEntry) -> bool: """Set up Bose SoundTouch from a config entry.""" try: device = await hass.async_add_executor_job( @@ -141,14 +152,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to connect to SoundTouch device at {entry.data[CONF_HOST]}" ) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device) + entry.runtime_data = SoundTouchData(device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SoundTouchConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 02c0d8a1bbf..b12deaf52b0 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -1,7 +1,5 @@ """Support for interface with a Bose SoundTouch.""" -from __future__ import annotations - from functools import partial import logging from typing import Any @@ -19,7 +17,6 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import ( @@ -29,6 +26,7 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import SoundTouchConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -46,16 +44,16 @@ ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SoundTouchConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bose SoundTouch media player based on a config entry.""" - device = hass.data[DOMAIN][entry.entry_id].device + device = entry.runtime_data.device media_player = SoundTouchMediaPlayer(device) async_add_entities([media_player], True) - hass.data[DOMAIN][entry.entry_id].media_player = media_player + entry.runtime_data.media_player = media_player class SoundTouchMediaPlayer(MediaPlayerEntity): @@ -388,14 +386,16 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): def _get_instance_by_ip(self, ip_address): """Search and return a SoundTouchDevice instance by it's IP address.""" - for data in self.hass.data[DOMAIN].values(): + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + data = entry.runtime_data if data.device.config.device_ip == ip_address: return data.media_player return None def _get_instance_by_id(self, instance_id): - """Search and return a SoundTouchDevice instance by it's ID (aka MAC address).""" - for data in self.hass.data[DOMAIN].values(): + """Search and return a SoundTouchDevice by its ID.""" + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + data = entry.runtime_data if data.device.config.device_id == instance_id: return data.media_player return None diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 7460cc5dcdf..106ce1b8719 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -252,6 +252,11 @@ class APISpaceApiView(HomeAssistantView): url = URL_API_SPACEAPI name = "api:spaceapi" + def __init__(self) -> None: + """Initialize SpaceAPI view.""" + self.requires_auth = False + self.cors_allowed = True + @staticmethod def get_sensor_data( hass: HomeAssistant, spaceapi: dict[str, Any], entity_id: str diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index 44e0572c9e9..29705603d32 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" -from __future__ import annotations - from pyspcwebgw import SpcWebGateway from pyspcwebgw.area import Area from pyspcwebgw.const import AreaMode diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index 529fa1e01ef..67d3a2949da 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" -from __future__ import annotations - from pyspcwebgw import SpcWebGateway from pyspcwebgw.const import ZoneInput, ZoneType from pyspcwebgw.zone import Zone diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 5f66ba380fe..c36964df2ed 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -1,7 +1,5 @@ """Support for testing internet speed via Speedtest.net.""" -from __future__ import annotations - from functools import partial import speedtest diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 4bae503f85e..07a2ecec622 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Speedtest.net.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 2002d46c838..01dd5745c2f 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,7 +1,5 @@ """Constants used by Speedtest.net.""" -from __future__ import annotations - from typing import Final DOMAIN: Final = "speedtestdotnet" diff --git a/homeassistant/components/speedtestdotnet/quality_scale.yaml b/homeassistant/components/speedtestdotnet/quality_scale.yaml new file mode 100644 index 00000000000..88f319c5e76 --- /dev/null +++ b/homeassistant/components/speedtestdotnet/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration has no custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Integration has no manual event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: todo + test-before-setup: done + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: Integration has no custom service actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: + status: exempt + comment: Integration has no installation parameters. + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Integration has no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration is a cloud polling service and does not use discovery. + discovery: + status: exempt + comment: Integration is a cloud polling service and does not use discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Integration is a service, not a hardware device. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices added after integration setup. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Integration only creates a single device, and does not dynamically discover devices. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index c2b7a6de28c..38a0fa4c60a 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -1,7 +1,5 @@ """Support for Speedtest.net internet speed testing sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index 84afe51ed95..244549ad179 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -66,6 +66,9 @@ "init": { "data": { "server_name": "Select test server" + }, + "data_description": { + "server_name": "The Speedtest.net server to use for tests. Select auto detect to automatically choose a server." } } } diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index c0d85c02dd4..2b2021449aa 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -1,7 +1,5 @@ """The Spider integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 3838957d81d..b373e754433 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -1,7 +1,5 @@ """Support to send data to a Splunk instance.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/splunk/config_flow.py b/homeassistant/components/splunk/config_flow.py index 6f84f9fab5d..8279fac25b6 100644 --- a/homeassistant/components/splunk/config_flow.py +++ b/homeassistant/components/splunk/config_flow.py @@ -1,6 +1,5 @@ """Config flow for Splunk integration.""" - -from __future__ import annotations +# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas from collections.abc import Mapping import logging diff --git a/homeassistant/components/splunk/diagnostics.py b/homeassistant/components/splunk/diagnostics.py index d9086924bdc..f5510ac3e39 100644 --- a/homeassistant/components/splunk/diagnostics.py +++ b/homeassistant/components/splunk/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Splunk.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index fc81dd9ef01..1f585730c95 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -1,7 +1,5 @@ """The spotify integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING import aiohttp diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index a468a66f12f..dad27c8b166 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -1,7 +1,5 @@ """Support for Spotify media browsing.""" -from __future__ import annotations - from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index 1fc19515318..87447b4bed8 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Spotify.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 6fdaff48a65..efc1ff8fea4 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -121,8 +121,9 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): position_updated_at=None, playlist=None, ) - # Record the last updated time, because Spotify's timestamp property is unreliable - # and doesn't actually return the fetch time as is mentioned in the API description + # Record the last updated time, because Spotify's + # timestamp property is unreliable and doesn't + # actually return the fetch time as described in API position_updated_at = dt_util.utcnow() dj_playlist = False diff --git a/homeassistant/components/spotify/diagnostics.py b/homeassistant/components/spotify/diagnostics.py index 82ce40eb22a..0169b1812c7 100644 --- a/homeassistant/components/spotify/diagnostics.py +++ b/homeassistant/components/spotify/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Spotify.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index d45d44751a6..d9561a957c4 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -1,7 +1,5 @@ """Support for interacting with Spotify Connect.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine import datetime as dt diff --git a/homeassistant/components/spotify/util.py b/homeassistant/components/spotify/util.py index d882e9c58b8..d57805d8ef7 100644 --- a/homeassistant/components/spotify/util.py +++ b/homeassistant/components/spotify/util.py @@ -1,7 +1,5 @@ """Utils for Spotify.""" -from __future__ import annotations - from spotifyaio import Image import yarl diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index aac9b47b0d4..c0e0498297b 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -1,7 +1,5 @@ """The sql component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index d8619db7228..d0f23163116 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for SQL integration.""" -from __future__ import annotations - import logging from typing import Any @@ -86,6 +84,8 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( CONFIG_SCHEMA: vol.Schema = vol.Schema( { + # Approved exemption: user names the SQL query sensor + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), vol.Optional(CONF_DB_URL): selector.TextSelector(), } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 44ee32ec8e8..859bc8cbcb8 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"] + "requirements": ["SQLAlchemy==2.0.50", "sqlparse==0.5.5"] } diff --git a/homeassistant/components/sql/models.py b/homeassistant/components/sql/models.py index 872ceedde71..dc785ac4e41 100644 --- a/homeassistant/components/sql/models.py +++ b/homeassistant/components/sql/models.py @@ -1,7 +1,5 @@ """The sql integration models.""" -from __future__ import annotations - from dataclasses import dataclass from sqlalchemy.orm import scoped_session diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index dddd1386932..ccf4ae659e8 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -1,7 +1,5 @@ """Sensor from an SQL Query.""" -from __future__ import annotations - import logging from typing import Any @@ -10,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session from homeassistant.components.recorder import CONF_DB_URL, get_instance -from homeassistant.components.sensor import CONF_STATE_CLASS +from homeassistant.components.sensor import CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -27,8 +25,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, + async_create_platform_config_not_supported_issue, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -71,14 +69,13 @@ async def async_setup_platform( ) -> None: """Set up the SQL sensor from yaml.""" if (conf := discovery_info) is None: - async_create_issue( + async_create_platform_config_not_supported_issue( hass, DOMAIN, - "sensor_platform_yaml_not_supported", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="platform_yaml_not_supported", + SENSOR_DOMAIN, + yaml_config_under_integration_supported=True, learn_more_url="https://www.home-assistant.io/integrations/sql/", + logger=_LOGGER, ) return diff --git a/homeassistant/components/sql/services.py b/homeassistant/components/sql/services.py index 6ab97a2e665..6df6b76609d 100644 --- a/homeassistant/components/sql/services.py +++ b/homeassistant/components/sql/services.py @@ -1,7 +1,5 @@ """Services for the SQL integration.""" -from __future__ import annotations - import logging from sqlalchemy.engine import Result diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 92f68d17d0e..2669f3c8c41 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -66,10 +66,6 @@ "entity_id_query_does_full_table_scan": { "description": "The query `{query}` contains the keyword `entity_id` but does not reference the `states_meta` table. This will cause a full table scan and database instability. Please check the documentation and use `states_meta.entity_id` instead.", "title": "SQL query does full table scan" - }, - "platform_yaml_not_supported": { - "description": "Platform YAML setup is not supported.\nChange from configuring it in the `sensor:` key to using the `sql:` key directly in configuration.yaml.\nTo see the detailed documentation, select Learn more.", - "title": "Platform YAML is not supported in SQL" } }, "options": { diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 7433462f125..222ce4d196e 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -1,6 +1,5 @@ """Utils for sql.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import date from decimal import Decimal @@ -122,7 +121,8 @@ def validate_query( Args: hass: The Home Assistant instance. query_template: The SQL query string to be validated. - uses_recorder_db: A boolean indicating if the query is against the recorder database. + uses_recorder_db: A boolean indicating if the query is + against the recorder database. unique_id: The unique ID of the entity, used for creating issue registry keys. Raises: diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 3ba320091a6..ebfb5489539 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -22,11 +22,13 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, + HomeAssistantError, ) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + DeviceEntry, DeviceEntryType, format_mac, ) @@ -77,6 +79,9 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + player_coordinators: dict[str, SqueezeBoxPlayerUpdateCoordinator] = field( + default_factory=dict + ) known_player_ids: set[str] = field(default_factory=set) @@ -135,7 +140,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - }, ) - # For other errors where status is None (e.g., server error, connection refused by server) + # For other errors where status is None + # (e.g., server error, connection refused by server) _LOGGER.warning( "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", host, @@ -216,6 +222,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() + entry.runtime_data.player_coordinators[player.player_id] = ( + player_coordinator + ) entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, player_coordinator @@ -259,3 +268,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) hass.data.pop(SQUEEZEBOX_HASS_DATA) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: SqueezeboxConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Allow removal of a Squeezebox player only if its coordinator is unavailable.""" + if device_entry.entry_type is DeviceEntryType.SERVICE: + raise HomeAssistantError( + f"Cannot remove Lyrion Music Server '{device_entry.name}' directly. " + "Please delete the associated config entry instead." + ) + + player_id = next( + (id_ for domain, id_ in device_entry.identifiers if domain == DOMAIN), None + ) + + if not player_id: + return False # Not a Squeezebox device + + coordinator = config_entry.runtime_data.player_coordinators.get(player_id) + + if coordinator is None: + return True + + if coordinator.available: + raise HomeAssistantError( + f"Cannot remove Squeezebox player '{coordinator.player_uuid}' " + "because it is currently online." + ) + + return True diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index f23d807cd19..4d92eec0bfa 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Squeezebox integration.""" -from __future__ import annotations - import logging from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 2ca9d6f058c..c1a24d77565 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -1,7 +1,5 @@ """Support for media browsing.""" -from __future__ import annotations - import contextlib from dataclasses import dataclass, field import logging @@ -326,10 +324,12 @@ async def build_item_response( child_media = _build_response_favorites(item) elif search_type in ["apps", "radios"]: - # item["cmd"] contains the name of the command to use with the cli for the app + # item["cmd"] contains the name of the command + # to use with the cli for the app; # add the command to the dictionaries if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: - # Skip searches in apps as they'd need UI or if the link isn't to audio + # Skip searches in apps as they'd need UI + # or if the link isn't to audio continue app_cmd = "app-" + item["cmd"] diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py index 0d2057ae801..7c11bb068fd 100644 --- a/homeassistant/components/squeezebox/button.py +++ b/homeassistant/components/squeezebox/button.py @@ -1,7 +1,5 @@ """Platform for button integration for squeezebox.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index f7c15e648a9..3463121fb41 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Squeezebox integration.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging @@ -289,7 +287,10 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="edit_integration_discovered", description_placeholders={ - "desc": f"LMS Host: {self.chosen_server[CONF_HOST]}, Port: {self.chosen_server[CONF_PORT]}" + "desc": ( + f"LMS Host: {self.chosen_server[CONF_HOST]}," + f" Port: {self.chosen_server[CONF_PORT]}" + ) }, data_schema=SHORT_EDIT_SCHEMA, errors=errors, @@ -332,7 +333,10 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert self.unique_id - # if we have detected this player, do nothing. if not, there must be a server out there for us to configure, so start the normal user flow (which tries to autodetect server) + # if we have detected this player, do nothing. if not, + # there must be a server out there for us to configure, + # so start the normal user flow (which tries to + # autodetect server) if registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.unique_id) is not None: # this player is already known, so do nothing other than mark as configured raise AbortFlow("already_configured") diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d1e80e4a48f..0f53d5f62ac 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -51,7 +51,6 @@ ATTR_DAYS_OF_WEEK = "dow" ATTR_ENABLED = "enabled" ATTR_REPEAT = "repeat" ATTR_SCHEDULED_TODAY = "scheduled_today" -ATTR_TIME = "time" ATTR_VOLUME = "volume" ATTR_URL = "url" UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary" diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index c078fc377b5..096d39f7a6b 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Squeezebox integration.""" -from __future__ import annotations - from asyncio import timeout from collections.abc import Callable from datetime import timedelta @@ -108,7 +106,8 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update the Player() object if available, or listen for rediscovery if not.""" if self.available: - # Only update players available at last update, unavailable players are rediscovered instead + # Only update players available at last update, + # unavailable players are rediscovered instead await self.player.async_update() if not self.player.connected: @@ -138,3 +137,10 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): _LOGGER.info("Player %s is available again", self.name) if self._remove_dispatcher: self._remove_dispatcher() + + @callback + def async_shutdown_dispatcher(self) -> None: + """Close down the dispatcher.""" + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index f2be716320f..9b66df9dbfe 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -32,8 +32,12 @@ class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): @property def available(self) -> bool: """Return True if entity is available.""" - # super().available refers to CoordinatorEntity.available (self.coordinator.last_update_success) - # self.coordinator.available is the custom availability flag from SqueezeBoxPlayerUpdateCoordinator + # super().available refers to + # CoordinatorEntity.available + # (self.coordinator.last_update_success). + # self.coordinator.available is the custom + # availability flag from + # SqueezeBoxPlayerUpdateCoordinator return self.coordinator.available and super().available diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 094f50397a6..0e7484b96a4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing to the SqueezeBox API.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import json @@ -136,7 +134,8 @@ async def async_setup_entry( manufacturer = player.creator model_id = player.model_type sw_version = "" - # Why? so we nicely merge with a server and a player linked by a MAC server is not all info lost + # So we nicely merge with a server and a player + # linked by a MAC server is not all info lost if ( server_device and (CONNECTION_NETWORK_MAC, format_mac(player.player_id)) @@ -292,10 +291,17 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.async_shutdown_dispatcher() + + self.coordinator.config_entry.runtime_data.known_player_ids.discard( self.coordinator.player.player_id ) + self.coordinator.config_entry.runtime_data.player_coordinators.pop( + self.coordinator.player.player_id, None + ) + await super().async_will_remove_from_hass() + @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" @@ -757,8 +763,8 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Add other Squeezebox players to this player's sync group. - If the other player is a member of a sync group, it will leave the current sync group - without asking. + If the other player is a member of a sync group, + it will leave the current sync group without asking. """ ent_reg = er.async_get(self.hass) for other_player_entity_id in group_members: @@ -793,7 +799,8 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): def get_synthetic_id_and_cache_url(self, url: str) -> str: """Cache a thumbnail URL and return a synthetic ID. - This enables us to proxy thumbnails for apps and favorites, as those do not have IDs. + This enables us to proxy thumbnails for apps and + favorites, as those do not have IDs. """ synthetic_id = f"s_{ulid_now()}" diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 7dedf66eaff..3706b0df7d4 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration for squeezebox.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/squeezebox/services.py b/homeassistant/components/squeezebox/services.py index 79eb2a687c5..a2386b8ed93 100644 --- a/homeassistant/components/squeezebox/services.py +++ b/homeassistant/components/squeezebox/services.py @@ -1,7 +1,5 @@ """Support for interfacing to the SqueezeBox API.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/squeezebox/services.yaml b/homeassistant/components/squeezebox/services.yaml index 07885ae5dd6..48694602ceb 100644 --- a/homeassistant/components/squeezebox/services.yaml +++ b/homeassistant/components/squeezebox/services.yaml @@ -11,7 +11,6 @@ call_method: text: parameters: example: '["loadtracks", "album.titlesearch=Revolver"]' - advanced: true selector: object: call_query: @@ -27,6 +26,5 @@ call_query: text: parameters: example: '["0", "20", "search:Revolver"]' - advanced: true selector: object: diff --git a/homeassistant/components/squeezebox/switch.py b/homeassistant/components/squeezebox/switch.py index 3e567b6228a..315228d3ffd 100644 --- a/homeassistant/components/squeezebox/switch.py +++ b/homeassistant/components/squeezebox/switch.py @@ -81,10 +81,12 @@ async def async_setup_entry( coordinator.async_add_listener(_async_listener) # If coordinator already has alarm data from the initial refresh, - # call the listener immediately to process existing alarms and create alarm entities. + # call the listener immediately to process existing + # alarms and create alarm entities. if coordinator.data["alarms"]: _LOGGER.debug( - "Coordinator has alarm data, calling _async_listener immediately for player %s", + "Coordinator has alarm data, calling" + " _async_listener immediately for player %s", coordinator.player, ) _async_listener() diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py index db235786817..242ec0eda87 100644 --- a/homeassistant/components/squeezebox/update.py +++ b/homeassistant/components/squeezebox/update.py @@ -1,7 +1,5 @@ """Platform for update integration for squeezebox.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging @@ -117,8 +115,10 @@ class ServerStatusUpdatePlugins(ServerStatusUpdate): """If install is supported give some info.""" rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] return ( - (rs or "") - + "The Plugins will be updated on the next restart triggered by selecting the Update button. Allow enough time for the service to restart. It will become briefly unavailable." + (rs or "") + "The Plugins will be updated on the next restart" + " triggered by selecting the Update button." + " Allow enough time for the service to restart." + " It will become briefly unavailable." if self.coordinator.can_server_restart else rs ) diff --git a/homeassistant/components/squeezebox/util.py b/homeassistant/components/squeezebox/util.py index a93122c22c4..eb2b0361286 100644 --- a/homeassistant/components/squeezebox/util.py +++ b/homeassistant/components/squeezebox/util.py @@ -1,7 +1,5 @@ """Utility functions for Squeezebox integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import Any diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index 13c21709445..0a540638e69 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -2,17 +2,16 @@ from srpenergy.client import SrpEnergyClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER -from .coordinator import SRPEnergyDataUpdateCoordinator +from .const import LOGGER +from .coordinator import SRPEnergyConfigEntry, SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SRPEnergyConfigEntry) -> bool: """Set up the SRP Energy component from a config entry.""" api_account_id: str = entry.data[CONF_ID] api_username: str = entry.data[CONF_USERNAME] @@ -30,17 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SRPEnergyConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index 9e32d935e80..55915bd1d00 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for SRP Energy.""" -from __future__ import annotations - from typing import Any from srpenergy.client import SrpEnergyClient @@ -88,6 +86,8 @@ class SRPEnergyConfigFlow(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_USER else self._get_reconfigure_entry().data[CONF_ID] ), + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_NAME, default=self.hass.config.location_name ): str, diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index f3821891afa..d5129376c81 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the srp_energy integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta @@ -23,14 +21,19 @@ from .const import ( TIMEOUT = 10 PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) +type SRPEnergyConfigEntry = ConfigEntry[SRPEnergyDataUpdateCoordinator] + class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """A srp_energy Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: SRPEnergyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: SrpEnergyClient + self, + hass: HomeAssistant, + config_entry: SRPEnergyConfigEntry, + client: SrpEnergyClient, ) -> None: """Initialize the srp_energy data coordinator.""" self._client = client diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 89274390411..d2ad6f164f7 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,13 +1,10 @@ """Support for SRP Energy Sensor.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -15,19 +12,17 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SRPEnergyDataUpdateCoordinator from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN +from .coordinator import SRPEnergyConfigEntry, SRPEnergyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SRPEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SRP Energy Usage sensor.""" - coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([SrpEntity(coordinator, entry)]) + async_add_entities([SrpEntity(entry.runtime_data, entry)]) class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity): @@ -43,7 +38,7 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) def __init__( self, coordinator: SRPEnergyDataUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: SRPEnergyConfigEntry, ) -> None: """Initialize the SrpEntity class.""" super().__init__(coordinator) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 97375cb600a..fd5d8be48d2 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -1,7 +1,5 @@ """The SSDP integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import partial from typing import Any @@ -10,7 +8,7 @@ from homeassistant.core import HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo as _SsdpServiceInfo from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_ssdp, bind_hass +from homeassistant.loader import async_get_ssdp from homeassistant.util.logging import catch_log_exception from . import websocket_api @@ -45,7 +43,6 @@ def _format_err(name: str, *args: Any) -> str: return f"Exception in SSDP callback {name}: {args}" -@bind_hass async def async_register_callback( hass: HomeAssistant, callback: Callable[ @@ -68,7 +65,6 @@ async def async_register_callback( return await scanner.async_register_callback(job, match_dict) -@bind_hass async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str ) -> _SsdpServiceInfo | None: @@ -77,7 +73,6 @@ async def async_get_discovery_info_by_udn_st( return await scanner.async_get_discovery_info_by_udn_st(udn, st) -@bind_hass async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str ) -> list[_SsdpServiceInfo]: @@ -86,7 +81,6 @@ async def async_get_discovery_info_by_st( return await scanner.async_get_discovery_info_by_st(st) -@bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str ) -> list[_SsdpServiceInfo]: diff --git a/homeassistant/components/ssdp/common.py b/homeassistant/components/ssdp/common.py index f1b961341f4..56da600fd87 100644 --- a/homeassistant/components/ssdp/common.py +++ b/homeassistant/components/ssdp/common.py @@ -1,7 +1,5 @@ """Common functions for SSDP discovery.""" -from __future__ import annotations - from ipaddress import IPv4Address, IPv6Address from typing import cast diff --git a/homeassistant/components/ssdp/const.py b/homeassistant/components/ssdp/const.py index ee5f1c240c6..d36889faa64 100644 --- a/homeassistant/components/ssdp/const.py +++ b/homeassistant/components/ssdp/const.py @@ -1,7 +1,5 @@ """Constants for the SSDP integration.""" -from __future__ import annotations - DOMAIN = "ssdp" SSDP_SCANNER = "scanner" UPNP_SERVER = "server" diff --git a/homeassistant/components/ssdp/scanner.py b/homeassistant/components/ssdp/scanner.py index f5b92483120..b1929d9bc8d 100644 --- a/homeassistant/components/ssdp/scanner.py +++ b/homeassistant/components/ssdp/scanner.py @@ -1,7 +1,5 @@ """The SSDP integration scanner.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine, Mapping from datetime import timedelta @@ -369,7 +367,7 @@ class Scanner: callbacks = self._async_get_matching_callbacks(combined_headers) # If there are no changes from a search, do not trigger a config flow - if source != SsdpSource.SEARCH_ALIVE: + if source is not SsdpSource.SEARCH_ALIVE: matching_domains = self.integration_matchers.async_matching_domains( CaseInsensitiveDict(combined_headers.as_dict(), **info_desc) ) @@ -377,7 +375,7 @@ class Scanner: if ( not callbacks and not matching_domains - and source != SsdpSource.ADVERTISEMENT_BYEBYE + and source is not SsdpSource.ADVERTISEMENT_BYEBYE ): return @@ -390,8 +388,9 @@ class Scanner: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) - # Config flows should only be created for alive/update messages from alive devices - if source == SsdpSource.ADVERTISEMENT_BYEBYE: + # Config flows should only be created for alive/update + # messages from alive devices + if source is SsdpSource.ADVERTISEMENT_BYEBYE: self._async_dismiss_discoveries(discovery_info) return diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index 01756d3f06b..4b01c9ff2c1 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -1,7 +1,5 @@ """The SSDP integration server.""" -from __future__ import annotations - import asyncio from contextlib import ExitStack from ipaddress import IPv6Address @@ -126,7 +124,11 @@ class Server: async def _async_get_instance_udn(self) -> str: """Get Unique Device Name for this instance.""" instance_id = await async_get_instance_id(self.hass) - return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() + return ( + f"uuid:{instance_id[0:8]}-{instance_id[8:12]}" + f"-{instance_id[12:16]}-{instance_id[16:20]}" + f"-{instance_id[20:32]}" + ).upper() async def _async_start_upnp_servers(self, event: Event) -> None: """Start the UPnP/SSDP servers.""" diff --git a/homeassistant/components/ssdp/websocket_api.py b/homeassistant/components/ssdp/websocket_api.py index 5342ec8035b..30e28825623 100644 --- a/homeassistant/components/ssdp/websocket_api.py +++ b/homeassistant/components/ssdp/websocket_api.py @@ -1,7 +1,5 @@ """The ssdp integration websocket apis.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any, Final diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py index 17f3b7dc504..5ae3994632f 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -1,7 +1,5 @@ """The StarLine component.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,8 +20,10 @@ from .const import ( SERVICE_UPDATE_STATE, ) +type StarlineConfigEntry = ConfigEntry[StarlineAccount] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> bool: """Set up the StarLine device from a config entry.""" account = StarlineAccount(hass, entry) await account.update() @@ -31,9 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not account.api.available: raise ConfigEntryNotReady - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = account + entry.runtime_data = account device_registry = dr.async_get(hass) for device in account.api.devices.values(): @@ -60,7 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await account.update() await account.update_obd() + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, async_update) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_SCAN_INTERVAL, @@ -73,6 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } ), ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_SCAN_OBD_INTERVAL, @@ -92,20 +93,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlineConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] - account.unload() + config_entry.runtime_data.unload() return unload_ok -async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_options_updated( + hass: HomeAssistant, config_entry: StarlineConfigEntry +) -> None: """Triggered by config entry options updates.""" - account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] + account = config_entry.runtime_data scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) scan_obd_interval = config_entry.options.get( CONF_SCAN_OBD_INTERVAL, DEFAULT_SCAN_OBD_INTERVAL diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 0fb5a367148..470b6b29b76 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -1,7 +1,5 @@ """StarLine Account.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index faec8974ed1..1f83bd8e661 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -1,19 +1,16 @@ """Reads vehicle status from StarLine API.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -71,11 +68,11 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ sensor for device in account.api.devices.values() diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index fd449607f52..f24e8875ae4 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -1,14 +1,11 @@ """Support for StarLine button.""" -from __future__ import annotations - from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( @@ -35,11 +32,11 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine button.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data async_add_entities( StarlineButton(account, device, description) for device in account.api.devices.values() diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 0f1983fc21d..6fc8f8d9a6c 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure StarLine component.""" -from __future__ import annotations - from starline import StarlineAuth import voluptuous as vol @@ -194,6 +192,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): self._app_code = await self.hass.async_add_executor_job( self._auth.get_app_code, self._app_id, self._app_secret ) + # pylint: disable-next=home-assistant-sequential-executor-jobs self._app_token = await self.hass.async_add_executor_job( self._auth.get_app_token, self._app_id, self._app_secret, self._app_code ) diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index d6e12b4ecd9..cb9444d579a 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -3,23 +3,22 @@ from typing import Any from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up StarLine entry.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data async_add_entities( StarlineDeviceTracker(account, device) for device in account.api.devices.values() diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index f940971c15c..4dc45d82e9c 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -1,7 +1,5 @@ """StarLine base entity.""" -from __future__ import annotations - from homeassistant.helpers.entity import Entity from .account import StarlineAccount, StarlineDevice diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 43886d63962..69b2b921cad 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -1,26 +1,23 @@ """Support for StarLine lock.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine lock.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [] for device in account.api.devices.values(): if device.support_state: diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 5fff61144dc..3ba9ad3b3b3 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,7 +1,5 @@ """Reads vehicle status from StarLine API.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( @@ -10,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -23,8 +20,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -63,7 +60,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="fuel", translation_key="fuel", - # No device_class: fuel can be reported as percentage or volume depending on vehicle + # No device_class: fuel can be reported as percentage + # or volume depending on vehicle state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( @@ -91,11 +89,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ sensor for device in account.api.devices.values() diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 3a457c6ffde..0dc94f39d78 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -1,16 +1,13 @@ """Support for StarLine switch.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -35,11 +32,11 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine switch.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ switch for device in account.api.devices.values() diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 063919179ac..9f83e70fb4f 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -1,7 +1,5 @@ """Support for balance data via the Starling Bank API.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 0c512bb21c5..c29f9762d4a 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -1,7 +1,5 @@ """The Starlink integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index e06e79009c3..18d2fbc04c6 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -1,7 +1,5 @@ """Contains binary sensors exposed by the Starlink integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index 15f35659b49..1ccc23f4db1 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -1,7 +1,5 @@ """Contains buttons exposed by the Starlink integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/starlink/config_flow.py b/homeassistant/components/starlink/config_flow.py index a64d5998556..a0f1fb04d2a 100644 --- a/homeassistant/components/starlink/config_flow.py +++ b/homeassistant/components/starlink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Starlink.""" -from __future__ import annotations - from typing import Any from starlink_grpc import ChannelContext, GrpcError, get_id diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 1b92720235a..418d91437de 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -1,7 +1,5 @@ """Contains the shared Coordinator for Starlink systems.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import timedelta @@ -142,7 +140,8 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Set Starlink system sleep schedule end time.""" duration = end - self.data.sleep[0] if duration < 0: - # If the duration pushed us into the next day, add one days worth to correct that. + # If the duration pushed us into the next day, + # add one days worth to correct that. duration += 1440 async with asyncio.timeout(4): try: diff --git a/homeassistant/components/starlink/entity.py b/homeassistant/components/starlink/entity.py index e868e4f0645..31390544844 100644 --- a/homeassistant/components/starlink/entity.py +++ b/homeassistant/components/starlink/entity.py @@ -1,7 +1,5 @@ """Contains base entity classes for Starlink entities.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 81913a997ea..e69e04d341b 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -1,7 +1,5 @@ """Contains sensors exposed by the Starlink integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -66,7 +64,7 @@ class StarlinkSensorEntity(StarlinkEntity, SensorEntity): class StarlinkAccumulationSensor(StarlinkSensorEntity, RestoreSensor): - """A StarlinkAccumulationSensor for Starlink devices. Handles creating unique IDs.""" + """A StarlinkAccumulationSensor for Starlink devices.""" _attr_native_value: int | float = 0 diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index c6dc237643e..203bcb30ecc 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -1,7 +1,5 @@ """Contains switches exposed by the Starlink integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 9f564333218..f072810cbfa 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -1,7 +1,5 @@ """Contains time pickers exposed by the Starlink integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, time, tzinfo @@ -65,6 +63,7 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time: hour -= 24 minute = utc_minutes % 60 try: + # pylint: disable-next=home-assistant-enforce-utcnow utc = datetime.now(UTC).replace( hour=hour, minute=minute, second=0, microsecond=0 ) diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json index add795cea92..deec3209776 100644 --- a/homeassistant/components/startca/manifest.json +++ b/homeassistant/components/startca/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/startca", "iot_class": "cloud_polling", "quality_scale": "legacy", - "requirements": ["xmltodict==1.0.2"] + "requirements": ["xmltodict==1.0.4"] } diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 9b927803749..68a0a245838 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -1,7 +1,5 @@ """Support for Start.ca Bandwidth Monitor.""" -from __future__ import annotations - import asyncio from datetime import timedelta from http import HTTPStatus diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 0375ab10777..9228bc927dc 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -1,7 +1,5 @@ """Config flow for statistics.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from typing import Any, cast diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 14471ab16ee..dc2147978ba 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -1,7 +1,5 @@ """Support for statistics for sensor values.""" -from __future__ import annotations - from collections import deque from collections.abc import Callable, Mapping import contextlib @@ -527,7 +525,7 @@ ICON = "mdi:calculator" def valid_state_characteristic_configuration(config: dict[str, Any]) -> dict[str, Any]: - """Validate that the characteristic selected is valid for the source sensor type, throw if it isn't.""" + """Validate characteristic is valid for source sensor type.""" is_binary = split_entity_id(config[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN characteristic = cast(str, config[CONF_STATE_CHARACTERISTIC]) if (is_binary and characteristic not in STATS_BINARY_SUPPORT) or ( @@ -558,7 +556,8 @@ def valid_keep_last_sample(config: dict[str, Any]) -> dict[str, Any]: if config.get(CONF_KEEP_LAST_SAMPLE) is True and config.get(CONF_MAX_AGE) is None: raise vol.RequiredFieldInvalid( - "The sensor configuration must provide 'max_age' if 'keep_last_sample' is True" + "The sensor configuration must provide 'max_age'" + " if 'keep_last_sample' is True" ) return config @@ -934,7 +933,8 @@ class StatisticsSensor(SensorEntity): while self.ages and (now_timestamp - self.ages[0]) > max_age: if self.samples_keep_last and len(self.ages) == 1: - # Under normal circumstance this will not be executed, as a purge will not + # Under normal circumstance this will not be + # executed, as a purge will not # be scheduled for the last value if samples_keep_last is enabled. # If this happens to be called outside normal scheduling logic or a # source sensor update, this ensures the last value is preserved. @@ -1098,7 +1098,8 @@ class StatisticsSensor(SensorEntity): def _update_value(self) -> None: """Front to call the right statistical characteristics functions. - One of the _stat_*() functions is represented by self._state_characteristic_fn(). + One of the _stat_*() functions is represented by + self._state_characteristic_fn(). """ value = self._state_characteristic_fn(self.states, self.ages, self._percentile) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 7a2c32cb4d5..838b0b450e1 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -1,7 +1,5 @@ """The Steam integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 57c75f0a704..c0071af4a61 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Steam integration.""" -from __future__ import annotations - from collections.abc import Iterator, Mapping from typing import Any diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 731154183ed..f464fdc8dde 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Steam integration.""" -from __future__ import annotations - from datetime import timedelta import steam diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index c1e20933185..30f52832c60 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -1,7 +1,5 @@ """Sensor for Steam account status.""" -from __future__ import annotations - from datetime import datetime from time import localtime, mktime from typing import cast diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 380f25ea8da..92904ec5fd1 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -1,7 +1,5 @@ """The Steamist integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index ee4b412cbbd..b8127ab614a 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Steamist integration.""" -from __future__ import annotations - import logging from typing import Any, Self diff --git a/homeassistant/components/steamist/coordinator.py b/homeassistant/components/steamist/coordinator.py index 3f864364be7..4f354a85aca 100644 --- a/homeassistant/components/steamist/coordinator.py +++ b/homeassistant/components/steamist/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for steamist.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/steamist/discovery.py b/homeassistant/components/steamist/discovery.py index 2abe2343f99..5fe5364e772 100644 --- a/homeassistant/components/steamist/discovery.py +++ b/homeassistant/components/steamist/discovery.py @@ -1,7 +1,5 @@ """The Steamist integration discovery.""" -from __future__ import annotations - import asyncio import logging from typing import Any @@ -114,6 +112,8 @@ async def async_discover_device(hass: HomeAssistant, host: str) -> Device30303 | @callback def async_get_discovery(hass: HomeAssistant, host: str) -> Device30303 | None: """Check if a device was already discovered via a broadcast discovery.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data discoveries: list[Device30303] = hass.data[DOMAIN][DISCOVERY] return async_find_discovery_by_ip(discoveries, host) diff --git a/homeassistant/components/steamist/entity.py b/homeassistant/components/steamist/entity.py index aef2d652058..5a7c3a879d8 100644 --- a/homeassistant/components/steamist/entity.py +++ b/homeassistant/components/steamist/entity.py @@ -1,7 +1,5 @@ """Support for Steamist sensors.""" -from __future__ import annotations - from aiosteamist import SteamistStatus from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 94e3ff86ee1..b5f613fcd5f 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -1,7 +1,5 @@ """Support for Steamist sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -61,6 +59,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index 17e1d6d47ac..faae0165b33 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -1,7 +1,5 @@ """Support for Steamist switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -25,6 +23,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index f10ef0df667..35331ab8c23 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -1,7 +1,5 @@ """Support for stiebel_eltron climate platform.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index 022fa50805a..14c701138df 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the STIEBEL ELTRON integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index b19c6404ab5..6e664847b85 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -1,7 +1,5 @@ """The Stookwijzer integration.""" -from __future__ import annotations - from typing import Any from stookwijzer import Stookwijzer diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index ff14bce26e6..004afb9dfd7 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Stookwijzer integration.""" -from __future__ import annotations - from typing import Any from stookwijzer import Stookwijzer diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 1f3ef4ee4ba..2b4fc1869fc 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Stookwijzer.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 91224b711be..b3857eb5b5e 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -1,7 +1,5 @@ """Support for Stookwijzer Sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index a31ce433c06..6043b53a546 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -15,8 +15,6 @@ tokens are expired. Alternatively, a Stream can be configured with keepalive to always keep workers active. """ -from __future__ import annotations - import asyncio from collections.abc import Callable, Mapping import copy @@ -187,8 +185,10 @@ def create_stream( ) -> Stream: """Create a stream with the specified identifier based on the source url. - The stream_source is typically an rtsp url (though any url accepted by ffmpeg is fine) and - options (see STREAM_OPTIONS_SCHEMA) are converted and passed into pyav / ffmpeg. + The stream_source is typically an rtsp url (though any + url accepted by ffmpeg is fine) and options (see + STREAM_OPTIONS_SCHEMA) are converted and passed into + pyav / ffmpeg. The stream_label is a string used as an additional message in logging. """ @@ -462,7 +462,8 @@ class Stream: def _run_worker(self) -> None: """Handle consuming streams and restart keepalive streams.""" - # Keep import here so that we can import stream integration without installing reqs + # Keep import here so that we can import stream + # integration without installing reqs from .worker import StreamState, stream_worker # noqa: PLC0415 stream_state = StreamState(self.hass, self.outputs, self._diagnostics) @@ -493,7 +494,8 @@ class Stream: stream_state.discontinuity() if not _should_retry() or self._thread_quit.is_set(): if self._fast_restart_once: - # The stream source is updated, restart without any delay and reset the retry + # The stream source is updated, restart + # without any delay and reset the retry # backoff for the new url. wait_timeout = 0 self._fast_restart_once = False @@ -503,8 +505,9 @@ class Stream: self._set_state(False) # To avoid excessive restarts, wait before restarting - # As the required recovery time may be different for different setups, start - # with trying a short wait_timeout and increase it on each reconnection attempt. + # As the required recovery time may be different + # for different setups, start with trying a short + # wait_timeout and increase it on each attempt. # Reset the wait_timeout after the worker has been up for several minutes if time.time() - start_time > STREAM_RESTART_RESET_TIME: wait_timeout = 0 @@ -517,9 +520,10 @@ class Stream: ) async def worker_finished() -> None: - # The worker is no checking availability of the stream and can no longer track - # availability so mark it as available, otherwise the frontend may not be able to - # interact with the stream. + # The worker is no longer checking availability + # of the stream and can no longer track it so + # mark it as available, otherwise the frontend + # may not be able to interact with the stream. if not self.available: self._async_update_state(True) # We can call remove_provider() sequentially as the wrapped _stop() function @@ -557,7 +561,8 @@ class Stream: ) -> None: """Make a .mp4 recording from a provided stream.""" - # Keep import here so that we can import stream integration without installing reqs + # Keep import here so that we can import stream + # integration without installing reqs from .recorder import RecorderOutput # noqa: PLC0415 # Check for file access diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index df50ecefd62..caba25a4da0 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -1,7 +1,5 @@ """Constants for Stream component.""" -from __future__ import annotations - from enum import IntEnum from typing import Final diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 2ee49edb23e..3a3d9f9a75c 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -1,7 +1,5 @@ """Provides core stream functionality.""" -from __future__ import annotations - import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable @@ -200,7 +198,7 @@ class Segment: def render_hls( self, last_stream_id: int, render_parts: bool, add_hint: bool ) -> str: - """Render the HLS playlist section for the Segment including a hint if requested.""" + """Render the Segment HLS playlist, optionally including parts and a hint.""" playlist_template = self._render_hls_template(last_stream_id, render_parts) playlist = playlist_template.format( self.hls_playlist_parts[0] if render_parts else "" @@ -419,15 +417,17 @@ TRANSFORM_IMAGE_FUNCTION = ( class KeyFrameConverter: - """Enables generating and getting an image from the last keyframe seen in the stream. + """Generate and get an image from the last keyframe. An overview of the thread and state interaction: the worker thread sets a packet get_image is called from the main asyncio loop - get_image schedules _generate_image in an executor thread - _generate_image will try to create an image from the packet - _generate_image will clear the packet, so there will only be one attempt per packet - If successful, self._image will be updated and returned by get_image + get_image schedules _generate_image in an executor + _generate_image will try to create an image from + the packet + _generate_image will clear the packet, so there + will only be one attempt per packet + If successful, self._image will be updated and returned If unsuccessful, get_image will return the previous image """ diff --git a/homeassistant/components/stream/diagnostics.py b/homeassistant/components/stream/diagnostics.py index 13fd70cc957..31aa428d4bb 100644 --- a/homeassistant/components/stream/diagnostics.py +++ b/homeassistant/components/stream/diagnostics.py @@ -4,8 +4,6 @@ The stream component does not have config entries itself, and all diagnostics information is managed by dependent components (e.g. camera) """ -from __future__ import annotations - from collections import Counter from typing import Any diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 3d2c40c752b..7d8f431d905 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -1,7 +1,5 @@ """Utilities to help convert mp4s to fmp4s.""" -from __future__ import annotations - from collections.abc import Generator from typing import TYPE_CHECKING diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 32845840f38..796b617bb86 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -1,7 +1,5 @@ """Provide functionality to stream HLS.""" -from __future__ import annotations - from http import HTTPStatus from typing import TYPE_CHECKING, cast @@ -121,8 +119,10 @@ class HlsMasterPlaylistView(StreamView): @staticmethod def render(track: StreamOutput) -> str: """Render M3U8 file.""" - # Need to calculate max bandwidth as input_container.bit_rate doesn't seem to work - # Calculate file size / duration and use a small multiplier to account for variation + # Need to calculate max bandwidth as + # input_container.bit_rate doesn't seem to work. + # Calculate file size / duration and use a small + # multiplier to account for variation # hls spec already allows for 25% variation if not (segment := track.get_segment(track.sequences[-2])): return "" @@ -186,15 +186,15 @@ class HlsPlaylistView(StreamView): ] if track.stream_settings.ll_hls: + part_dur = track.stream_settings.part_target_duration + start_offset = EXT_X_START_LL_HLS * part_dur playlist.extend( [ "#EXT-X-PART-INF:PART-TARGET=" f"{track.stream_settings.part_target_duration:.3f}", "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" f"{2 * track.stream_settings.part_target_duration:.3f}", - "#EXT-X-START:TIME-OFFSET=-" - f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}" - ",PRECISE=YES", + f"#EXT-X-START:TIME-OFFSET=-{start_offset:.3f},PRECISE=YES", ] ) else: diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6d2ca7865f9..b9cc560699d 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.0", "av==16.0.1", "numpy==2.3.2"] + "requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.3.2"] } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index df80774d595..fdddeb36be8 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -1,7 +1,5 @@ """Provide functionality to record stream.""" -from __future__ import annotations - from collections import deque from io import DEFAULT_BUFFER_SIZE, BytesIO import logging @@ -82,10 +80,12 @@ class RecorderOutput(StreamOutput): def write_segment(segment: Segment) -> None: """Write a segment to output.""" # fmt: off - nonlocal output, output_v, output_a, last_stream_id, running_duration, last_sequence + nonlocal output, output_v, output_a + nonlocal last_stream_id, running_duration, last_sequence # fmt: on - # Because the stream_worker is in a different thread from the record service, - # the lookback segments may still have some overlap with the recorder segments + # Because the stream_worker is in a different + # thread from the record service, the lookback + # segments may still overlap with recorder ones if segment.sequence <= last_sequence: return last_sequence = segment.sequence @@ -169,11 +169,7 @@ class RecorderOutput(StreamOutput): out_file.write(chunk) os.remove(video_path + ".tmp") - def finish_writing( - segments: deque[Segment], - output: av.container.OutputContainer | None, - video_path: str, - ) -> None: + def finish_writing(segments: deque[Segment]) -> None: """Finish writing output.""" # Should only have 0 or 1 segments, but loop through just in case while segments: @@ -183,14 +179,14 @@ class RecorderOutput(StreamOutput): return output.close() try: - write_transform_matrix_and_rename(video_path) + write_transform_matrix_and_rename(self.video_path) except FileNotFoundError: _LOGGER.error( ( "Error writing to '%s'. There are likely multiple recordings" " writing to the same file" ), - video_path, + self.video_path, ) # Write lookback segments @@ -208,6 +204,4 @@ class RecorderOutput(StreamOutput): write_segment, self._segments.popleft() ) # Write remaining segments and close output - await self._hass.async_add_executor_job( - finish_writing, self._segments, output, self.video_path - ) + await self._hass.async_add_executor_job(finish_writing, self._segments) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index f2d59c7e090..6def655ca96 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -1,7 +1,5 @@ """Provides the worker thread needed for processing streams.""" -from __future__ import annotations - from collections import defaultdict, deque from collections.abc import Callable, Generator, Iterator, Mapping import contextlib @@ -15,6 +13,7 @@ from typing import Any, Self, cast import av import av.audio +from av.codec.codec import UnknownCodecError # pylint: disable=no-name-in-module import av.container from av.container import InputContainer import av.stream @@ -152,6 +151,23 @@ class StreamMuxer: self._stream_state = stream_state self._start_time = dt_util.utcnow() + @staticmethod + def _add_stream_from_template( + container: av.container.OutputContainer, + template: av.stream.Stream, + ) -> av.stream.Stream: + """Add a stream to the output container from a template. + + Decoder-only codecs (e.g., libdav1d for AV1) have no matching + encoder, causing add_stream_from_template to fail. Retrying with + opaque=True bypasses the encoder lookup and copies codec parameters + directly from the template, which is sufficient for remuxing. + """ + try: + return container.add_stream_from_template(template) + except UnknownCodecError: + return container.add_stream_from_template(template, opaque=True) + def make_new_av( self, memory_file: BytesIO, @@ -169,7 +185,11 @@ class StreamMuxer: # https://github.com/home-assistant/core/pull/39970 # "cmaf" flag replaces several of the movflags used, # but too recent to use for now - "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", + "movflags": ( + "frag_custom+empty_moov+default_base_moof" + "+frag_discont+negative_cts_offsets" + "+skip_trailer+delay_moov" + ), # Sometimes the first segment begins with negative timestamps, # and this setting just # adjusts the timestamps in the output from that segment to start @@ -183,7 +203,12 @@ class StreamMuxer: # Fragment durations may exceed the 15% allowed variance but it seems ok **( { - "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", + "movflags": ( + "empty_moov+default_base_moof" + "+frag_discont" + "+negative_cts_offsets" + "+skip_trailer+delay_moov" + ), # Create a fragment every TARGET_PART_DURATION. The data from # each fragment is stored in a "Part" that can be combined with # the data from all the other "Part"s, plus an init section, @@ -223,7 +248,10 @@ class StreamMuxer: format=SEGMENT_CONTAINER_FORMAT, container_options=container_options, ) - output_vstream = container.add_stream_from_template(input_vstream) + output_vstream = cast( + av.VideoStream, + self._add_stream_from_template(container, input_vstream), + ) # Check if audio is requested output_astream = None if input_astream: @@ -231,7 +259,10 @@ class StreamMuxer: self._audio_bsf_context = av.BitStreamFilterContext( self._audio_bsf, input_astream ) - output_astream = container.add_stream_from_template(input_astream) + output_astream = cast( + av.audio.AudioStream, + self._add_stream_from_template(container, input_astream), + ) return container, output_vstream, output_astream def reset(self, video_dts: int) -> None: @@ -640,7 +671,8 @@ def stream_worker( except av.FFmpegError as ex: container.close() raise StreamWorkerError( - f"Error demuxing stream while finding first packet ({redact_av_error_string(ex)})" + "Error demuxing stream while finding first packet" + f" ({redact_av_error_string(ex)})" ) from ex muxer = StreamMuxer( diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 1c1357a9b2b..efbf973476b 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -3,13 +3,12 @@ from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .coordinator import StreamlabsCoordinator +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" @@ -30,7 +29,7 @@ SET_AWAY_MODE_SCHEMA = vol.Schema( PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" api_key = entry.data[CONF_API_KEY] @@ -39,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def set_away_mode(service: ServiceCall) -> None: @@ -48,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0] client.update_location(location_id, away_mode) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA ) @@ -55,9 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index e3e966edde0..5f75c2ab1a4 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -1,24 +1,20 @@ """Support for Streamlabs Water Monitor Away Mode.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import StreamlabsCoordinator -from .const import DOMAIN +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator from .entity import StreamlabsWaterEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StreamlabsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water binary sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( StreamlabsAwayMode(coordinator, location_id) for location_id in coordinator.data diff --git a/homeassistant/components/streamlabswater/config_flow.py b/homeassistant/components/streamlabswater/config_flow.py index e931a7cf3ba..52185061e74 100644 --- a/homeassistant/components/streamlabswater/config_flow.py +++ b/homeassistant/components/streamlabswater/config_flow.py @@ -1,7 +1,5 @@ """Config flow for StreamLabs integration.""" -from __future__ import annotations - from typing import Any from streamlabswater.streamlabswater import StreamlabsClient diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index df4a6056b36..d038a3657b8 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -23,15 +23,18 @@ class StreamlabsData: yearly_usage: float +type StreamlabsConfigEntry = ConfigEntry[StreamlabsCoordinator] + + class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): """Coordinator for Streamlabs.""" - config_entry: ConfigEntry + config_entry: StreamlabsConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: StreamlabsConfigEntry, client: StreamlabsClient, ) -> None: """Coordinator for Streamlabs.""" diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index dea3f081326..ddfac710a11 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,7 +1,5 @@ """Support for Streamlabs Water Monitor Usage.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -10,15 +8,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import StreamlabsCoordinator -from .const import DOMAIN -from .coordinator import StreamlabsData +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator, StreamlabsData from .entity import StreamlabsWaterEntity @@ -59,11 +54,11 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StreamlabsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( StreamLabsSensor(coordinator, location_id, entity_description) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index abc828dac3f..435b68033e9 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -1,7 +1,5 @@ """Provide functionality to STT.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import AsyncIterable from dataclasses import asdict @@ -46,7 +44,12 @@ from .legacy import ( async_get_provider, async_setup_legacy, ) -from .models import SpeechMetadata, SpeechResult +from .models import ( + DEFAULT_AUDIO_PROCESSING, + SpeechAudioProcessing, + SpeechMetadata, + SpeechResult, +) __all__ = [ "DOMAIN", @@ -197,6 +200,11 @@ class SpeechToTextEntity(RestoreEntity): def supported_channels(self) -> list[AudioChannels]: """Return a list of supported channels.""" + @property + def audio_processing(self) -> SpeechAudioProcessing: + """Return required/preferred input audio processing settings.""" + return DEFAULT_AUDIO_PROCESSING + async def async_internal_added_to_hass(self) -> None: """Call when the provider entity is added to hass.""" await super().async_internal_added_to_hass() diff --git a/homeassistant/components/stt/const.py b/homeassistant/components/stt/const.py index 16e39d00a34..a46061057e0 100644 --- a/homeassistant/components/stt/const.py +++ b/homeassistant/components/stt/const.py @@ -1,7 +1,5 @@ """STT constante.""" -from __future__ import annotations - from enum import Enum, StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 13144eae5b4..7bbf21227ca 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -1,7 +1,5 @@ """Handle legacy speech-to-text platforms.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Coroutine import logging @@ -26,7 +24,12 @@ from .const import ( AudioFormats, AudioSampleRates, ) -from .models import SpeechMetadata, SpeechResult +from .models import ( + DEFAULT_AUDIO_PROCESSING, + SpeechAudioProcessing, + SpeechMetadata, + SpeechResult, +) _LOGGER = logging.getLogger(__name__) @@ -143,6 +146,11 @@ class Provider(ABC): def supported_channels(self) -> list[AudioChannels]: """Return a list of supported channels.""" + @property + def audio_processing(self) -> SpeechAudioProcessing: + """Return required/preferred input audio processing settings.""" + return DEFAULT_AUDIO_PROCESSING + @abstractmethod async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] diff --git a/homeassistant/components/stt/models.py b/homeassistant/components/stt/models.py index 40b43109778..bfc3041b4cd 100644 --- a/homeassistant/components/stt/models.py +++ b/homeassistant/components/stt/models.py @@ -30,3 +30,27 @@ class SpeechResult: text: str | None result: SpeechResultState + + +@dataclass +class SpeechAudioProcessing: + """Required and preferred input audio processing settings.""" + + requires_external_vad: bool + """True if an external voice activity detector (VAD) is required. + + If False, the speech-to-text entity must detect the end of speech itself. + """ + + prefers_auto_gain_enabled: bool + """True if input audio should adjust gain automatically for best results.""" + + prefers_noise_reduction_enabled: bool + """True if input audio should apply noise reduction for best results.""" + + +DEFAULT_AUDIO_PROCESSING = SpeechAudioProcessing( + requires_external_vad=True, + prefers_auto_gain_enabled=True, + prefers_noise_reduction_enabled=True, +) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 247618a8dcd..8ecf33e8f48 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -4,7 +4,6 @@ import logging from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_COUNTRY, CONF_DEVICE_ID, @@ -19,9 +18,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from .const import ( DOMAIN, - ENTRY_CONTROLLER, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, FETCH_INTERVAL, MANUFACTURER, PLATFORMS, @@ -37,12 +33,16 @@ from .const import ( VEHICLE_NAME, VEHICLE_VIN, ) -from .coordinator import SubaruDataUpdateCoordinator +from .coordinator import ( + SubaruConfigEntry, + SubaruDataUpdateCoordinator, + SubaruRuntimeData, +) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SubaruConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data websession = aiohttp_client.async_create_clientsession(hass) @@ -77,24 +77,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - ENTRY_CONTROLLER: controller, - ENTRY_COORDINATOR: coordinator, - ENTRY_VEHICLES: vehicle_info, - } + entry.runtime_data = SubaruRuntimeData( + controller=controller, + coordinator=coordinator, + vehicles=vehicle_info, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SubaruConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def get_vehicle_info(controller, vin): diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py new file mode 100644 index 00000000000..74f8dc63a95 --- /dev/null +++ b/homeassistant/components/subaru/button.py @@ -0,0 +1,97 @@ +"""Support for Subaru remote service buttons.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from subarulink import Controller as SubaruAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import get_device_info +from .const import ( + SERVICE_REMOTE_START, + SERVICE_REMOTE_STOP, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, + VEHICLE_VIN, +) +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .remote_service import async_call_remote_service + + +@dataclass(frozen=True, kw_only=True) +class SubaruButtonEntityDescription(ButtonEntityDescription): + """Describes a Subaru button entity.""" + + arg: Callable[[dict[str, Any]], str | None] | None = None + + +REMOTE_BUTTONS = [ + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_START, + translation_key="remote_start", + arg=lambda _: "Auto", + ), + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_STOP, + translation_key="remote_stop", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SubaruConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Subaru remote service buttons by config_entry.""" + coordinator = config_entry.runtime_data.coordinator + controller = config_entry.runtime_data.controller + vehicle_info = config_entry.runtime_data.vehicles + async_add_entities( + SubaruButton(vehicle, controller, coordinator, description) + for vehicle in vehicle_info.values() + if vehicle[VEHICLE_HAS_REMOTE_START] or vehicle[VEHICLE_HAS_EV] + for description in REMOTE_BUTTONS + ) + + +class SubaruButton(ButtonEntity): + """Class for a Subaru button.""" + + _attr_has_entity_name = True + entity_description: SubaruButtonEntityDescription + + def __init__( + self, + vehicle_info: dict[str, Any], + controller: SubaruAPI, + coordinator: SubaruDataUpdateCoordinator, + description: SubaruButtonEntityDescription, + ) -> None: + """Initialize the button for the vehicle.""" + self.controller = controller + self.coordinator = coordinator + self.vehicle_info = vehicle_info + self.entity_description = description + vin = vehicle_info[VEHICLE_VIN] + self._attr_unique_id = f"{vin}_{description.key}" + self._attr_device_info = get_device_info(vehicle_info) + + async def async_press(self) -> None: + """Press the button.""" + arg = ( + self.entity_description.arg(self.vehicle_info) + if self.entity_description.arg + else None + ) + await async_call_remote_service( + self.controller, + self.entity_description.key, + self.vehicle_info, + arg, + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 0ef4ed29941..2370a6cd406 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Subaru integration.""" -from __future__ import annotations - from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -15,12 +13,7 @@ from subarulink import ( from subarulink.const import COUNTRY_CAN, COUNTRY_USA import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_COUNTRY, CONF_DEVICE_ID, @@ -32,6 +25,7 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_UPDATE_ENABLED, DOMAIN +from .coordinator import SubaruConfigEntry _LOGGER = logging.getLogger(__name__) CONF_CONTACT_METHOD = "contact_method" @@ -103,7 +97,7 @@ class SubaruConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index d8692e6a8bc..ec08bdde5c8 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -9,11 +9,6 @@ FETCH_INTERVAL = 300 UPDATE_INTERVAL = 7200 CONF_UPDATE_ENABLED = "update_enabled" -# entry fields -ENTRY_CONTROLLER = "controller" -ENTRY_COORDINATOR = "coordinator" -ENTRY_VEHICLES = "vehicles" - # update coordinator name COORDINATOR_NAME = "subaru_data" @@ -37,13 +32,14 @@ API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, ] -SERVICE_LOCK = "lock" -SERVICE_UNLOCK = "unlock" +SERVICE_REMOTE_START = "remote_start" +SERVICE_REMOTE_STOP = "remote_stop" SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" ATTR_DOOR = "door" diff --git a/homeassistant/components/subaru/coordinator.py b/homeassistant/components/subaru/coordinator.py index 73aec22250a..39e65eeadb3 100644 --- a/homeassistant/components/subaru/coordinator.py +++ b/homeassistant/components/subaru/coordinator.py @@ -1,7 +1,6 @@ """Data update coordinator for Subaru.""" -from __future__ import annotations - +from dataclasses import dataclass from datetime import timedelta import logging import time @@ -23,16 +22,27 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type SubaruConfigEntry = ConfigEntry[SubaruRuntimeData] + + +@dataclass +class SubaruRuntimeData: + """Runtime data for Subaru.""" + + controller: SubaruAPI + coordinator: SubaruDataUpdateCoordinator + vehicles: dict[str, dict[str, Any]] + class SubaruDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Subaru data.""" - config_entry: ConfigEntry + config_entry: SubaruConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, *, controller: SubaruAPI, vehicle_info: dict[str, dict[str, Any]], @@ -73,7 +83,8 @@ async def _refresh_subaru_data( for vehicle in vehicle_info.values(): vin = vehicle[VEHICLE_VIN] - # Optionally send an "update" remote command to vehicle (throttled with update_interval) + # Optionally send an "update" remote command to + # vehicle (throttled with update_interval) if config_entry.options.get(CONF_UPDATE_ENABLED, False): await _update_subaru(vehicle, controller) diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 3c5d6487cb5..d298b7f03db 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -1,38 +1,27 @@ """Support for Subaru device tracker.""" -from __future__ import annotations - from typing import Any from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_device_info -from .const import ( - DOMAIN, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, - VEHICLE_HAS_REMOTE_SERVICE, - VEHICLE_STATUS, - VEHICLE_VIN, -) -from .coordinator import SubaruDataUpdateCoordinator +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru device tracker by config_entry.""" - entry: dict = hass.data[DOMAIN][config_entry.entry_id] - coordinator: SubaruDataUpdateCoordinator = entry[ENTRY_COORDINATOR] - vehicle_info: dict = entry[ENTRY_VEHICLES] + coordinator = config_entry.runtime_data.coordinator + vehicle_info = config_entry.runtime_data.vehicles async_add_entities( SubaruDeviceTracker(vehicle, coordinator) for vehicle in vehicle_info.values() diff --git a/homeassistant/components/subaru/diagnostics.py b/homeassistant/components/subaru/diagnostics.py index eec5b01ab56..1db998302d0 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics for the Subaru integration.""" -from __future__ import annotations - from typing import Any from subarulink.const import ( @@ -13,23 +11,23 @@ from subarulink.const import ( ) from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN, ENTRY_CONTROLLER, ENTRY_COORDINATOR, VEHICLE_VIN +from .const import VEHICLE_VIN +from .coordinator import SubaruConfigEntry CONFIG_FIELDS_TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_PIN, CONF_DEVICE_ID] DATA_FIELDS_TO_REDACT = [VEHICLE_VIN, VEHICLE_NAME, LATITUDE, LONGITUDE, ODOMETER] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SubaruConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + coordinator = config_entry.runtime_data.coordinator return { "config_entry": async_redact_data(config_entry.data, CONFIG_FIELDS_TO_REDACT), @@ -42,12 +40,11 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SubaruConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - coordinator = entry[ENTRY_COORDINATOR] - controller = entry[ENTRY_CONTROLLER] + coordinator = config_entry.runtime_data.coordinator + controller = config_entry.runtime_data.controller vin = next(iter(device.identifiers))[1] diff --git a/homeassistant/components/subaru/icons.json b/homeassistant/components/subaru/icons.json index be9628303b7..ffae30aecd3 100644 --- a/homeassistant/components/subaru/icons.json +++ b/homeassistant/components/subaru/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "remote_start": { + "default": "mdi:power" + }, + "remote_stop": { + "default": "mdi:stop-circle-outline" + } + }, "device_tracker": { "location": { "default": "mdi:car" diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 07caa0d6367..b2cba866596 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -6,17 +6,14 @@ from typing import Any import voluptuous as vol from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, get_device_info +from . import get_device_info from .const import ( ATTR_DOOR, - ENTRY_CONTROLLER, - ENTRY_VEHICLES, SERVICE_UNLOCK_SPECIFIC_DOOR, UNLOCK_DOOR_ALL, UNLOCK_VALID_DOORS, @@ -24,6 +21,7 @@ from .const import ( VEHICLE_NAME, VEHICLE_VIN, ) +from .coordinator import SubaruConfigEntry from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -31,13 +29,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru locks by config_entry.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - controller = entry[ENTRY_CONTROLLER] - vehicle_info = entry[ENTRY_VEHICLES] + controller = config_entry.runtime_data.controller + vehicle_info = config_entry.runtime_data.vehicles async_add_entities( SubaruLock(vehicle, controller) for vehicle in vehicle_info.values() @@ -56,7 +53,9 @@ async def async_setup_entry( class SubaruLock(LockEntity): """Representation of a Subaru door lock. - Note that the Subaru API currently does not support returning the status of the locks. Lock status is always unknown. + Note that the Subaru API currently does not support + returning the status of the locks. Lock status is + always unknown. """ _attr_has_entity_name = True diff --git a/homeassistant/components/subaru/remote_service.py b/homeassistant/components/subaru/remote_service.py index acd71e186da..56f028a07cd 100644 --- a/homeassistant/components/subaru/remote_service.py +++ b/homeassistant/components/subaru/remote_service.py @@ -4,9 +4,10 @@ import logging from subarulink.exceptions import SubaruException +from homeassistant.const import SERVICE_UNLOCK from homeassistant.exceptions import HomeAssistantError -from .const import SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN +from .const import SERVICE_REMOTE_START, VEHICLE_NAME, VEHICLE_VIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +21,7 @@ async def async_call_remote_service(controller, cmd, vehicle_info, arg=None): success = False err_msg = "" try: - if cmd == SERVICE_UNLOCK: + if cmd in (SERVICE_UNLOCK, SERVICE_REMOTE_START): success = await getattr(controller, cmd)(vin, arg) else: success = await getattr(controller, cmd)(vin) diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 880e0043fa8..f75e083a076 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -1,7 +1,5 @@ """Support for Subaru sensors.""" -from __future__ import annotations - import logging from typing import Any @@ -26,15 +24,12 @@ from . import get_device_info from .const import ( API_GEN_2, API_GEN_3, - DOMAIN, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_STATUS, VEHICLE_VIN, ) -from .coordinator import SubaruDataUpdateCoordinator +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -138,13 +133,12 @@ EV_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru sensors by config_entry.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - coordinator = entry[ENTRY_COORDINATOR] - vehicle_info = entry[ENTRY_VEHICLES] + coordinator = config_entry.runtime_data.coordinator + vehicle_info = config_entry.runtime_data.vehicles entities = [] await _async_migrate_entries(hass, config_entry) for info in vehicle_info.values(): diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 699dca1f05d..5e72848e46b 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -47,6 +47,14 @@ } }, "entity": { + "button": { + "remote_start": { + "name": "Remote start" + }, + "remote_stop": { + "name": "Remote stop" + } + }, "lock": { "door_locks": { "name": "Door locks" diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 30f8c030c26..2104b49fd70 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -1,7 +1,5 @@ """The Suez Water integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index fb8bc2988d6..f1f198f9d9d 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Suez Water integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 9bbe24abb59..3f0e7b22230 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -1,7 +1,5 @@ """Sensor for Suez Water Consumption data.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import asdict, dataclass from typing import Any diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 0faa1db379d..c33459cec5f 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -1,7 +1,5 @@ """Support for functionality to keep track of the sun.""" -from __future__ import annotations - import logging from homeassistant.config_entries import SOURCE_IMPORT diff --git a/homeassistant/components/sun/binary_sensor.py b/homeassistant/components/sun/binary_sensor.py index 962a385191c..f33b38f2b60 100644 --- a/homeassistant/components/sun/binary_sensor.py +++ b/homeassistant/components/sun/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Sun integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 40a6eb652c4..e8226daed9a 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -1,7 +1,5 @@ """Offer sun based automation rules.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any, Unpack, cast @@ -13,7 +11,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckParams, ConditionConfig, condition_trace_set_result, @@ -151,19 +148,20 @@ class SunCondition(Condition): super().__init__(hass, config) assert config.options is not None self._options = config.options + self._before = self._options.get("before") + self._after = self._options.get("after") + self._before_offset = self._options.get("before_offset") + self._after_offset = self._options.get("after_offset") - async def async_get_checker(self) -> ConditionChecker: - """Wrap action method with sun based condition.""" - before = self._options.get("before") - after = self._options.get("after") - before_offset = self._options.get("before_offset") - after_offset = self._options.get("after_offset") - - def sun_if(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Validate time based if-condition.""" - return sun(self._hass, before, after, before_offset, after_offset) - - return sun_if + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Check the condition.""" + return sun( + self._hass, + self._before, + self._after, + self._before_offset, + self._after_offset, + ) CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/sun/config_flow.py b/homeassistant/components/sun/config_flow.py index 16c465be8ad..c6676823b7d 100644 --- a/homeassistant/components/sun/config_flow.py +++ b/homeassistant/components/sun/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Sun integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py index 4070190e52a..d4ec7675b28 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -1,7 +1,5 @@ """Support for functionality to keep track of the sun.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import Any diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 3e2b6fdf6ed..8660f051ea0 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Sun integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 56b85bafb98..6a13d3c5d1e 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -1,7 +1,5 @@ """The Sunricher DALI integration.""" -from __future__ import annotations - import asyncio from collections.abc import Sequence import logging diff --git a/homeassistant/components/sunricher_dali/binary_sensor.py b/homeassistant/components/sunricher_dali/binary_sensor.py index a2a5646d2d0..4f17c5241bd 100644 --- a/homeassistant/components/sunricher_dali/binary_sensor.py +++ b/homeassistant/components/sunricher_dali/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for Sunricher DALI binary sensor entities.""" -from __future__ import annotations - from PySrDaliGateway import CallbackEventType, Device from PySrDaliGateway.helper import is_motion_sensor from PySrDaliGateway.types import MotionState, MotionStatus @@ -81,10 +79,10 @@ class SunricherDaliMotionSensor(DaliDeviceEntity, BinarySensorEntity): def _handle_motion_status(self, status: MotionStatus) -> None: """Handle motion status updates.""" motion_state = status["motion_state"] - if motion_state == MotionState.MOTION: + if motion_state is MotionState.MOTION: self._attr_is_on = True self.schedule_update_ha_state() - elif motion_state == MotionState.NO_MOTION: + elif motion_state is MotionState.NO_MOTION: self._attr_is_on = False self.schedule_update_ha_state() @@ -126,6 +124,6 @@ class SunricherDaliOccupancySensor(DaliDeviceEntity, BinarySensorEntity): if motion_state in _OCCUPANCY_ON_STATES: self._attr_is_on = True self.schedule_update_ha_state() - elif motion_state == MotionState.VACANT: + elif motion_state is MotionState.VACANT: self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/sunricher_dali/button.py b/homeassistant/components/sunricher_dali/button.py index 9ba034924bf..368863d974d 100644 --- a/homeassistant/components/sunricher_dali/button.py +++ b/homeassistant/components/sunricher_dali/button.py @@ -1,7 +1,5 @@ """Support for Sunricher DALI device identify button.""" -from __future__ import annotations - import logging from PySrDaliGateway import Device diff --git a/homeassistant/components/sunricher_dali/config_flow.py b/homeassistant/components/sunricher_dali/config_flow.py index 5d13bc771b7..ae2289c3ca3 100644 --- a/homeassistant/components/sunricher_dali/config_flow.py +++ b/homeassistant/components/sunricher_dali/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Sunricher DALI integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sunricher_dali/diagnostics.py b/homeassistant/components/sunricher_dali/diagnostics.py new file mode 100644 index 00000000000..72eb5c99b3e --- /dev/null +++ b/homeassistant/components/sunricher_dali/diagnostics.py @@ -0,0 +1,96 @@ +"""Diagnostics support for Sunricher DALI.""" + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER +from .types import DaliCenterConfigEntry + +if TYPE_CHECKING: + from PySrDaliGateway import Device, Scene + from PySrDaliGateway.types import SceneDeviceType + +TO_REDACT = { + CONF_HOST, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SERIAL_NUMBER, + "dev_sn", +} + +ALLOWED_ENTRY_KEYS: tuple[str, ...] = ( + CONF_HOST, + CONF_PORT, + CONF_NAME, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SERIAL_NUMBER, +) + + +def _serialize_entry_data(entry: DaliCenterConfigEntry) -> dict[str, Any]: + """Return entry data filtered by the whitelist.""" + return {key: entry.data[key] for key in ALLOWED_ENTRY_KEYS if key in entry.data} + + +def _serialize_device(device: Device) -> dict[str, Any]: + """Return a whitelisted dict view of a Device.""" + return { + "dev_id": device.dev_id, + "unique_id": device.unique_id, + "name": device.name, + "dev_type": device.dev_type, + "channel": device.channel, + "address": device.address, + "status": device.status, + "dev_sn": device.dev_sn, + "area_name": getattr(device, "area_name", None), + "area_id": getattr(device, "area_id", None), + "model": device.model, + } + + +def _serialize_scene(scene: Scene) -> dict[str, Any]: + """Return a whitelisted dict view of a Scene.""" + members: list[SceneDeviceType] = scene.devices + return { + "scene_id": scene.scene_id, + "name": scene.name, + "channel": scene.channel, + "area_id": getattr(scene, "area_id", None), + "unique_id": scene.unique_id, + "device_unique_ids": [member["unique_id"] for member in members], + } + + +def _strip_gw_sn(data: Any, gw_sn: str) -> Any: + """Recursively replace gw_sn in string values and list items.""" + if isinstance(data, dict): + return {key: _strip_gw_sn(value, gw_sn) for key, value in data.items()} + if isinstance(data, list): + return [_strip_gw_sn(item, gw_sn) for item in data] + if isinstance(data, str): + return data.replace(gw_sn, REDACTED) + return data + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: DaliCenterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data + payload = { + "entry_data": _serialize_entry_data(entry), + "devices": [_serialize_device(device) for device in data.devices], + "scenes": [_serialize_scene(scene) for scene in data.scenes], + } + return _strip_gw_sn(async_redact_data(payload, TO_REDACT), data.gateway.gw_sn) diff --git a/homeassistant/components/sunricher_dali/entity.py b/homeassistant/components/sunricher_dali/entity.py index 7cc0da20ca8..a7dc0008b9f 100644 --- a/homeassistant/components/sunricher_dali/entity.py +++ b/homeassistant/components/sunricher_dali/entity.py @@ -1,7 +1,5 @@ """Base entity for Sunricher DALI integration.""" -from __future__ import annotations - import logging from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device diff --git a/homeassistant/components/sunricher_dali/light.py b/homeassistant/components/sunricher_dali/light.py index 33387ad0c7d..ce2f75bb811 100644 --- a/homeassistant/components/sunricher_dali/light.py +++ b/homeassistant/components/sunricher_dali/light.py @@ -1,7 +1,5 @@ """Platform for light integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index d5a76d0d0d8..7ceff556cf0 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["PySrDaliGateway==0.19.3"] + "requirements": ["PySrDaliGateway==0.21.0"] } diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 33615ffd869..1c4af840fc3 100644 --- a/homeassistant/components/sunricher_dali/quality_scale.yaml +++ b/homeassistant/components/sunricher_dali/quality_scale.yaml @@ -46,7 +46,7 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: status: exempt diff --git a/homeassistant/components/sunricher_dali/sensor.py b/homeassistant/components/sunricher_dali/sensor.py index 7b82a8d1dc3..01f3f3c50f9 100644 --- a/homeassistant/components/sunricher_dali/sensor.py +++ b/homeassistant/components/sunricher_dali/sensor.py @@ -1,7 +1,5 @@ """Platform for Sunricher DALI sensor entities.""" -from __future__ import annotations - import logging from PySrDaliGateway import CallbackEventType, Device diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index 555e44e7354..02451e1388a 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -1,7 +1,5 @@ """Sensor for Supervisord process status.""" -from __future__ import annotations - import logging from typing import Any import xmlrpc.client diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 0c7a3c354c8..5c4180a38a9 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -1,7 +1,5 @@ """Support for Supla devices.""" -from __future__ import annotations - import logging from asyncpysupla import SuplaAPI diff --git a/homeassistant/components/supla/coordinator.py b/homeassistant/components/supla/coordinator.py index 0e0a4792b51..26cfee955c8 100644 --- a/homeassistant/components/supla/coordinator.py +++ b/homeassistant/components/supla/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Supla integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 37b64c375eb..750f854f4ec 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -1,7 +1,5 @@ """Support for SUPLA covers - curtains, rollershutters, entry gate etc.""" -from __future__ import annotations - import logging from pprint import pformat from typing import Any diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py index 8f4619b0a42..ea5aeebcda8 100644 --- a/homeassistant/components/supla/entity.py +++ b/homeassistant/components/supla/entity.py @@ -1,7 +1,5 @@ """Base class for SUPLA channels.""" -from __future__ import annotations - import logging from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 1c8c4593745..1a321a2d011 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -1,7 +1,5 @@ """Support for SUPLA switch.""" -from __future__ import annotations - import logging from pprint import pformat from typing import Any diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 130242b7742..01d21e1be58 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -1,7 +1,5 @@ """The surepetcare integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -9,22 +7,20 @@ from surepy.enums import Location from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_LOCATION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from .const import ( ATTR_FLAP_ID, - ATTR_LOCATION, ATTR_LOCK_STATE, ATTR_PET_NAME, DOMAIN, SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, ) -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,15 +28,10 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=3) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - try: - hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( - hass, - entry, - ) + coordinator = SurePetcareDataCoordinator(hass, entry) except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") raise ConfigEntryAuthFailed from error @@ -49,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) lock_state_service_schema = vol.Schema( @@ -63,6 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_LOCK_STATE, @@ -81,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } ) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_SET_PET_LOCATION, @@ -91,10 +85,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SurePetcareConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 9600f87437e..b05f24472f1 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Sure PetCare Flaps/Pets binary sensors.""" -from __future__ import annotations - from typing import cast from surepy.entities import SurepyEntity @@ -12,26 +10,24 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" entities: list[SurePetcareBinarySensor] = [] - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for surepy_entity in coordinator.data.values(): # connectivity diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 472d7ac10f0..ccc63677066 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Sure Petcare integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py index e0940b41002..e04a206d4c7 100644 --- a/homeassistant/components/surepetcare/const.py +++ b/homeassistant/components/surepetcare/const.py @@ -18,6 +18,5 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW SERVICE_SET_LOCK_STATE = "set_lock_state" SERVICE_SET_PET_LOCATION = "set_pet_location" ATTR_FLAP_ID = "flap_id" -ATTR_LOCATION = "location" ATTR_LOCK_STATE = "lock_state" ATTR_PET_NAME = "pet_name" diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index d8112cebc90..7432eac2c7f 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the surepetcare integration.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -10,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,7 +16,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( ATTR_FLAP_ID, - ATTR_LOCATION, ATTR_LOCK_STATE, ATTR_PET_NAME, DOMAIN, @@ -29,13 +26,15 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=3) +type SurePetcareConfigEntry = ConfigEntry[SurePetcareDataCoordinator] + class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): """Handle Surepetcare data.""" - config_entry: ConfigEntry + config_entry: SurePetcareConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SurePetcareConfigEntry) -> None: """Initialize the data handler.""" self.surepy = Surepy( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py index 312ae4730b0..b3f9aee26ce 100644 --- a/homeassistant/components/surepetcare/entity.py +++ b/homeassistant/components/surepetcare/entity.py @@ -1,7 +1,5 @@ """Entity for Surepetcare.""" -from __future__ import annotations - from abc import abstractmethod from surepy.entities import SurepyEntity diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index 09fadf8be60..48fe13c34c9 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -1,30 +1,26 @@ """Support for Sure PetCare Flaps locks.""" -from __future__ import annotations - from typing import Any from surepy.entities import SurepyEntity from surepy.enums import EntityType, LockState as SurepyLockState from homeassistant.components.lock import LockEntity, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare locks on a config entry.""" - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SurePetcareLock(surepy_entity.id, coordinator, lock_state) diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index a34675eee74..e8334cf1982 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -1,7 +1,5 @@ """Support for Sure PetCare Flaps/Pets sensors.""" -from __future__ import annotations - from typing import cast from surepy.entities import SurepyEntity @@ -10,26 +8,25 @@ from surepy.entities.pet import Pet as SurepyPet from surepy.enums import EntityType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW -from .coordinator import SurePetcareDataCoordinator +from .const import SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps sensors.""" entities: list[SurePetcareEntity] = [] - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for surepy_entity in coordinator.data.values(): if surepy_entity.type in [ diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index fdec1df6df2..c4631d1249c 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -1,7 +1,5 @@ """Support for hydrological data from the Fed. Office for the Environment.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 49fe9949772..d564947d22b 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -86,6 +86,7 @@ async def async_setup_entry( }, ) from e except OpendataTransportError as e: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise ConfigEntryError( translation_domain=DOMAIN, translation_key="invalid_data", @@ -145,7 +146,8 @@ async def async_migrate_entry( new_unique_id=f"{new_unique_id}_departure", ) _LOGGER.debug( - "Faulty entity with unique_id 'None_departure' migrated to new unique_id '%s'", + "Faulty entity with unique_id 'None_departure'" + " migrated to new unique_id '%s'", f"{new_unique_id}_departure", ) @@ -155,7 +157,9 @@ async def async_migrate_entry( ) if config_entry.version < 3: - # Via stations and time/offset settings now available, which are not backwards compatible if used, changes unique id + # Via stations and time/offset settings now available, + # which are not backwards compatible if used, + # changes unique id hass.config_entries.async_update_entry(config_entry, version=3, minor_version=1) _LOGGER.debug( diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 32b52122c7d..f67fdd1ea1d 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the swiss_public_transport integration.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from typing import TypedDict diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 6475fe802c2..be5bfa1cc35 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -1,7 +1,5 @@ """Support for transport.opendata.ch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/swisscom/__init__.py b/homeassistant/components/swisscom/__init__.py index 5e0c11af090..bb277c1e7c2 100644 --- a/homeassistant/components/swisscom/__init__.py +++ b/homeassistant/components/swisscom/__init__.py @@ -1 +1,23 @@ -"""The swisscom component.""" +"""The Swisscom Internet-Box integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool: + """Set up Swisscom Internet-Box from a config entry.""" + coordinator = SwisscomDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/swisscom/config_flow.py b/homeassistant/components/swisscom/config_flow.py new file mode 100644 index 00000000000..45ee3848e51 --- /dev/null +++ b/homeassistant/components/swisscom/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for the Swisscom Internet-Box integration.""" + +import logging +from typing import Any + +from swisscom_internet_box import ( + SwisscomAuthError, + SwisscomClient, + SwisscomConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SwisscomConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Swisscom Internet-Box.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = SwisscomClient( + async_get_clientsession(self.hass), + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + try: + await client.login() + info = await client.get_box_info() + except SwisscomAuthError: + errors["base"] = "invalid_auth" + except SwisscomConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Swisscom config flow") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(info.base_mac)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info.model_name or "Internet-Box", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/swisscom/const.py b/homeassistant/components/swisscom/const.py new file mode 100644 index 00000000000..9bc400a268e --- /dev/null +++ b/homeassistant/components/swisscom/const.py @@ -0,0 +1,6 @@ +"""Constants for the Swisscom Internet-Box integration.""" + +DOMAIN = "swisscom" + +DEFAULT_HOST = "192.168.1.1" +DEFAULT_USERNAME = "admin" diff --git a/homeassistant/components/swisscom/coordinator.py b/homeassistant/components/swisscom/coordinator.py new file mode 100644 index 00000000000..c59f24dc05e --- /dev/null +++ b/homeassistant/components/swisscom/coordinator.py @@ -0,0 +1,59 @@ +"""DataUpdateCoordinator for the Swisscom Internet-Box.""" + +from datetime import timedelta +import logging + +from swisscom_internet_box import ( + Device, + SwisscomAuthError, + SwisscomClient, + SwisscomConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type SwisscomConfigEntry = ConfigEntry[SwisscomDataUpdateCoordinator] + + +class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): + """Poll the Internet-Box for the list of LAN devices.""" + + config_entry: SwisscomConfigEntry + + def __init__(self, hass: HomeAssistant, entry: SwisscomConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + self.client = SwisscomClient( + async_get_clientsession(hass), + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch device data from the box.""" + try: + devices = await self.client.get_devices() + except SwisscomAuthError as err: + raise ConfigEntryAuthFailed(str(err)) from err + except SwisscomConnectionError as err: + raise UpdateFailed(str(err)) from err + + return {device.key: device for device in devices if device.key} diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 842dc657817..b8acdb1acd8 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -1,113 +1,115 @@ -"""Support for Swisscom routers (Internet-Box).""" +"""Device tracker for the Swisscom Internet-Box.""" -from __future__ import annotations - -from contextlib import suppress -import logging - -import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - DeviceScanner, + AsyncSeeCallback, + ScannerEntity, ) from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) - -DEFAULT_IP = "192.168.1.1" +from .const import DEFAULT_HOST, DOMAIN +from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string} + {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} ) -def get_scanner( - hass: HomeAssistant, config: ConfigType -) -> SwisscomDeviceScanner | None: - """Return the Swisscom device scanner.""" - scanner = SwisscomDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) - - return scanner if scanner.success_init else None +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: AsyncSeeCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> bool: + """Inform users that the YAML configuration is no longer supported.""" + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_credentials_required", + breaks_in_ha_version="2027.1.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_credentials_required", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swisscom Internet-Box", + "host": config[CONF_HOST], + }, + ) + return False -class SwisscomDeviceScanner(DeviceScanner): - """Class which queries a router running Swisscom Internet-Box firmware.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: SwisscomConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker entities for the Swisscom Internet-Box.""" + coordinator = entry.runtime_data + tracked: set[str] = set() - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.last_results = {} + @callback + def _add_new_entities() -> None: + new_keys = [key for key in coordinator.data if key not in tracked] + if new_keys: + tracked.update(new_keys) + async_add_entities( + SwisscomScannerEntity(coordinator, key) for key in new_keys + ) - # Test the router is accessible. - data = self.get_swisscom_data() - self.success_init = data is not None + _add_new_entities() + entry.async_on_unload(coordinator.async_add_listener(_add_new_entities)) - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - return [client["mac"] for client in self.last_results] - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - if not self.last_results: - return None - for client in self.last_results: - if client["mac"] == device: - return client["host"] - return None +class SwisscomScannerEntity( + CoordinatorEntity[SwisscomDataUpdateCoordinator], ScannerEntity +): + """A device tracked by the Swisscom Internet-Box.""" - def _update_info(self): - """Ensure the information from the Swisscom router is up to date. + def __init__(self, coordinator: SwisscomDataUpdateCoordinator, key: str) -> None: + """Initialize the scanner entity.""" + super().__init__(coordinator) + self._key = key + self._attr_unique_id = key - Return boolean if scanning successful. - """ - if not self.success_init: - return False + @property + def _device(self): + return self.coordinator.data.get(self._key) - _LOGGER.debug("Loading data from Swisscom Internet Box") - if not (data := self.get_swisscom_data()): - return False + @property + def is_connected(self) -> bool: + """Return whether the device is currently connected to the LAN.""" + device = self._device + return bool(device and device.active) - active_clients = [client for client in data.values() if client["status"]] - self.last_results = active_clients - return True + @property + def mac_address(self) -> str: + """Return the MAC address of the device.""" + device = self._device + return device.phys_address if device else self._key - def get_swisscom_data(self): - """Retrieve data from Swisscom and return parsed result.""" - url = f"http://{self.host}/ws" - headers = {"Content-Type": "application/x-sah-ws-4-call+json"} - data = """ - {"service":"Devices", "method":"get", - "parameters":{"expression":"lan and not self"}}""" + @property + def hostname(self) -> str | None: + """Return the hostname of the device.""" + device = self._device + return device.name if device else None - devices = {} + @property + def ip_address(self) -> str | None: + """Return the IP address of the device.""" + device = self._device + return device.ip_address if device else None - try: - request = requests.post(url, headers=headers, data=data, timeout=10) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, - requests.exceptions.ConnectTimeout, - ): - _LOGGER.debug("No response from Swisscom Internet Box") - return devices - - if "status" not in request.json(): - _LOGGER.debug("No status in response from Swisscom Internet Box") - return devices - - for device in request.json()["status"]: - with suppress(KeyError, requests.exceptions.RequestException): - devices[device["Key"]] = { - "ip": device["IPAddress"], - "mac": device["PhysAddress"], - "host": device["Name"], - "status": device["Active"], - } - return devices + @property + def name(self) -> str | None: + """Return the friendly name of the device.""" + return self.hostname diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index cf1ea01ea9c..8b259e82d90 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -2,7 +2,9 @@ "domain": "swisscom", "name": "Swisscom Internet-Box", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swisscom", + "integration_type": "hub", "iot_class": "local_polling", - "quality_scale": "legacy" + "requirements": ["python-swisscom-internet-box==0.1.1"] } diff --git a/homeassistant/components/swisscom/strings.json b/homeassistant/components/swisscom/strings.json new file mode 100644 index 00000000000..5ea96118dd4 --- /dev/null +++ b/homeassistant/components/swisscom/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Swisscom Internet-Box.", + "password": "The administrator password printed on the bottom of the box.", + "username": "The administrator username, normally \"admin\"." + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_credentials_required": { + "description": "Configuring the {integration_title} integration through YAML is deprecated. The integration now requires a username and password to authenticate to your Internet-Box, which cannot be safely carried over from YAML.\n\nSet up the integration through the UI to provide your credentials (your existing host `{host}` will need to be re-entered), then remove the `{domain}` entry from your `configuration.yaml` file and restart Home Assistant.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 3c173cf5b2a..2a7f39c40b8 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,7 +1,5 @@ """Component to interface with switches that can be controlled remotely.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum import logging @@ -21,7 +19,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -51,7 +48,6 @@ DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] # mypy: disallow-any-generics -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the switch is on based on the statemachine. diff --git a/homeassistant/components/switch/conditions.yaml b/homeassistant/components/switch/conditions.yaml index ea9adeb6f74..6edfd1d990d 100644 --- a/homeassistant/components/switch/conditions.yaml +++ b/homeassistant/components/switch/conditions.yaml @@ -8,11 +8,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_off: *condition_common is_on: *condition_common diff --git a/homeassistant/components/switch/device_action.py b/homeassistant/components/switch/device_action.py index bff4ce6e396..50519b9ed4b 100644 --- a/homeassistant/components/switch/device_action.py +++ b/homeassistant/components/switch/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for switches.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index f3a6c299529..26e9576ce0b 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -1,7 +1,5 @@ """Provides device conditions for switches.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index 6898a9954de..0c5db05f361 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for switches.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index a781f29bdfa..fa22b0d2e66 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -1,7 +1,5 @@ """Light support for switch entities.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/switch/reproduce_state.py b/homeassistant/components/switch/reproduce_state.py index aaed39d39b8..9e5657151c4 100644 --- a/homeassistant/components/switch/reproduce_state.py +++ b/homeassistant/components/switch/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Switch state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/switch/significant_change.py b/homeassistant/components/switch/significant_change.py index ab7c6bc9281..c0930546e01 100644 --- a/homeassistant/components/switch/significant_change.py +++ b/homeassistant/components/switch/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Switch state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 40f629b9e64..40576351e13 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is on" @@ -65,21 +73,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "toggle": { "description": "Toggles a switch on/off.", @@ -101,6 +94,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::trigger_for_name%]" } }, "name": "Switch turned off" @@ -110,6 +106,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::trigger_for_name%]" } }, "name": "Switch turned on" diff --git a/homeassistant/components/switch/triggers.yaml b/homeassistant/components/switch/triggers.yaml index 98cc334d8f5..505d0f0a2da 100644 --- a/homeassistant/components/switch/triggers.yaml +++ b/homeassistant/components/switch/triggers.yaml @@ -6,14 +6,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: turned_off: *trigger_common turned_on: *trigger_common diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index dfb5ded2791..93de0befa18 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -1,7 +1,5 @@ """Component to wrap switch entities in entities of other domains.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 4b44af63234..fa2983951d3 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Switch as X integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 8fd9c799bcb..dd586bd5b8d 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -1,7 +1,5 @@ """Cover support for switch entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 7611725d457..fa8bb0cf22b 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -1,7 +1,5 @@ """Base entity for the Switch as X integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.homeassistant import exposed_entities diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 846e9ae7e80..9a0438af0ce 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -1,7 +1,5 @@ """Fan support for switch entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.fan import ( diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index c043a354869..386b07c242f 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -1,7 +1,5 @@ """Light support for switch entities.""" -from __future__ import annotations - from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, ColorMode, diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 946429e0395..baeca74fc37 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -1,7 +1,5 @@ """Lock support for switch entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index b96c7c6e0ea..d42cff6411a 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -1,7 +1,5 @@ """Siren support for switch entities.""" -from __future__ import annotations - from homeassistant.components.siren import ( DOMAIN as SIREN_DOMAIN, SirenEntity, diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py index 2b5f252ac2d..a6a5c3b18ff 100644 --- a/homeassistant/components/switch_as_x/valve.py +++ b/homeassistant/components/switch_as_x/valve.py @@ -1,7 +1,5 @@ """Valve support for switch entities.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 6e4bf004a3d..9ee64a32291 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -1,7 +1,5 @@ """The SwitchBee Smart Home integration.""" -from __future__ import annotations - import logging import re @@ -9,7 +7,6 @@ from aiohttp import ClientSession from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api from switchbee.api.central_unit import SwitchBeeError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -17,7 +14,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,10 +50,9 @@ async def get_api_object( return api -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SwitchBeeConfigEntry) -> bool: """Set up SwitchBee Smart Home from a config entry.""" - hass.data.setdefault(DOMAIN, {}) central_unit = entry.data[CONF_HOST] user = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] @@ -67,27 +63,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SwitchBeeConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: SwitchBeeConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SwitchBeeConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/switchbee/button.py b/homeassistant/components/switchbee/button.py index 1ac81ec4e0d..bc98503db6a 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -4,27 +4,25 @@ from switchbee.api.central_unit import SwitchBeeError from switchbee.device import ApiStateCommand, DeviceType from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry from .entity import SwitchBeeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee button.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeButton(switchbee_device, coordinator) for switchbee_device in coordinator.data.values() - if switchbee_device.type == DeviceType.Scenario + if switchbee_device.type is DeviceType.Scenario ) diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 7837798b0cb..cf1e3fcf6b3 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -1,7 +1,5 @@ """Support for SwitchBee climate.""" -from __future__ import annotations - from typing import Any from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError @@ -23,14 +21,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity FAN_SB_TO_HASS = { @@ -75,11 +71,11 @@ SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee climate.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeClimateEntity(switchbee_device, coordinator) for switchbee_device in coordinator.data.values() diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index b2cd53398ab..1edc21088d3 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -1,7 +1,5 @@ """Config flow for SwitchBee Smart Home integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b0ea1707be8..f22aafb2c1b 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -1,7 +1,5 @@ """SwitchBee integration Coordinator.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta import logging @@ -19,16 +17,18 @@ from .const import DOMAIN, SCAN_INTERVAL_SEC _LOGGER = logging.getLogger(__name__) +type SwitchBeeConfigEntry = ConfigEntry[SwitchBeeCoordinator] + class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevice]]): """Class to manage fetching SwitchBee data API.""" - config_entry: ConfigEntry + config_entry: SwitchBeeConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitchBeeConfigEntry, swb_api: CentralUnitPolling | CentralUnitWsRPC, ) -> None: """Initialize.""" diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py index 247063ab18a..afa3283276a 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -1,7 +1,5 @@ """Support for SwitchBee cover.""" -from __future__ import annotations - from typing import Any from switchbee.api.central_unit import SwitchBeeError, SwitchBeeTokenError @@ -14,23 +12,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry from .entity import SwitchBeeDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up SwitchBee switch.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + """Set up SwitchBee covers.""" + coordinator = entry.runtime_data entities: list[CoverEntity] = [] for device in coordinator.data.values(): diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index d2d58a3ace3..7dd7594242a 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -48,7 +48,7 @@ class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice]( super().__init__(device, coordinator) self._is_online: bool = True identifier = ( - device.id if device.type == DeviceType.Thermostat else device.unit_id + device.id if device.type is DeviceType.Thermostat else device.unit_id ) self._attr_device_info = DeviceInfo( name=device.zone, @@ -73,7 +73,10 @@ class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice]( return self._is_online and super().available def _check_if_became_offline(self) -> None: - """Check if the device was online (now offline), log message and mark it as Unavailable.""" + """Check if the device was online (now offline). + + Log message and mark it as unavailable. + """ if self._is_online: _LOGGER.warning( diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 228667540df..a806a3fa858 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -1,20 +1,16 @@ """Support for SwitchBee light.""" -from __future__ import annotations - -from typing import Any +from typing import Any, cast from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import ApiStateCommand, DeviceType, SwitchBeeDimmer from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity MAX_BRIGHTNESS = 255 @@ -36,15 +32,15 @@ def _switchbee_brightness_to_hass(value: int) -> int: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee light.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - SwitchBeeLightEntity(switchbee_device, coordinator) + SwitchBeeLightEntity(cast(SwitchBeeDimmer, switchbee_device), coordinator) for switchbee_device in coordinator.data.values() - if switchbee_device.type == DeviceType.Dimmer + if switchbee_device.type is DeviceType.Dimmer ) diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index 41538f6fd71..ee0c7ec9a8c 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -1,7 +1,5 @@ """Support for SwitchBee switch.""" -from __future__ import annotations - from typing import Any from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError @@ -14,23 +12,21 @@ from switchbee.device import ( ) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee switch.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeSwitchEntity(device, coordinator) @@ -81,8 +77,9 @@ class SwitchBeeSwitchEntity[ self._check_if_became_online() - # timed power switch state is an integer representing the number of minutes left until it goes off - # regulare switches state is ON/OFF (1/0 respectively) + # timed power switch state is an integer representing + # the number of minutes left until it goes off; + # regular switches state is ON/OFF (1/0 respectively) self._attr_is_on = coordinator_device.state != ApiStateCommand.OFF async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 0d47f7752a7..3b28c3d6dce 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1,13 +1,12 @@ """Support for Switchbot devices.""" -from __future__ import annotations - import logging from typing import Any import switchbot from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.sensor import ConfigType from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -58,10 +57,15 @@ PLATFORMS_BY_TYPE = { SupportedModels.HYGROMETER.value: [Platform.SENSOR], SupportedModels.HYGROMETER_CO2.value: [ Platform.BUTTON, + Platform.NUMBER, Platform.SENSOR, Platform.SELECT, ], - SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.CONTACT.value: [ + Platform.BINARY_SENSOR, + Platform.EVENT, + Platform.SENSOR, + ], SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.HUMIDIFIER.value: [Platform.HUMIDIFIER, Platform.SENSOR], @@ -107,13 +111,38 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.LOCK_ULTRA.value: [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.LOCK, Platform.SENSOR, ], - SupportedModels.AIR_PURIFIER_JP.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_US.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_TABLE_JP.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_TABLE_US.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.AIR_PURIFIER_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + Platform.LIGHT, + ], + SupportedModels.AIR_PURIFIER_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + Platform.LIGHT, + ], + SupportedModels.AIR_PURIFIER_TABLE_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + Platform.LIGHT, + ], + SupportedModels.AIR_PURIFIER_TABLE_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + Platform.LIGHT, + ], SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [ Platform.HUMIDIFIER, Platform.SENSOR, @@ -122,6 +151,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.PERMANENT_OUTDOOR_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR], @@ -145,6 +175,22 @@ PLATFORMS_BY_TYPE = { Platform.BINARY_SENSOR, Platform.EVENT, ], + SupportedModels.LOCK_VISION.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], + SupportedModels.LOCK_VISION_PRO.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], + SupportedModels.LOCK_PRO_WIFI.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], + SupportedModels.WEATHER_STATION.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -174,19 +220,29 @@ CLASS_BY_DEVICE = { SupportedModels.AIR_PURIFIER_US.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE_JP.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE_US.value: switchbot.SwitchbotAirPurifier, - SupportedModels.EVAPORATIVE_HUMIDIFIER.value: switchbot.SwitchbotEvaporativeHumidifier, + SupportedModels.EVAPORATIVE_HUMIDIFIER.value: ( + switchbot.SwitchbotEvaporativeHumidifier + ), SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, + SupportedModels.PERMANENT_OUTDOOR_LIGHT.value: ( + switchbot.SwitchbotPermanentOutdoorLight + ), SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM, SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener, - SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator, + SupportedModels.SMART_THERMOSTAT_RADIATOR.value: ( + switchbot.SwitchbotSmartThermostatRadiator + ), SupportedModels.ART_FRAME.value: switchbot.SwitchbotArtFrame, SupportedModels.KEYPAD_VISION.value: switchbot.SwitchbotKeypadVision, SupportedModels.KEYPAD_VISION_PRO.value: switchbot.SwitchbotKeypadVision, SupportedModels.HYGROMETER_CO2.value: switchbot.SwitchbotMeterProCO2, + SupportedModels.LOCK_VISION_PRO.value: switchbot.SwitchbotLock, + SupportedModels.LOCK_VISION.value: switchbot.SwitchbotLock, + SupportedModels.LOCK_PRO_WIFI.value: switchbot.SwitchbotLock, } @@ -259,6 +315,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> translation_placeholders={ "sensor_type": entry.data[CONF_SENSOR_TYPE], "address": entry.data[CONF_ADDRESS], + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + entry.data[CONF_ADDRESS].upper(), + BluetoothReachabilityIntent.CONNECTION, + ), }, ) @@ -280,7 +341,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="device_not_found_error", - translation_placeholders={"sensor_type": sensor_type, "address": address}, + translation_placeholders={ + "sensor_type": sensor_type, + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION + if connectable + else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT, + ), + }, ) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index ef035bbfdf2..9650233981d 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -1,11 +1,10 @@ """Support for SwitchBot binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass -from switchbot import SwitchbotModel +import switchbot +from switchbot import LockStatus, SwitchbotModel from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -21,12 +20,17 @@ from .entity import SwitchbotEntity PARALLEL_UPDATES = 0 +LOCK_ULTRA_BINARY_SENSORS = {"half_lock_calibration", "half_locked"} + @dataclass(frozen=True, kw_only=True) class SwitchbotBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Switchbot binary sensor entity.""" device_class_fn: Callable[[SwitchbotModel], BinarySensorDeviceClass] | None = None + value_fn: Callable[[switchbot.SwitchbotDevice, str], bool | None] = ( + lambda device, key: device.parsed_data.get(key) + ) BINARY_SENSOR_TYPES: dict[str, SwitchbotBinarySensorEntityDescription] = { @@ -35,6 +39,20 @@ BINARY_SENSOR_TYPES: dict[str, SwitchbotBinarySensorEntityDescription] = { translation_key="calibration", entity_category=EntityCategory.DIAGNOSTIC, ), + "half_lock_calibration": SwitchbotBinarySensorEntityDescription( + key="half_lock_calibration", + translation_key="half_lock_calibration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "half_locked": SwitchbotBinarySensorEntityDescription( + key="half_locked", + translation_key="half_locked", + value_fn=lambda device, _: ( + None + if (status := device.get_lock_status()) is None + else status is LockStatus.HALF_LOCKED + ), + ), "motion_detected": SwitchbotBinarySensorEntityDescription( key="pir_state", device_class_fn=lambda model: { @@ -102,10 +120,14 @@ async def async_setup_entry( ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data + binary_sensors: set[str] = { + bs for bs in coordinator.device.parsed_data if bs in BINARY_SENSOR_TYPES + } + if coordinator.model is SwitchbotModel.LOCK_ULTRA: + binary_sensors.update(LOCK_ULTRA_BINARY_SENSORS) async_add_entities( SwitchBotBinarySensor(coordinator, binary_sensor) - for binary_sensor in coordinator.device.parsed_data - if binary_sensor in BINARY_SENSOR_TYPES + for binary_sensor in binary_sensors ) @@ -130,6 +152,6 @@ class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity): ) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the sensor.""" - return self.parsed_data[self._sensor] + return self.entity_description.value_fn(self._device, self._sensor) diff --git a/homeassistant/components/switchbot/button.py b/homeassistant/components/switchbot/button.py index 3d9db9074f2..311e795383a 100644 --- a/homeassistant/components/switchbot/button.py +++ b/homeassistant/components/switchbot/button.py @@ -3,6 +3,7 @@ import logging import switchbot +from switchbot import SwitchbotModel from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity, exception_handler @@ -24,9 +26,13 @@ async def async_setup_entry( ) -> None: """Set up Switchbot button platform.""" coordinator = entry.runtime_data + entities: list[ButtonEntity] = [] + + if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): + entities.append(LightSensorButton(coordinator)) if isinstance(coordinator.device, switchbot.SwitchbotArtFrame): - async_add_entities( + entities.extend( [ SwitchBotArtFrameNextButton(coordinator, "next_image"), SwitchBotArtFramePrevButton(coordinator, "previous_image"), @@ -34,7 +40,35 @@ async def async_setup_entry( ) if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): - async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)]) + entities.append(SwitchBotMeterProCO2SyncDateTimeButton(coordinator)) + + if ( + isinstance(coordinator.device, switchbot.SwitchbotLock) + and coordinator.model is SwitchbotModel.LOCK_ULTRA + and entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH) + ): + entities.append(HalfLockButton(coordinator)) + + if entities: + async_add_entities(entities) + + +class LightSensorButton(SwitchbotEntity, ButtonEntity): + """Representation of a Switchbot light sensor button.""" + + _attr_translation_key = "light_sensor" + _device: switchbot.SwitchbotAirPurifier + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Switchbot light sensor button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_light_sensor" + + @exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + _LOGGER.debug("Toggling light sensor mode for %s", self._address) + await self._device.open_light_sensitive_switch() class SwitchBotArtFrameButtonBase(SwitchbotEntity, ButtonEntity): @@ -72,7 +106,7 @@ class SwitchBotArtFramePrevButton(SwitchBotArtFrameButtonBase): class SwitchBotMeterProCO2SyncDateTimeButton(SwitchbotEntity, ButtonEntity): - """Button to sync date and time on Meter Pro CO2 to the current HA instance datetime.""" + """Button to sync date and time on Meter Pro CO2.""" _device: switchbot.SwitchbotMeterProCO2 _attr_entity_category = EntityCategory.CONFIG @@ -99,7 +133,8 @@ class SwitchBotMeterProCO2SyncDateTimeButton(SwitchbotEntity, ButtonEntity): timestamp = int(now.timestamp()) _LOGGER.debug( - "Syncing time for %s: timestamp=%s, utc_offset_hours=%s, utc_offset_minutes=%s", + "Syncing time for %s: timestamp=%s," + " utc_offset_hours=%s, utc_offset_minutes=%s", self._address, timestamp, utc_offset_hours, @@ -111,3 +146,21 @@ class SwitchBotMeterProCO2SyncDateTimeButton(SwitchbotEntity, ButtonEntity): utc_offset_hours=utc_offset_hours, utc_offset_minutes=utc_offset_minutes, ) + + +class HalfLockButton(SwitchbotEntity, ButtonEntity): + """Representation of a Half Lock button for Lock Ultra.""" + + _attr_translation_key = "half_lock" + _device: switchbot.SwitchbotLock + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Half Lock button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_half_lock" + + @exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + _LOGGER.debug("Sending half lock command for %s", self._address) + await self._device.half_lock() diff --git a/homeassistant/components/switchbot/climate.py b/homeassistant/components/switchbot/climate.py index 79b05388d22..0d4cd2ec434 100644 --- a/homeassistant/components/switchbot/climate.py +++ b/homeassistant/components/switchbot/climate.py @@ -1,7 +1,5 @@ """Support for Switchbot Climate devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index d9b3ea44fe1..c954279cd78 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Switchbot.""" -from __future__ import annotations - import logging from typing import Any @@ -16,6 +14,7 @@ from switchbot import ( ) import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -96,6 +95,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_advs: dict[str, SwitchBotAdvertisement] = {} self._cloud_username: str | None = None self._cloud_password: str | None = None + self._encryption_method_selected = False async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -197,6 +197,13 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): assert self._discovered_adv is not None description_placeholders: dict[str, str] = {} + if user_input is None: + if not self._encryption_method_selected and not ( + self._cloud_username and self._cloud_password + ): + return await self.async_step_encrypted_choose_method() + self._encryption_method_selected = False + # If we have saved credentials from cloud login, try them first if user_input is None and self._cloud_username and self._cloud_password: user_input = { @@ -258,6 +265,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the SwitchBot API chose method step.""" assert self._discovered_adv is not None + self._encryption_method_selected = True return self.async_show_menu( step_id="encrypted_choose_method", menu_options=["encrypted_auth", "encrypted_key"], @@ -272,6 +280,12 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the encryption key step.""" errors: dict[str, str] = {} assert self._discovered_adv is not None + + if user_input is None: + if not self._encryption_method_selected: + return await self.async_step_encrypted_choose_method() + self._encryption_method_selected = False + if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] @@ -411,6 +425,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_password() return await self._async_create_entry_from_discovery(user_input) + await bluetooth.async_request_active_scan(self.hass) self._async_discover_devices() if len(self._discovered_advs) == 1: # If there is only one device we can ask for a password @@ -464,6 +479,9 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): SupportedModels.LOCK, SupportedModels.LOCK_PRO, SupportedModels.LOCK_ULTRA, + SupportedModels.LOCK_PRO_WIFI, + SupportedModels.LOCK_VISION, + SupportedModels.LOCK_VISION_PRO, ): options.update( { diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 142b13befcc..30293f359df 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -56,6 +56,7 @@ class SupportedModels(StrEnum): STRIP_LIGHT_3 = "strip_light_3" RGBICWW_STRIP_LIGHT = "rgbicww_strip_light" RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" + PERMANENT_OUTDOOR_LIGHT = "permanent_outdoor_light" PLUG_MINI_EU = "plug_mini_eu" RELAY_SWITCH_2PM = "relay_switch_2pm" K11_PLUS_VACUUM = "k11+_vacuum" @@ -66,6 +67,10 @@ class SupportedModels(StrEnum): ART_FRAME = "art_frame" KEYPAD_VISION = "keypad_vision" KEYPAD_VISION_PRO = "keypad_vision_pro" + LOCK_VISION_PRO = "lock_vision_pro" + LOCK_VISION = "lock_vision" + LOCK_PRO_WIFI = "lock_pro_wifi" + WEATHER_STATION = "weather_station" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -101,6 +106,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: SupportedModels.PERMANENT_OUTDOOR_LIGHT, SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM, SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM, @@ -111,6 +117,9 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.KEYPAD_VISION: SupportedModels.KEYPAD_VISION, SwitchbotModel.KEYPAD_VISION_PRO: SupportedModels.KEYPAD_VISION_PRO, SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, + SwitchbotModel.LOCK_VISION_PRO: SupportedModels.LOCK_VISION_PRO, + SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION, + SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -126,6 +135,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, SwitchbotModel.HUB3: SupportedModels.HUB3, SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL, + SwitchbotModel.WEATHER_STATION: SupportedModels.WEATHER_STATION, } SUPPORTED_MODEL_TYPES = ( @@ -148,6 +158,7 @@ ENCRYPTED_MODELS = { SwitchbotModel.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, SwitchbotModel.PLUG_MINI_EU, SwitchbotModel.RELAY_SWITCH_2PM, SwitchbotModel.GARAGE_DOOR_OPENER, @@ -155,6 +166,9 @@ ENCRYPTED_MODELS = { SwitchbotModel.ART_FRAME, SwitchbotModel.KEYPAD_VISION, SwitchbotModel.KEYPAD_VISION_PRO, + SwitchbotModel.LOCK_VISION_PRO, + SwitchbotModel.LOCK_VISION, + SwitchbotModel.LOCK_PRO_WIFI, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -175,13 +189,19 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, + SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: switchbot.SwitchbotPermanentOutdoorLight, SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM, SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch, - SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator, + SwitchbotModel.SMART_THERMOSTAT_RADIATOR: ( + switchbot.SwitchbotSmartThermostatRadiator + ), SwitchbotModel.ART_FRAME: switchbot.SwitchbotArtFrame, SwitchbotModel.KEYPAD_VISION: switchbot.SwitchbotKeypadVision, SwitchbotModel.KEYPAD_VISION_PRO: switchbot.SwitchbotKeypadVision, + SwitchbotModel.LOCK_VISION_PRO: switchbot.SwitchbotLock, + SwitchbotModel.LOCK_VISION: switchbot.SwitchbotLock, + SwitchbotModel.LOCK_PRO_WIFI: switchbot.SwitchbotLock, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 4c80c534812..dba9cbeb3af 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -1,7 +1,5 @@ """Provides the switchbot DataUpdateCoordinator.""" -from __future__ import annotations - import asyncio import contextlib import logging @@ -72,6 +70,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) # and we actually have a way to connect to the device return ( self.hass.state is CoreState.running + and self.connectable and self.device.poll_needed(seconds_since_last_poll) and bool( bluetooth.async_ble_device_from_address( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 18486daf68f..4c1b7c2e71e 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -1,7 +1,5 @@ """Support for SwitchBot curtains.""" -from __future__ import annotations - import logging from typing import Any @@ -220,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) - self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] - self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/diagnostics.py b/homeassistant/components/switchbot/diagnostics.py index 71c913c6411..4057bcdeba3 100644 --- a/homeassistant/components/switchbot/diagnostics.py +++ b/homeassistant/components/switchbot/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for switchbot integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components import bluetooth diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index 8aa2368cf6a..398f6f219a2 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -1,7 +1,5 @@ """An abstract class common to all Switchbot entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Mapping import logging from typing import Any, Concatenate @@ -43,7 +41,8 @@ class SwitchbotEntity( self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_BLUETOOTH, self._address)}, manufacturer=MANUFACTURER, - model=coordinator.model, # Sometimes the modelName is missing from the advertisement data + # Sometimes the modelName is missing from ads + model=coordinator.model, name=coordinator.device_name, ) self._channel: int | None = None diff --git a/homeassistant/components/switchbot/event.py b/homeassistant/components/switchbot/event.py index 30ccca7ea95..d0935c719da 100644 --- a/homeassistant/components/switchbot/event.py +++ b/homeassistant/components/switchbot/event.py @@ -1,6 +1,6 @@ """Support for SwitchBot event entities.""" -from __future__ import annotations +from dataclasses import dataclass from homeassistant.components.event import ( EventDeviceClass, @@ -15,13 +15,31 @@ from .entity import SwitchbotEntity PARALLEL_UPDATES = 0 -EVENT_TYPES = { - "doorbell": EventEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class SwitchbotEventEntityDescription(EventEntityDescription): + """Describes a Switchbot event entity.""" + + counter_key: str + fire_event: str + + +EVENT_DESCRIPTIONS: tuple[SwitchbotEventEntityDescription, ...] = ( + SwitchbotEventEntityDescription( key="doorbell", device_class=EventDeviceClass.DOORBELL, event_types=["ring"], + counter_key="doorbell_seq", + fire_event="ring", ), -} + SwitchbotEventEntityDescription( + key="button", + device_class=EventDeviceClass.BUTTON, + event_types=["press"], + counter_key="button_count", + fire_event="press", + ), +) async def async_setup_entry( @@ -32,32 +50,34 @@ async def async_setup_entry( """Set up the SwitchBot event platform.""" coordinator = config_entry.runtime_data async_add_entities( - SwitchbotEventEntity(coordinator, event, description) - for event, description in EVENT_TYPES.items() - if event in coordinator.device.parsed_data + SwitchbotEventEntity(coordinator, description) + for description in EVENT_DESCRIPTIONS + if description.counter_key in coordinator.device.parsed_data ) class SwitchbotEventEntity(SwitchbotEntity, EventEntity): """Representation of a SwitchBot event.""" + entity_description: SwitchbotEventEntityDescription + def __init__( self, coordinator: SwitchbotDataUpdateCoordinator, - event: str, - description: EventEntityDescription, + description: SwitchbotEventEntityDescription, ) -> None: """Initialize the SwitchBot event.""" super().__init__(coordinator) - self._event = event self.entity_description = description - self._attr_unique_id = f"{coordinator.base_unique_id}-{event}" - self._previous_value = False + self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}" + self._previous_counter = int( + coordinator.device.parsed_data.get(description.counter_key, 0) + ) @callback def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = bool(self.parsed_data.get(self._event, False)) - if value and not self._previous_value: - self._trigger_event("ring") - self._previous_value = value + counter = int(self.parsed_data.get(self.entity_description.counter_key, 0)) + if counter not in (0, self._previous_counter): + self._trigger_event(self.entity_description.fire_event) + self._previous_counter = counter diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index 66d407eed2e..79083aa1125 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -1,7 +1,5 @@ """Support for SwitchBot Fans.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index c162f4947ed..fb2627a9416 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -1,7 +1,5 @@ """Support for Switchbot humidifier.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 29aedc20aa3..589fb59ad9e 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,15 @@ { "entity": { + "binary_sensor": { + "half_lock_calibration": { + "default": "mdi:lock-check" + } + }, + "button": { + "light_sensor": { + "default": "mdi:brightness-auto" + } + }, "climate": { "climate": { "state_attributes": { @@ -116,6 +126,24 @@ } }, "sensor": { + "aqi_quality_level": { + "default": "mdi:air-filter", + "state": { + "excellent": "mdi:emoticon-excited-outline", + "good": "mdi:emoticon-happy-outline", + "moderate": "mdi:emoticon-neutral-outline", + "unhealthy": "mdi:emoticon-sad-outline" + } + }, + "battery_range": { + "default": "mdi:battery", + "state": { + "critical": "mdi:battery-alert-variant-outline", + "high": "mdi:battery-80", + "low": "mdi:battery-20", + "medium": "mdi:battery-50" + } + }, "light_level": { "default": "mdi:brightness-7", "state": { @@ -140,6 +168,20 @@ "medium": "mdi:water" } } + }, + "switch": { + "child_lock": { + "state": { + "off": "mdi:lock-open", + "on": "mdi:lock" + } + }, + "wireless_charging": { + "state": { + "off": "mdi:battery-charging-wireless-outline", + "on": "mdi:battery-charging-wireless" + } + } } }, "services": { diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index c75a880e87a..9f535f8fc68 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -1,7 +1,5 @@ """Switchbot integration light platform.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -17,10 +15,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity -from .coordinator import SwitchbotConfigEntry +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity, exception_handler SWITCHBOT_COLOR_MODE_TO_HASS = { @@ -38,7 +38,74 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switchbot light.""" - async_add_entities([SwitchbotLightEntity(entry.runtime_data)]) + coordinator = entry.runtime_data + if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): + async_add_entities([SwitchbotAirPurifierLightEntity(coordinator)]) + else: + async_add_entities([SwitchbotLightEntity(coordinator)]) + + +class SwitchbotAirPurifierLightEntity(SwitchbotEntity, LightEntity, RestoreEntity): + """Representation of a Switchbot air purifier light.""" + + _device: switchbot.SwitchbotAirPurifier + _attr_translation_key = "air_purifier_light" + _attr_is_on: bool | None = None + _attr_supported_color_modes = {ColorMode.RGB} + _attr_color_mode = ColorMode.RGB + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_light" + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state: + self._attr_is_on = last_state.state == STATE_ON + + @property + def brightness(self) -> int: + """Return the brightness of the light.""" + return max(0, min(255, round(self._device.brightness * 2.55))) + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of the light.""" + return self._device.rgb + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + return self._attr_is_on + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + _LOGGER.debug("Turning on light %s, address %s", kwargs, self._address) + brightness = round( + cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 + ) + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + await self._device.set_rgb(brightness, rgb[0], rgb[1], rgb[2]) + return + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_brightness(brightness) + return + await self._device.turn_led_on() + self._attr_is_on = True + self.async_write_ha_state() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + _LOGGER.debug("Turning off light %s, address %s", kwargs, self._address) + await self._device.turn_led_off() + self._attr_is_on = False + self.async_write_ha_state() class SwitchbotLightEntity(SwitchbotEntity, LightEntity): diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index 069b01521c4..389beb70dbd 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -46,7 +46,7 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): def _async_update_attrs(self) -> None: """Update the entity attributes.""" status = self._device.get_lock_status() - self._attr_is_locked = status is LockStatus.LOCKED + self._attr_is_locked = status in {LockStatus.LOCKED, LockStatus.HALF_LOCKED} self._attr_is_locking = status is LockStatus.LOCKING self._attr_is_unlocking = status is LockStatus.UNLOCKING self._attr_is_jammed = status in { diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 122a46e2a3d..d346058b1df 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.0.0"] + "requirements": ["PySwitchbot==2.2.0"] } diff --git a/homeassistant/components/switchbot/number.py b/homeassistant/components/switchbot/number.py new file mode 100644 index 00000000000..baf57024d47 --- /dev/null +++ b/homeassistant/components/switchbot/number.py @@ -0,0 +1,77 @@ +"""Number platform for SwitchBot devices.""" + +from datetime import timedelta +import logging + +import switchbot +from switchbot import SwitchbotOperationError +from switchbot.devices.meter_pro import MAX_TIME_OFFSET + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity, exception_handler + +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(days=7) +_LOGGER = logging.getLogger(__name__) +_SECONDS_IN_MINUTE = 60 +_MAX_TIME_OFFSET_MINUTES = MAX_TIME_OFFSET // _SECONDS_IN_MINUTE + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot number platform.""" + coordinator = entry.runtime_data + + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities( + [SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True + ) + + +class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity): + """Number entity to set the time offset for Meter Pro CO2 devices.""" + + _device: switchbot.SwitchbotMeterProCO2 + _attr_device_class = NumberDeviceClass.DURATION + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "display_time_offset" + _attr_native_min_value = -_MAX_TIME_OFFSET_MINUTES + _attr_native_max_value = _MAX_TIME_OFFSET_MINUTES + _attr_native_step = 1.0 + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_should_poll = True + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_display_time_offset" + + @exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set the time offset.""" + _LOGGER.debug("Setting time offset to %s minutes for %s", value, self._address) + offset_minutes = round(value) + offset_seconds = offset_minutes * _SECONDS_IN_MINUTE + await self._device.set_time_offset(offset_seconds) + self._attr_native_value = offset_minutes + self.async_write_ha_state() + + async def async_update(self) -> None: + """Fetch the latest time offset from the device.""" + try: + offset_seconds = await self._device.get_time_offset() + except SwitchbotOperationError: + _LOGGER.debug( + "Failed to update time offset for %s", self._address, exc_info=True + ) + return + self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE) diff --git a/homeassistant/components/switchbot/select.py b/homeassistant/components/switchbot/select.py index 967ed83d347..7b9783cfee0 100644 --- a/homeassistant/components/switchbot/select.py +++ b/homeassistant/components/switchbot/select.py @@ -1,7 +1,5 @@ """Select platform for SwitchBot.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index ab400b58065..641479e942f 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -1,10 +1,10 @@ """Support for SwitchBot sensors.""" -from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import switchbot -from switchbot import HumidifierWaterLevel -from switchbot.const.air_purifier import AirQualityLevel +from switchbot import AirQualityLevel, HumidifierWaterLevel, SwitchbotModel from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.sensor import ( @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -29,14 +30,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import AIRPURIFIER_PM25_MODELS, DOMAIN from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity PARALLEL_UPDATES = 0 -SENSOR_TYPES: dict[str, SensorEntityDescription] = { - "rssi": SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class SwitchBotSensorEntityDescription(SensorEntityDescription): + """Describes SwitchBot sensor entities with optional value transformation.""" + + value_fn: Callable[[str | int | None], str | int | None] = lambda v: v + + +SENSOR_TYPES: dict[str, SwitchBotSensorEntityDescription] = { + "rssi": SwitchBotSensorEntityDescription( key="rssi", translation_key="bluetooth_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -45,7 +54,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), - "wifi_rssi": SensorEntityDescription( + "wifi_rssi": SwitchBotSensorEntityDescription( key="wifi_rssi", translation_key="wifi_signal", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -54,78 +63,97 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), - "battery": SensorEntityDescription( + "battery": SwitchBotSensorEntityDescription( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - "co2": SensorEntityDescription( + "co2": SwitchBotSensorEntityDescription( key="co2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CO2, ), - "lightLevel": SensorEntityDescription( + "lightLevel": SwitchBotSensorEntityDescription( key="lightLevel", translation_key="light_level", state_class=SensorStateClass.MEASUREMENT, ), - "humidity": SensorEntityDescription( + "humidity": SwitchBotSensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), - "illuminance": SensorEntityDescription( + "illuminance": SwitchBotSensorEntityDescription( key="illuminance", native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.ILLUMINANCE, ), - "temperature": SensorEntityDescription( + "temperature": SwitchBotSensorEntityDescription( key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), - "power": SensorEntityDescription( + "power": SwitchBotSensorEntityDescription( key="power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), - "current": SensorEntityDescription( + "current": SwitchBotSensorEntityDescription( key="current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), - "voltage": SensorEntityDescription( + "voltage": SwitchBotSensorEntityDescription( key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), - "aqi_level": SensorEntityDescription( + "aqi_level": SwitchBotSensorEntityDescription( key="aqi_level", translation_key="aqi_quality_level", device_class=SensorDeviceClass.ENUM, options=[member.name.lower() for member in AirQualityLevel], ), - "energy": SensorEntityDescription( + "energy": SwitchBotSensorEntityDescription( key="energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), - "water_level": SensorEntityDescription( + "water_level": SwitchBotSensorEntityDescription( key="water_level", translation_key="water_level", device_class=SensorDeviceClass.ENUM, options=HumidifierWaterLevel.get_levels(), ), + "battery_range": SwitchBotSensorEntityDescription( + key="battery_range", + translation_key="battery_range", + device_class=SensorDeviceClass.ENUM, + options=["critical", "low", "medium", "high"], + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda v: { + "<10%": "critical", + "10-19%": "low", + "20-59%": "medium", + ">=60%": "high", + }.get(str(v)), + ), + "pm25": SwitchBotSensorEntityDescription( + key="pm25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, + ), } @@ -136,6 +164,7 @@ async def async_setup_entry( ) -> None: """Set up Switchbot sensor based on a config entry.""" coordinator = entry.runtime_data + parsed_data = coordinator.device.parsed_data sensor_entities: list[SensorEntity] = [] if isinstance(coordinator.device, switchbot.SwitchbotRelaySwitch2PM): sensor_entities.extend( @@ -144,11 +173,29 @@ async def async_setup_entry( for sensor in coordinator.device.get_parsed_data(channel) if sensor in SENSOR_TYPES ) - else: + elif coordinator.model == SwitchbotModel.PRESENCE_SENSOR: sensor_entities.extend( SwitchBotSensor(coordinator, sensor) - for sensor in coordinator.device.parsed_data - if sensor in SENSOR_TYPES + for sensor in ( + *( + s + for s in parsed_data + if s in SENSOR_TYPES and s not in ("battery", "battery_range") + ), + "battery_range", + ) + ) + if "battery" in parsed_data: + sensor_entities.append(SwitchBotSensor(coordinator, "battery")) + else: + sensors: set[str] = {sensor for sensor in parsed_data if sensor in SENSOR_TYPES} + if ( + isinstance(coordinator.device, switchbot.SwitchbotAirPurifier) + and coordinator.model in AIRPURIFIER_PM25_MODELS + ): + sensors.add("pm25") + sensor_entities.extend( + SwitchBotSensor(coordinator, sensor) for sensor in sensors ) sensor_entities.append(SwitchbotRSSISensor(coordinator, "rssi")) async_add_entities(sensor_entities) @@ -157,6 +204,8 @@ async def async_setup_entry( class SwitchBotSensor(SwitchbotEntity, SensorEntity): """Representation of a Switchbot sensor.""" + entity_description: SwitchBotSensorEntityDescription + def __init__( self, coordinator: SwitchbotDataUpdateCoordinator, @@ -185,7 +234,7 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity): @property def native_value(self) -> str | int | None: """Return the state of the sensor.""" - return self.parsed_data[self._sensor] + return self.entity_description.value_fn(self.parsed_data.get(self._sensor)) class SwitchbotRSSISensor(SwitchBotSensor): diff --git a/homeassistant/components/switchbot/services.py b/homeassistant/components/switchbot/services.py index d959c3ecb33..7e8c9a92152 100644 --- a/homeassistant/components/switchbot/services.py +++ b/homeassistant/components/switchbot/services.py @@ -1,7 +1,5 @@ """Services for the SwitchBot integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ConfigEntryState diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 5d306ed2aaa..076be9c30db 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -99,9 +99,21 @@ }, "door_unlocked_alarm": { "name": "Unlocked alarm" + }, + "half_lock_calibration": { + "name": "Half-lock calibration" + }, + "half_locked": { + "name": "Half locked" } }, "button": { + "half_lock": { + "name": "Half lock" + }, + "light_sensor": { + "name": "Light sensor" + }, "next_image": { "name": "Next image" }, @@ -206,6 +218,9 @@ } }, "light": { + "air_purifier_light": { + "name": "[%key:component::light::title%]" + }, "light": { "state_attributes": { "effect": { @@ -269,6 +284,11 @@ } } }, + "number": { + "display_time_offset": { + "name": "Display time offset" + } + }, "select": { "time_format": { "name": "Time format", @@ -288,6 +308,15 @@ "unhealthy": "Unhealthy" } }, + "battery_range": { + "name": "Battery range", + "state": { + "critical": "Critical", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, "bluetooth_signal": { "name": "Bluetooth signal" }, @@ -323,6 +352,12 @@ } } } + }, + "child_lock": { + "name": "Child lock" + }, + "wireless_charging": { + "name": "Wireless charging" } }, "vacuum": { @@ -349,7 +384,7 @@ "message": "The device ID {device_id} does not belong to SwitchBot integration." }, "device_not_found_error": { - "message": "Could not find Switchbot {sensor_type} with address {address}" + "message": "Could not find Switchbot {sensor_type} with address {address}: {reason}" }, "device_without_config_entry": { "message": "The device ID {device_id} is not associated with a config entry." diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index d67aaed3412..5d69696f081 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -1,23 +1,60 @@ """Support for Switchbot bot.""" -from __future__ import annotations - +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging from typing import Any import switchbot -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN +from .const import AIRPURIFIER_BASIC_MODELS, AIRPURIFIER_TABLE_MODELS, DOMAIN from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotSwitchedEntity, exception_handler + +@dataclass(frozen=True, kw_only=True) +class SwitchbotSwitchEntityDescription(SwitchEntityDescription): + """Describes a Switchbot switch entity.""" + + is_on_fn: Callable[[switchbot.SwitchbotDevice], bool | None] + turn_on_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]] + turn_off_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]] + + +AIRPURIFIER_BASIC_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = ( + SwitchbotSwitchEntityDescription( + key="child_lock", + translation_key="child_lock", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda device: device.is_child_lock_on(), + turn_on_fn=lambda device: device.open_child_lock(), + turn_off_fn=lambda device: device.close_child_lock(), + ), +) + +AIRPURIFIER_TABLE_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = ( + *AIRPURIFIER_BASIC_SWITCHES, + SwitchbotSwitchEntityDescription( + key="wireless_charging", + translation_key="wireless_charging", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda device: device.is_wireless_charging_on(), + turn_on_fn=lambda device: device.open_wireless_charging(), + turn_off_fn=lambda device: device.close_wireless_charging(), + ), +) + PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @@ -36,10 +73,64 @@ async def async_setup_entry( for channel in range(1, coordinator.device.channel + 1) ] async_add_entities(entries) + elif coordinator.model in AIRPURIFIER_BASIC_MODELS: + async_add_entities( + [ + SwitchbotGenericSwitch(coordinator, desc) + for desc in AIRPURIFIER_BASIC_SWITCHES + ] + ) + elif coordinator.model in AIRPURIFIER_TABLE_MODELS: + async_add_entities( + [ + SwitchbotGenericSwitch(coordinator, desc) + for desc in AIRPURIFIER_TABLE_SWITCHES + ] + ) else: async_add_entities([SwitchBotSwitch(coordinator)]) +class SwitchbotGenericSwitch(SwitchbotSwitchedEntity, SwitchEntity): + """Representation of a Switchbot switch controlled via entity description.""" + + entity_description: SwitchbotSwitchEntityDescription + _device: switchbot.SwitchbotDevice + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + description: SwitchbotSwitchEntityDescription, + ) -> None: + """Initialize the Switchbot generic switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self.entity_description.is_on_fn(self._device) + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + _LOGGER.debug( + "Turning on %s for %s", self.entity_description.key, self._address + ) + await self.entity_description.turn_on_fn(self._device) + self.async_write_ha_state() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + _LOGGER.debug( + "Turning off %s for %s", self.entity_description.key, self._address + ) + await self.entity_description.turn_off_fn(self._device) + self.async_write_ha_state() + + class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity): """Representation of a Switchbot switch.""" diff --git a/homeassistant/components/switchbot/vacuum.py b/homeassistant/components/switchbot/vacuum.py index 8535fdc7843..dd8dbcecd31 100644 --- a/homeassistant/components/switchbot/vacuum.py +++ b/homeassistant/components/switchbot/vacuum.py @@ -1,7 +1,5 @@ """Support for switchbot vacuums.""" -from __future__ import annotations - from typing import Any import switchbot diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index dd47f37e7e0..e11e431a173 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -13,6 +13,7 @@ from switchbot_api import ( SwitchBotAPI, SwitchBotAuthenticationError, SwitchBotConnectionError, + SwitchBotDeviceOfflineError, ) from homeassistant.components import webhook @@ -22,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, ENTRY_TITLE +from .const import DEVICE_SUPPORT_MAP, DOMAIN, ENTRY_TITLE from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) @@ -74,9 +75,12 @@ class SwitchbotCloudData: devices: SwitchbotDevices +type SwitchbotCloudConfigEntry = ConfigEntry[SwitchbotCloudData] + + async def coordinator_for_device( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], @@ -96,7 +100,7 @@ async def coordinator_for_device( async def make_switchbot_devices( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], @@ -114,7 +118,7 @@ async def make_switchbot_devices( async def make_device_data( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, device: Device | Remote, devices_data: SwitchbotDevices, @@ -150,12 +154,6 @@ async def make_device_data( devices_data.switches.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ - "Meter", - "MeterPlus", - "WoIOSensor", - "Hub 2", - "MeterPro", - "MeterPro(CO2)", "Relay Switch 1PM", "Plug Mini (US)", "Plug Mini (JP)", @@ -202,7 +200,7 @@ async def make_device_data( if isinstance(device, Device) and device.device_type == "Bot": coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id, True ) devices_data.sensors.append((device, coordinator)) if coordinator.data is not None: @@ -222,22 +220,6 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.fans.append((device, coordinator)) - if isinstance(device, Device) and device.device_type in [ - "Motion Sensor", - "Contact Sensor", - "Presence Sensor", - ]: - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id, True - ) - devices_data.sensors.append((device, coordinator)) - devices_data.binary_sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "Hub 3": - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id, True - ) - devices_data.sensors.append((device, coordinator)) - devices_data.binary_sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type == "Water Detector": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id, True @@ -309,12 +291,6 @@ async def make_device_data( ) devices_data.humidifiers.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "Climate Panel": - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.binary_sensors.append((device, coordinator)) - devices_data.sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type == "AI Art Frame": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id @@ -322,14 +298,61 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) devices_data.images.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "WeatherStation": - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.sensors.append((device, coordinator)) + + await make_new_device_data( + hass, entry, api, device, devices_data, coordinators_by_id + ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def make_new_device_data( + hass: HomeAssistant, + entry: SwitchbotCloudConfigEntry, + api: SwitchBotAPI, + device: Device | Remote, + devices_data: SwitchbotDevices, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Make device data.""" + if device.device_type not in DEVICE_SUPPORT_MAP: + return + + default_config = DEVICE_SUPPORT_MAP[device.device_type] + coordinator = await coordinator_for_device( + hass, + entry, + api, + device, + coordinators_by_id, + manageable_by_webhook=default_config.webhook, + ) + + _platform_list_map: dict[Platform, list] = { + Platform.BINARY_SENSOR: devices_data.binary_sensors, + Platform.BUTTON: devices_data.buttons, + Platform.CLIMATE: devices_data.climates, + Platform.COVER: devices_data.covers, + Platform.FAN: devices_data.fans, + Platform.HUMIDIFIER: devices_data.humidifiers, + Platform.IMAGE: devices_data.images, + Platform.LIGHT: devices_data.lights, + Platform.LOCK: devices_data.locks, + Platform.SENSOR: devices_data.sensors, + Platform.SWITCH: devices_data.switches, + Platform.VACUUM: devices_data.vacuums, + } + + for platform in default_config.entity_config: + target_list = _platform_list_map.get(platform) + if target_list is None: + continue + existing_ids = {item[0].device_id for item in target_list} + if device.device_id not in existing_ids: + target_list.append((device, coordinator)) + + +async def async_setup_entry( + hass: HomeAssistant, entry: SwitchbotCloudConfigEntry +) -> bool: """Set up SwitchBot via API from a config entry.""" token = entry.data[CONF_API_TOKEN] secret = entry.data[CONF_API_KEY] @@ -352,10 +375,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: switchbot_devices = await make_switchbot_devices( hass, entry, api, devices, coordinators_by_id ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = SwitchbotCloudData( - api=api, devices=switchbot_devices - ) + entry.runtime_data = SwitchbotCloudData(api=api, devices=switchbot_devices) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -364,17 +384,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SwitchbotCloudConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _initialize_webhook( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> None: @@ -409,42 +428,49 @@ async def _initialize_webhook( hass, entry.data[CONF_WEBHOOK_ID], ) - # check if webhook is configured in switchbot cloud - check_webhook_result = None - with contextlib.suppress(Exception): - check_webhook_result = await api.get_webook_configuration() - actual_webhook_urls = ( - check_webhook_result["urls"] - if check_webhook_result and "urls" in check_webhook_result - else [] - ) - need_add_webhook = ( - len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls - ) - need_clean_previous_webhook = ( - len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls - ) + try: + check_webhook_result = None + with contextlib.suppress(Exception): + check_webhook_result = await api.get_webook_configuration() - if need_clean_previous_webhook: - # it seems is impossible to register multiple webhook. - # So, if webhook already exists, we delete it - await api.delete_webhook(actual_webhook_urls[0]) - _LOGGER.debug( - "Deleted previous Switchbot cloud webhook url: %s", - actual_webhook_urls[0], + actual_webhook_urls = ( + check_webhook_result["urls"] + if check_webhook_result and "urls" in check_webhook_result + else [] + ) + need_add_webhook = ( + len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls + ) + need_clean_previous_webhook = ( + len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls ) - if need_add_webhook: - # call api for register webhookurl - await api.setup_webhook(webhook_url) - _LOGGER.debug("Registered Switchbot cloud webhook at hass: %s", webhook_url) + if need_clean_previous_webhook: + # it seems is impossible to register multiple webhook. + # So, if webhook already exists, we delete it + await api.delete_webhook(actual_webhook_urls[0]) + _LOGGER.debug( + "Deleted previous Switchbot cloud webhook url: %s", + actual_webhook_urls[0], + ) - for coordinator in coordinators_by_id.values(): - coordinator.webhook_subscription_listener(True) + if need_add_webhook: + # call api for register webhookurl + await api.setup_webhook(webhook_url) + _LOGGER.debug( + "Registered Switchbot cloud webhook at hass: %s", webhook_url + ) - _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + for coordinator in coordinators_by_id.values(): + coordinator.webhook_subscription_listener(True) + + _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + except SwitchBotDeviceOfflineError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) + except SwitchBotConnectionError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) def _create_handle_webhook( @@ -476,14 +502,20 @@ def _create_handle_webhook( _LOGGER.debug("Received invalid data from switchbot webhook %s", repr(data)) return _LOGGER.debug("Received data from switchbot webhook: %s", repr(data)) - deviceMac = data["context"]["deviceMac"] + device_mac = data["context"]["deviceMac"] - if deviceMac not in coordinators_by_id: - _LOGGER.error( - "Received data for unknown entity from switchbot webhook: %s", data + registered_device_macs = [ + coordinator.data.get("deviceMac") or coordinator.data.get("deviceId") + for coordinator in coordinators_by_id.values() + if coordinator.manageable_by_webhook() and coordinator.data is not None + ] + if device_mac not in registered_device_macs: + _LOGGER.debug( + "Received data for an unregistered webhook entity from SwitchBot Webhook: %s", + data, ) return - coordinators_by_id[deviceMac].async_set_updated_data(data["context"]) + coordinators_by_id[device_mac].async_set_updated_data(data["context"]) return _internal_handle_webhook diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index dac916c6cae..fdb625866e1 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -11,13 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -127,7 +125,7 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Hub 3": (MOVE_DETECTED_DESCRIPTION,), "Water Detector": (LEAK_DESCRIPTION,), - "Climate Panel": ( + "Home Climate Panel": ( IS_LIGHT_DESCRIPTION, MOVE_DETECTED_DESCRIPTION, ), @@ -137,19 +135,25 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data - async_add_entities( - SwitchBotCloudBinarySensor(data.api, device, coordinator, description) - for device, coordinator in data.devices.binary_sensors + entities: list[SwitchBotCloudBinarySensor] = [] + + for device, coordinator in data.devices.binary_sensors: + if not BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES.get(device.device_type): + continue for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type - ] - ) + ]: + entities.extend( + [SwitchBotCloudBinarySensor(data.api, device, coordinator, description)] + ) + + async_add_entities(entities) class SwitchBotCloudBinarySensor(SwitchBotCloudEntity, BinarySensorEntity): diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py index d64139a052c..9bf9d4f7df6 100644 --- a/homeassistant/components/switchbot_cloud/button.py +++ b/homeassistant/components/switchbot_cloud/button.py @@ -12,12 +12,10 @@ from switchbot_api import ( from switchbot_api.commands import ArtFrameCommands, BotCommands, CommonCommands from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -58,13 +56,15 @@ BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES = { async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudBot] = [] for device, coordinator in data.devices.buttons: + if not BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES.get(device.device_type): + continue description_set = BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] for description in description_set: entities.extend( diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 629e34197f4..5276409b719 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -26,7 +26,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_TENTHS, STATE_UNAVAILABLE, @@ -37,10 +36,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import SwitchbotCloudData, SwitchBotCoordinator +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .const import ( CLIMATE_PRESET_SCHEDULE, - DOMAIN, SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH, ) from .entity import SwitchBotCloudEntity @@ -69,11 +67,11 @@ _DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.climates diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 15e958b4777..ce0f1dfa07a 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -1,9 +1,12 @@ """Constants for the SwitchBot Cloud integration.""" +from dataclasses import dataclass from datetime import timedelta from enum import Enum from typing import Final +from homeassistant.const import Platform + DOMAIN: Final = "switchbot_cloud" ENTRY_TITLE = "SwitchBot Cloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=600) @@ -61,3 +64,61 @@ class Humidifier2Mode(Enum): def get_modes(cls) -> list[str]: """Return a list of available humidifier2 modes as lowercase strings.""" return [mode.name.lower() for mode in cls] + + +class SwitchbotCloudDeviceLockState(Enum): + """Lock State.""" + + LOCKED = "locked" + UNLOCKED = "unlocked" + LOCKING = "locking" + UNLOCKING = "unlocking" + JAMMED = "jammed" + LATCH_BOLT_LOCKED = "latchBoltLocked" + HALF_LOCKED = "halfLocked" + + @classmethod + def get_states(cls) -> list[SwitchbotCloudDeviceLockState]: + """Get lock states.""" + return list(cls) + + @classmethod + def get_values(cls) -> list[str]: + """Get lock value.""" + return [mode.value for mode in cls] + + +@dataclass(frozen=True) +class SwitchbotCloudDeviceConfig: + """Switchbot Cloud Device Config.""" + + webhook: bool + entity_config: tuple[Platform, ...] + + +DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { + "Motion Sensor": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + ), + "Contact Sensor": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + ), + "Presence Sensor": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + ), + "Hub 3": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + ), + "Home Climate Panel": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR) + ), + "WeatherStation": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR,) + ), + "Meter": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPlus": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), +} diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py index e5e7b745cbb..0543d2bb5d0 100644 --- a/homeassistant/components/switchbot_cloud/cover.py +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -18,22 +18,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.covers diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 376ed47f79f..b4ddaedd7c0 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -3,8 +3,14 @@ from typing import Any from switchbot_api import Commands, Device, Remote, SwitchBotAPI +from switchbot_api.exceptions import ( + SwitchBotConnectionError, + SwitchBotDeviceOfflineError, + SwitchBotError, +) from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -46,13 +52,37 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): command_type: str = "command", parameters: dict | str | int = "default", ) -> None: - """Send command to device.""" - await self._api.send_command( - self._attr_unique_id, - command, - command_type, - parameters, - ) + """Send command to device. + + Translate SwitchBot library exceptions into ``HomeAssistantError`` so + device-communication failures follow the developer-docs guidance and + can be suppressed by script-level flags such as ``continue_on_error``, + which only catches ``HomeAssistantError`` subclasses. + """ + try: + await self._api.send_command( + self._attr_unique_id, + command, + command_type, + parameters, + ) + except SwitchBotDeviceOfflineError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_offline", + ) from err + except SwitchBotConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + except SwitchBotError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"error": str(err)}, + ) from err @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index 45704d49922..32675cf83f2 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -13,13 +13,12 @@ from switchbot_api import ( ) from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode +from . import SwitchbotCloudConfigEntry +from .const import AFTER_COMMAND_REFRESH, AirPurifierMode from .entity import SwitchBotCloudEntity _LOGGER = logging.getLogger(__name__) @@ -28,11 +27,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data for device, coordinator in data.devices.fans: if device.device_type.startswith("Air Purifier"): async_add_entities( diff --git a/homeassistant/components/switchbot_cloud/humidifier.py b/homeassistant/components/switchbot_cloud/humidifier.py index dc4824bd890..808c4c02619 100644 --- a/homeassistant/components/switchbot_cloud/humidifier.py +++ b/homeassistant/components/switchbot_cloud/humidifier.py @@ -12,13 +12,12 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode +from . import SwitchbotCloudConfigEntry +from .const import AFTER_COMMAND_REFRESH, HUMIDITY_LEVELS, Humidifier2Mode from .entity import SwitchBotCloudEntity PARALLEL_UPDATES = 0 @@ -26,11 +25,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SwitchBotHumidifier(data.api, device, coordinator) if device.device_type == "Humidifier" diff --git a/homeassistant/components/switchbot_cloud/image.py b/homeassistant/components/switchbot_cloud/image.py index e6966845ae0..9e513d8f4a2 100644 --- a/homeassistant/components/switchbot_cloud/image.py +++ b/homeassistant/components/switchbot_cloud/image.py @@ -6,22 +6,20 @@ from switchbot_api import Device, Remote, SwitchBotAPI from switchbot_api.utils import get_file_stream_from_cloud from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.images diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index d3bf22beebb..eedba4377be 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -14,12 +14,11 @@ from switchbot_api import ( ) from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import AFTER_COMMAND_REFRESH, DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import AFTER_COMMAND_REFRESH from .entity import SwitchBotCloudEntity @@ -35,11 +34,11 @@ def brightness_map_value(value: int) -> int: async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.lights diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 191b17c397e..916d3239ce2 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -5,22 +5,20 @@ from typing import Any from switchbot_api import Device, LockCommands, LockV2Commands, Remote, SwitchBotAPI from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( SwitchBotCloudLock(data.api, device, coordinator) for device, coordinator in data.devices.locks diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 11cb9f7bb57..602c625f484 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -26,8 +25,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry +from .const import DOMAIN, SwitchbotCloudDeviceLockState from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -48,6 +47,8 @@ RELAY_SWITCH_2PM_SENSOR_TYPE_VOLTAGE = "Voltage" RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT = "ElectricCurrent" RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY = "UsedElectricity" +LOCK_SENSOR_TYPE_LOCK_STATE = "lockState" + @dataclass(frozen=True, kw_only=True) class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): @@ -166,6 +167,21 @@ LIGHTLEVEL_DESCRIPTION = SwitchbotCloudSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, ) + +LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION = SwitchbotCloudSensorEntityDescription( + key=LOCK_SENSOR_TYPE_LOCK_STATE, + device_class=SensorDeviceClass.ENUM, + translation_key="lock_state", + options=[ + value.name.lower() for value in SwitchbotCloudDeviceLockState.get_states() + ], + value_fn=lambda value: ( + SwitchbotCloudDeviceLockState(value).name.lower() + if value in SwitchbotCloudDeviceLockState.get_values() + else None + ), +) + SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), "Battery Circulator Fan": (BATTERY_DESCRIPTION,), @@ -225,7 +241,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Smart Lock": (BATTERY_DESCRIPTION,), "Smart Lock Lite": (BATTERY_DESCRIPTION,), "Smart Lock Pro": (BATTERY_DESCRIPTION,), - "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": ( + BATTERY_DESCRIPTION, + LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION, + ), "Smart Lock Vision": (BATTERY_DESCRIPTION,), "Smart Lock Vision Pro": (BATTERY_DESCRIPTION,), "Lock Vision": (BATTERY_DESCRIPTION,), @@ -250,7 +269,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Contact Sensor": (BATTERY_DESCRIPTION,), "Water Detector": (BATTERY_DESCRIPTION,), "Humidifier": (TEMPERATURE_DESCRIPTION,), - "Climate Panel": ( + "Home Climate Panel": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, BATTERY_DESCRIPTION, @@ -267,14 +286,17 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudSensor] = [] for device, coordinator in data.devices.sensors: - for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]: + if device.device_type not in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES: + continue + descriptions = SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] + for description in descriptions: if device.device_type == "Relay Switch 2PM": entities.append( SwitchBotCloudRelaySwitch2PMSensor( @@ -315,7 +337,6 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): if not self.coordinator.data: return value = self.coordinator.data.get(self.entity_description.key) - self._attr_native_value = self.entity_description.value_fn(value) diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 6883efff030..5d49dc587f1 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -76,7 +76,30 @@ "sensor": { "light_level": { "name": "Light level" + }, + "lock_state": { + "name": "Lock state", + "state": { + "half_locked": "Half locked", + "jammed": "Jammed", + "latch_bolt_locked": "Latch bolt locked", + "locked": "[%key:common::state::locked%]", + "locking": "Locking", + "unlocked": "[%key:common::state::unlocked%]", + "unlocking": "Unlocking" + } } } + }, + "exceptions": { + "command_failed": { + "message": "SwitchBot Cloud command failed: {error}" + }, + "connection_error": { + "message": "Error communicating with SwitchBot Cloud: {error}" + }, + "device_offline": { + "message": "SwitchBot Cloud device is offline." + } } } diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 2ca98f928b4..d6e123f9183 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -6,12 +6,11 @@ from typing import Any from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudConfigEntry from .const import AFTER_COMMAND_REFRESH, DOMAIN from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -19,11 +18,11 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudSwitch] = [] for device, coordinator in data.devices.switches: if device.device_type == "Relay Switch 2PM": diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 595bcee8e2e..40e694225e0 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -17,13 +17,11 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudConfigEntry from .const import ( - DOMAIN, VACUUM_FAN_SPEED_MAX, VACUUM_FAN_SPEED_QUIET, VACUUM_FAN_SPEED_STANDARD, @@ -35,11 +33,11 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.vacuums diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 840b62252f1..dcc843349bd 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -1,7 +1,5 @@ """The Switcher integration.""" -from __future__ import annotations - import logging from aioswitcher.bridge import SwitcherBridge @@ -48,7 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> # New device - create device _LOGGER.info( - "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s), is_token_needed: %s", + "Discovered Switcher device - id: %s, key: %s," + " name: %s, type: %s (%s), is_token_needed: %s", device.device_id, device.device_key, device.name, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index ba4bc4dc776..b5c01f3d571 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -1,7 +1,5 @@ """Switcher integration Button platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -72,7 +70,7 @@ async def async_setup_entry( async def async_add_buttons(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add button from Switcher device.""" data = cast(SwitcherBreezeRemote, coordinator.data) - if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + if coordinator.data.device_type.category is DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( get_breeze_remote_manager(hass).get_remote, data.remote_id ) diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 8ed64d5f039..f0202eabf52 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -1,7 +1,5 @@ """Switcher integration Climate platform.""" -from __future__ import annotations - from typing import Any, cast from aioswitcher.api.remotes import SwitcherBreezeRemote @@ -69,7 +67,7 @@ async def async_setup_entry( async def async_add_climate(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add climate from Switcher device.""" data = cast(SwitcherThermostat, coordinator.data) - if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + if coordinator.data.device_type.category is DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( get_breeze_remote_manager(hass).get_remote, data.remote_id ) @@ -132,7 +130,7 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): self._attr_target_temperature = float(data.target_temperature) self._attr_hvac_mode = HVACMode.OFF - if data.device_state == DeviceState.ON: + if data.device_state is DeviceState.ON: self._attr_hvac_mode = DEVICE_MODE_TO_HA[data.mode] self._attr_fan_mode = None @@ -146,7 +144,7 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): if features["swing"]: self._attr_swing_mode = SWING_OFF self._attr_swing_modes = [SWING_VERTICAL, SWING_OFF] - if data.swing == ThermostatSwing.ON: + if data.swing is ThermostatSwing.ON: self._attr_swing_mode = SWING_VERTICAL async def _async_control_breeze_device(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index d0803b117e2..7d8f3af3798 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Switcher integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, Final diff --git a/homeassistant/components/switcher_kis/coordinator.py b/homeassistant/components/switcher_kis/coordinator.py index 118c86b8d78..9617964e947 100644 --- a/homeassistant/components/switcher_kis/coordinator.py +++ b/homeassistant/components/switcher_kis/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Switcher integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index ebb6126f292..edc48f23326 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -1,7 +1,5 @@ """Switcher integration Cover platform.""" -from __future__ import annotations - from typing import Any, cast from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter @@ -77,10 +75,10 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity): self._attr_current_cover_position = data.position[self._cover_id] self._attr_is_closed = data.position[self._cover_id] == 0 self._attr_is_closing = ( - data.direction[self._cover_id] == ShutterDirection.SHUTTER_DOWN + data.direction[self._cover_id] is ShutterDirection.SHUTTER_DOWN ) self._attr_is_opening = ( - data.direction[self._cover_id] == ShutterDirection.SHUTTER_UP + data.direction[self._cover_id] is ShutterDirection.SHUTTER_UP ) async def async_close_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py index a81e3e25bb9..2d6a78104af 100644 --- a/homeassistant/components/switcher_kis/diagnostics.py +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Switcher.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index d599b478a7f..c582afe7930 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -1,7 +1,5 @@ """Switcher integration Light platform.""" -from __future__ import annotations - from typing import Any, cast from aioswitcher.device import DeviceCategory, DeviceState, SwitcherLight @@ -80,7 +78,7 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity): return data = cast(SwitcherLight, self.coordinator.data) - self._attr_is_on = bool(data.light[self._light_id] == DeviceState.ON) + self._attr_is_on = bool(data.light[self._light_id] is DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index d253c340788..f2e973e108c 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -1,7 +1,5 @@ """Switcher integration Sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -95,7 +93,7 @@ async def async_setup_entry( @callback def async_add_sensors(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add sensors from Switcher device.""" - if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: + if coordinator.data.device_type.category is DeviceCategory.POWER_PLUG: async_add_entities( SwitcherSensorEntity(coordinator, description) for description in POWER_PLUG_SENSORS @@ -108,7 +106,7 @@ async def async_setup_entry( SwitcherSensorEntity(coordinator, description) for description in HEATER_SENSORS ) - elif coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + elif coordinator.data.device_type.category is DeviceCategory.THERMOSTAT: async_add_entities( SwitcherSensorEntity(coordinator, description) for description in THERMOSTAT_SENSORS diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index d79b319fc6e..c751fa4ca29 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -1,7 +1,5 @@ """Switcher integration Switch platform.""" -from __future__ import annotations - from datetime import timedelta from typing import Any, cast @@ -77,7 +75,7 @@ async def async_setup_entry( """Add switch from Switcher device.""" entities: list[SwitchEntity] = [] - if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: + if coordinator.data.device_type.category is DeviceCategory.POWER_PLUG: entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category in [ DeviceCategory.WATER_HEATER, @@ -125,7 +123,7 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity): self.control_result = None return - self._attr_is_on = bool(self.coordinator.data.device_state == DeviceState.ON) + self._attr_is_on = bool(self.coordinator.data.device_state is DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -190,7 +188,7 @@ class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): return data = cast(SwitcherShutter, self.coordinator.data) - self._attr_is_on = bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + self._attr_is_on = bool(data.child_lock[self._cover_id] is ShutterChildLock.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index 44f906aef44..06e313bd038 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -1,7 +1,5 @@ """Switcher integration helpers functions.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py index 0b449c65194..98c10b8d186 100644 --- a/homeassistant/components/switchmate/switch.py +++ b/homeassistant/components/switchmate/switch.py @@ -1,7 +1,5 @@ """Support for Switchmate.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/syncthing/__init__.py b/homeassistant/components/syncthing/__init__.py index 091b9b0c949..73850904a7b 100644 --- a/homeassistant/components/syncthing/__init__.py +++ b/homeassistant/components/syncthing/__init__.py @@ -18,26 +18,19 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - DOMAIN, - EVENTS, - RECONNECT_INTERVAL, - SERVER_AVAILABLE, - SERVER_UNAVAILABLE, -) +from .const import EVENTS, RECONNECT_INTERVAL, SERVER_AVAILABLE, SERVER_UNAVAILABLE PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type SyncthingConfigEntry = ConfigEntry[SyncthingClient] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: SyncthingConfigEntry) -> bool: """Set up syncthing from a config entry.""" data = entry.data - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - client = aiosyncthing.Syncthing( data[CONF_TOKEN], url=data[CONF_URL], @@ -54,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: syncthing = SyncthingClient(hass, client, server_id) syncthing.subscribe() - hass.data[DOMAIN][entry.entry_id] = syncthing + entry.runtime_data = syncthing await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -69,12 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SyncthingConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - syncthing = hass.data[DOMAIN].pop(entry.entry_id) - await syncthing.unsubscribe() + await entry.runtime_data.unsubscribe() return unload_ok diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index d57da2b30ca..5304f1e8f3c 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -6,7 +6,6 @@ from typing import Any import aiosyncthing from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,7 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from . import SyncthingClient +from . import SyncthingClient, SyncthingConfigEntry from .const import ( DOMAIN, FOLDER_PAUSED_RECEIVED, @@ -28,11 +27,11 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncthingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Syncthing sensors.""" - syncthing = hass.data[DOMAIN][config_entry.entry_id] + syncthing = config_entry.runtime_data try: config = await syncthing.system.config() diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index f514f538821..bd6bee62f49 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -1,7 +1,5 @@ """The syncthru component.""" -from __future__ import annotations - from pysyncthru import SyncThruAPINotSupported from homeassistant.const import Platform diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 56edff38680..3defe4daf58 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Samsung Printers with SyncThru web interface.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index c245b181cc2..24f9f39d42e 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -92,6 +92,8 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str, + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=user_input.get(CONF_NAME, "")): str, } ), diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py index 169d354ef76..cfa5beb9221 100644 --- a/homeassistant/components/syncthru/diagnostics.py +++ b/homeassistant/components/syncthru/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Syncthru.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index ec6ecce7ace..6ff5b95edb2 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.1"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==3.0.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index e65c3b6ba71..4076f75bf6b 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -1,7 +1,5 @@ """Support for Samsung Printers with SyncThru web interface.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py index a4ae3b1aaa2..4814271e016 100644 --- a/homeassistant/components/synology_chat/notify.py +++ b/homeassistant/components/synology_chat/notify.py @@ -1,7 +1,5 @@ """SynologyChat platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index d5254798072..e0d0668f558 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,7 +1,5 @@ """The Synology DSM component.""" -from __future__ import annotations - from itertools import chain import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 3933a3f2fc2..70d43212186 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -1,7 +1,5 @@ """Support for Synology DSM backup agents.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 3af87f9756d..102b07100d0 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Synology DSM binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index d7623045437..d413724c30c 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -1,7 +1,5 @@ """Support for Synology DSM buttons.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 56183804e5f..424bdd0b04d 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -1,7 +1,5 @@ """Support for Synology DSM cameras.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 8b4cf655388..3e9710f36fc 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -1,7 +1,5 @@ """The Synology DSM component.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from contextlib import suppress diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index e92a052fa6e..4bcbd19920d 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Synology DSM integration.""" -from __future__ import annotations - from collections.abc import Mapping from contextlib import suppress from ipaddress import ip_address as ip @@ -307,7 +305,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): friendly_name = upnp_friendly_name.split("(", 1)[0].strip() mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL] discovered_macs = [format_synology_mac(mac_address)] - # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. + # Synology NAS can broadcast on multiple IP addresses, + # since they can be connected to multiple Ethernet interfaces. # The serial of the NAS is actually its MAC address. host = cast(str, parsed_url.hostname) return await self._async_from_discovery(host, friendly_name, discovered_macs) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 758fad53970..2a38f377d52 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -1,7 +1,5 @@ """Constants for Synology DSM.""" -from __future__ import annotations - from collections.abc import Callable from aiohttp import ClientTimeout diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index c2fa275c7de..83a5274a4b4 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -1,7 +1,5 @@ """synology_dsm coordinators.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index 5cba9ed5aac..fbec12d7877 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Synology DSM.""" -from __future__ import annotations - from typing import Any from homeassistant.components.camera import diagnostics as camera_diagnostics diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 3ffbcce5466..5f887579061 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -1,11 +1,13 @@ """Entities for Synology DSM.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,6 +58,9 @@ class SynologyDSMBaseEntity[_CoordinatorT: SynologyDSMUpdateCoordinator[Any]]( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, information.serial)}, + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) for mac in network.macs + }, name=network.hostname, manufacturer="Synology", model=information.model, diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 4d57beac4e4..d3b9f2be9a4 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -1,14 +1,14 @@ { "domain": "synology_dsm", "name": "Synology DSM", - "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], + "codeowners": ["@Quentame", "@mib1185"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "integration_type": "device", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.3"], + "requirements": ["py-synologydsm-api==2.9.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Basic:1", diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 94edef603ce..3913207770f 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -1,7 +1,5 @@ """Expose Synology DSM as a media source.""" -from __future__ import annotations - from logging import getLogger import mimetypes from typing import TYPE_CHECKING diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py index 8a4e47a32b5..2e425b18c61 100644 --- a/homeassistant/components/synology_dsm/repairs.py +++ b/homeassistant/components/synology_dsm/repairs.py @@ -1,7 +1,5 @@ """Repair flows for the Synology DSM integration.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import cast @@ -9,8 +7,11 @@ from typing import cast from synology_dsm.api.file_station.models import SynoFileSharedFolder import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.selector import ( @@ -43,7 +44,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( @@ -55,7 +56,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" syno_data = self.entry.runtime_data @@ -101,7 +102,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): async def async_step_ignore( self, _: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) return self.async_abort(reason="ignored") diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index dd46fa33c3a..aef7a96f409 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -1,10 +1,9 @@ """Support for Synology DSM sensors.""" -from __future__ import annotations - +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from synology_dsm.api.core.external_usb import ( SynoCoreExternalUSB, @@ -50,6 +49,8 @@ class SynologyDSMSensorEntityDescription( ): """Describes Synology DSM sensor entity.""" + value_fn: Callable[[SynoDSMInformation, str], Any] = getattr + UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( @@ -327,8 +328,10 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="uptime", - translation_key="uptime", - device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda api_information, _: ( + utcnow() - timedelta(seconds=api_information.uptime) + ), + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -545,29 +548,15 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" - def __init__( - self, - api: SynoApi, - coordinator: SynologyDSMCentralUpdateCoordinator, - description: SynologyDSMSensorEntityDescription, - ) -> None: - """Initialize the Synology SynoDSMInfoSensor entity.""" - super().__init__(api, coordinator, description) - self._previous_uptime: str | None = None - self._last_boot: datetime | None = None - @property def native_value(self) -> StateType | datetime: """Return the state.""" - attr = getattr(self._api.information, self.entity_description.key) - if attr is None: + if self._api.information is None: return None - if self.entity_description.key == "uptime": - # reboot happened or entity creation - if self._previous_uptime is None or self._previous_uptime > attr: - self._last_boot = utcnow() - timedelta(seconds=attr) - - self._previous_uptime = attr - return self._last_boot - return attr # type: ignore[no-any-return] + return cast( + StateType | datetime, + self.entity_description.value_fn( + self._api.information, self.entity_description.key + ), + ) diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py index ad0615eaa56..bfdac00b4df 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -1,13 +1,12 @@ """The Synology DSM component.""" -from __future__ import annotations - import logging -from typing import TYPE_CHECKING, cast +from typing import cast from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES from .coordinator import SynologyDSMConfigEntry @@ -27,27 +26,37 @@ async def _service_handler(call: ServiceCall) -> None: entry: SynologyDSMConfigEntry | None = ( call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) ) - if TYPE_CHECKING: - assert entry + if not entry: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="serial_not_found", + translation_placeholders={"serial": serial}, + ) dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) else: - LOGGER.error( - "More than one DSM configured, must specify one of serials %s", - sorted(dsm_devices), + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_serial", + translation_placeholders={"serials": ", ".join(sorted(dsm_devices))}, ) - return if not dsm_device: - LOGGER.error("DSM with specified serial %s not found", serial) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="serial_not_found", + translation_placeholders={"serial": serial}, + ) if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: if serial not in dsm_devices: - LOGGER.error("DSM with specified serial %s not found", serial) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="serial_not_found", + translation_placeholders={"serial": serial}, + ) LOGGER.debug("%s DSM with serial %s", call.service, serial) LOGGER.warning( ( @@ -61,13 +70,15 @@ async def _service_handler(call: ServiceCall) -> None: try: await getattr(dsm_api, f"async_{call.service}")() except SynologyDSMException as ex: - LOGGER.error( - "%s of DSM with serial %s not possible, because of %s", - call.service, - serial, - ex, - ) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execution_error", + translation_placeholders={ + "action": call.service, + "serial": serial, + "error": str(ex), + }, + ) from ex @callback diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 1ccd549be79..fe4c056398d 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -9,7 +9,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "missing_data": "Missing data: please retry later or an other configuration", + "missing_data": "Missing data: please retry later or try a different configuration", "otp_failed": "Two-step authentication failed, retry with a new passcode", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -157,9 +157,6 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, - "uptime": { - "name": "Last boot" - }, "volume_disk_temp_avg": { "name": "Average disk temp" }, @@ -190,6 +187,17 @@ } } }, + "exceptions": { + "execution_error": { + "message": "Execute {action} on DSM with serial {serial} not possible, because of {error}." + }, + "missing_serial": { + "message": "More than one DSM configured, must specify one of serials: {serials}." + }, + "serial_not_found": { + "message": "DSM with specified serial {serial} not found." + } + }, "issues": { "missing_backup_setup": { "fix_flow": { diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 8be6dedd8ca..1a53e5a2405 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -1,7 +1,5 @@ """Support for Synology DSM switch.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 6b421f639e7..58d55c17781 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -1,7 +1,5 @@ """Support for Synology DSM update platform.""" -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, Final diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index b916be84acf..1262ae9ca40 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker for Synology SRM routers.""" -from __future__ import annotations - import logging import synology_srm diff --git a/homeassistant/components/syslog/notify.py b/homeassistant/components/syslog/notify.py index 96102cc9c0a..b6e300a9e07 100644 --- a/homeassistant/components/syslog/notify.py +++ b/homeassistant/components/syslog/notify.py @@ -1,7 +1,5 @@ """Syslog notification service.""" -from __future__ import annotations - import syslog from typing import Any diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c057ae0c214..724498ab9a6 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -1,11 +1,7 @@ """The System Bridge integration.""" -from __future__ import annotations - import asyncio -from dataclasses import asdict import logging -from typing import Any from systembridgeconnector.exceptions import ( AuthenticationException, @@ -13,54 +9,34 @@ from systembridgeconnector.exceptions import ( ConnectionErrorException, DataMissingException, ) -from systembridgeconnector.models.keyboard_key import KeyboardKey -from systembridgeconnector.models.keyboard_text import KeyboardText -from systembridgeconnector.models.modules.processes import Process -from systembridgeconnector.models.open_path import OpenPath -from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import Version -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_COMMAND, CONF_ENTITY_ID, CONF_HOST, - CONF_ID, CONF_NAME, - CONF_PATH, CONF_PORT, CONF_TOKEN, - CONF_URL, Platform, ) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - discovery, -) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType from .config_flow import SystemBridgeConfigFlow from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, @@ -69,31 +45,17 @@ PLATFORMS = [ Platform.UPDATE, ] -CONF_BRIDGE = "bridge" -CONF_KEY = "key" -CONF_TEXT = "text" -SERVICE_GET_PROCESS_BY_ID = "get_process_by_id" -SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name" -SERVICE_OPEN_PATH = "open_path" -SERVICE_POWER_COMMAND = "power_command" -SERVICE_OPEN_URL = "open_url" -SERVICE_SEND_KEYPRESS = "send_keypress" -SERVICE_SEND_TEXT = "send_text" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the System Bridge services.""" -POWER_COMMAND_MAP = { - "hibernate": "power_hibernate", - "lock": "power_lock", - "logout": "power_logout", - "restart": "power_restart", - "shutdown": "power_shutdown", - "sleep": "power_sleep", -} + async_setup_services(hass) + return True async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> bool: """Set up System Bridge from a config entry.""" @@ -198,15 +160,11 @@ async def async_setup_entry( # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator - # Set up all platforms except notify - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Set up notify platform + # Set up legacy notify platform hass.async_create_task( discovery.async_load_platform( hass, @@ -216,263 +174,35 @@ async def async_setup_entry( CONF_NAME: f"{DOMAIN}_{coordinator.data.system.hostname}", CONF_ENTITY_ID: entry.entry_id, }, - hass.data[DOMAIN][entry.entry_id], + {}, ) ) - if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): - return True - - def valid_device(device: str) -> str: - """Check device is valid.""" - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device) - if device_entry is not None: - try: - return next( - entry.entry_id - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ) - except StopIteration as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device": device}, - ) from exception - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device": device}, - ) - - async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: - """Handle the get process by id service call.""" - _LOGGER.debug("Get process by id: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - processes: list[Process] = coordinator.data.processes - - # Find process.id from list, raise ServiceValidationError if not found - try: - return asdict( - next( - process - for process in processes - if process.id == service_call.data[CONF_ID] - ) - ) - except StopIteration as exception: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="process_not_found", - translation_placeholders={"id": service_call.data[CONF_ID]}, - ) from exception - - async def handle_get_processes_by_name( - service_call: ServiceCall, - ) -> ServiceResponse: - """Handle the get process by name service call.""" - _LOGGER.debug("Get process by name: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - - # Find processes from list - items: list[dict[str, Any]] = [ - asdict(process) - for process in coordinator.data.processes - if process.name is not None - and service_call.data[CONF_NAME].lower() in process.name.lower() - ] - - return { - "count": len(items), - "processes": list(items), - } - - async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: - """Handle the open path service call.""" - _LOGGER.debug("Open path: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - response = await coordinator.websocket_client.open_path( - OpenPath(path=service_call.data[CONF_PATH]) - ) - return asdict(response) - - async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: - """Handle the power command service call.""" - _LOGGER.debug("Power command: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - response = await getattr( - coordinator.websocket_client, - POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], - )() - return asdict(response) - - async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: - """Handle the open url service call.""" - _LOGGER.debug("Open URL: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - response = await coordinator.websocket_client.open_url( - OpenUrl(url=service_call.data[CONF_URL]) - ) - return asdict(response) - - async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: - """Handle the send_keypress service call.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - response = await coordinator.websocket_client.keyboard_keypress( - KeyboardKey(key=service_call.data[CONF_KEY]) - ) - return asdict(response) - - async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: - """Handle the send_keypress service call.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] - response = await coordinator.websocket_client.keyboard_text( - KeyboardText(text=service_call.data[CONF_TEXT]) - ) - return asdict(response) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_PROCESS_BY_ID, - handle_get_process_by_id, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_ID): cv.positive_int, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_GET_PROCESSES_BY_NAME, - handle_get_processes_by_name, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_NAME): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_OPEN_PATH, - handle_open_path, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_PATH): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_POWER_COMMAND, - handle_power_command, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP), - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_OPEN_URL, - handle_open_url, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_URL): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SEND_KEYPRESS, - handle_send_keypress, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_KEY): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - description_placeholders={ - "syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys" - }, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SEND_TEXT, - handle_send_text, - schema=vol.Schema( - { - vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_TEXT): cv.string, - }, - ), - supports_response=SupportsResponse.ONLY, - ) - # Reload entry when its updated. entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SystemBridgeConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data # Ensure disconnected and cleanup stop sub await coordinator.websocket_client.close() if coordinator.unsub: coordinator.unsub() - del hass.data[DOMAIN][entry.entry_id] - - if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_OPEN_PATH) - hass.services.async_remove(DOMAIN, SERVICE_OPEN_URL) - hass.services.async_remove(DOMAIN, SERVICE_SEND_KEYPRESS) - hass.services.async_remove(DOMAIN, SERVICE_SEND_TEXT) - return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry( + hass: HomeAssistant, entry: SystemBridgeConfigEntry +) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 883c74f2589..9583f62aeb6 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -1,7 +1,5 @@ """Support for System Bridge binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -10,13 +8,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -64,11 +60,11 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, .. async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge binary sensor based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ SystemBridgeBinarySensor(coordinator, description, entry.data[CONF_PORT]) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 6bf001c9603..871bc0274c1 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -1,7 +1,5 @@ """Config flow for System Bridge integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 6fca2e5902f..4016d7599da 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for System Bridge.""" -from __future__ import annotations - from asyncio import Task from collections.abc import Callable from datetime import timedelta @@ -36,18 +34,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES from .data import SystemBridgeData +type SystemBridgeConfigEntry = ConfigEntry[SystemBridgeDataUpdateCoordinator] + class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]): """Class to manage fetching System Bridge data from single endpoint.""" - config_entry: ConfigEntry + config_entry: SystemBridgeConfigEntry def __init__( self, hass: HomeAssistant, LOGGER: logging.Logger, *, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> None: """Initialize global System Bridge data updater.""" self.title = entry.title diff --git a/homeassistant/components/system_bridge/entity.py b/homeassistant/components/system_bridge/entity.py index b37e55cf406..91a25fa1024 100644 --- a/homeassistant/components/system_bridge/entity.py +++ b/homeassistant/components/system_bridge/entity.py @@ -17,13 +17,13 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): self, coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, - key: str, + key: str | None = None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname - self._key = f"{self._hostname}_{key}" + self._key = f"{self._hostname}_{key}" if key is not None else self._hostname self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" ) diff --git a/homeassistant/components/system_bridge/icons.json b/homeassistant/components/system_bridge/icons.json index 6a9488cadcf..c02c0e9931c 100644 --- a/homeassistant/components/system_bridge/icons.json +++ b/homeassistant/components/system_bridge/icons.json @@ -17,15 +17,51 @@ "boot_time": { "default": "mdi:av-timer" }, + "cpu_power_core": { + "default": "mdi:chip" + }, "cpu_power_package": { "default": "mdi:chip" }, "cpu_speed": { "default": "mdi:speedometer" }, + "display_refresh_rate": { + "default": "mdi:monitor" + }, + "display_resolution_x": { + "default": "mdi:monitor" + }, + "display_resolution_y": { + "default": "mdi:monitor" + }, "displays_connected": { "default": "mdi:monitor" }, + "gpu_core_clock_speed": { + "default": "mdi:speedometer" + }, + "gpu_fan_speed": { + "default": "mdi:fan" + }, + "gpu_memory_clock_speed": { + "default": "mdi:speedometer" + }, + "gpu_memory_free": { + "default": "mdi:memory" + }, + "gpu_memory_used": { + "default": "mdi:memory" + }, + "gpu_memory_used_percentage": { + "default": "mdi:memory" + }, + "gpu_power_usage": { + "default": "mdi:lightning-bolt" + }, + "gpu_usage_percentage": { + "default": "mdi:percent" + }, "kernel": { "default": "mdi:devices" }, @@ -38,6 +74,9 @@ "memory_used": { "default": "mdi:memory" }, + "memory_used_percentage": { + "default": "mdi:memory" + }, "os": { "default": "mdi:devices" }, @@ -47,6 +86,12 @@ "processes": { "default": "mdi:counter" }, + "processes_load_cpu": { + "default": "mdi:percent" + }, + "space_used": { + "default": "mdi:harddisk" + }, "version": { "default": "mdi:counter" }, diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index c7b1fab679a..a5a693a72ef 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -1,7 +1,5 @@ """Support for System Bridge media players.""" -from __future__ import annotations - import datetime as dt from typing import Final @@ -15,13 +13,11 @@ from homeassistant.components.media_player import ( MediaPlayerState, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -64,11 +60,11 @@ MEDIA_PLAYER_DESCRIPTION: Final[MediaPlayerEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge media players based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data data = coordinator.data if data.media is not None: diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 930557568b8..a217f5625da 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -1,7 +1,5 @@ """System Bridge Media Source Implementation.""" -from __future__ import annotations - from systembridgeconnector.models.media_directories import MediaDirectory from systembridgeconnector.models.media_files import MediaFile, MediaFiles from systembridgeconnector.models.media_get_files import MediaGetFiles @@ -15,12 +13,22 @@ from homeassistant.components.media_source import ( MediaSourceItem, PlayMedia, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry + + +def _get_loaded_entry(hass: HomeAssistant, entry_id: str) -> SystemBridgeConfigEntry: + """Return a loaded System Bridge config entry by id.""" + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ValueError("Invalid entry") + return entry async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -46,9 +54,7 @@ class SystemBridgeSource(MediaSource): ) -> PlayMedia: """Resolve media to a url.""" entry_id, path, mime_type = item.identifier.split("~~", 2) - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ValueError("Invalid entry") + entry = _get_loaded_entry(self.hass, entry_id) path_split = path.split("/", 1) return PlayMedia( f"{_build_base_url(entry)}&base={path_split[0]}&path={path_split[1]}", @@ -64,21 +70,14 @@ class SystemBridgeSource(MediaSource): return self._build_bridges() if "~~" not in item.identifier: - entry = self.hass.config_entries.async_get_entry(item.identifier) - if entry is None: - raise ValueError("Invalid entry") - coordinator: SystemBridgeDataUpdateCoordinator = self.hass.data[DOMAIN].get( - entry.entry_id - ) + entry = _get_loaded_entry(self.hass, item.identifier) + coordinator = entry.runtime_data directories = await coordinator.websocket_client.get_directories() return _build_root_paths(entry, directories) entry_id, path = item.identifier.split("~~", 1) - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ValueError("Invalid entry") - - coordinator = self.hass.data[DOMAIN].get(entry.entry_id) + entry = _get_loaded_entry(self.hass, entry_id) + coordinator = entry.runtime_data path_split = path.split("/", 1) @@ -123,7 +122,7 @@ class SystemBridgeSource(MediaSource): def _build_base_url( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> str: """Build base url for System Bridge media.""" return ( @@ -133,7 +132,7 @@ def _build_base_url( def _build_root_paths( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, media_directories: list[MediaDirectory], ) -> BrowseMediaSource: """Build base categories for System Bridge media.""" @@ -164,7 +163,7 @@ def _build_root_paths( def _build_media_items( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, media_files: MediaFiles, path: str, identifier: str, diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 2b13fef071e..de64677a293 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -1,10 +1,9 @@ """Support for System Bridge notification service.""" -from __future__ import annotations - import logging from typing import Any +from systembridgeconnector.exceptions import ConnectionClosedException from systembridgeconnector.models.notification import Notification from homeassistant.components.notify import ( @@ -12,13 +11,18 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, ) -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID +from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator +from .entity import SystemBridgeEntity _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,44 @@ ATTR_IMAGE = "image" ATTR_TIMEOUT = "timeout" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SystemBridgeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notification entity platform.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + [SystemBridgeNotifyEntity(coordinator, config_entry.data[CONF_PORT])] + ) + + +class SystemBridgeNotifyEntity(SystemBridgeEntity, NotifyEntity): + """Representation of a notification entity.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + _attr_name = None + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message via notify.send_message action.""" + notification = Notification( + message=message, title=ATTR_TITLE_DEFAULT if title is None else title + ) + try: + await self.coordinator.websocket_client.send_notification(notification) + except ConnectionClosedException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders={ + "title": self.coordinator.config_entry.title, + "host": self.coordinator.config_entry.data[CONF_HOST], + }, + ) from e + + async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -37,11 +79,13 @@ async def async_get_service( if discovery_info is None: return None - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( discovery_info[CONF_ENTITY_ID] - ] + ) + if entry is None: + return None - return SystemBridgeNotificationService(coordinator) + return SystemBridgeNotificationService(entry.runtime_data) class SystemBridgeNotificationService(BaseNotificationService): diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 220d2c8823b..6ce1ec1e8dc 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -1,7 +1,5 @@ """Support for System Bridge sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -17,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PORT, PERCENTAGE, @@ -30,11 +27,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED, StateType +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -288,10 +284,10 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", + translation_key="memory_used_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, - icon="mdi:memory", value=lambda data: data.memory.virtual.percent, ), SystemBridgeSensorEntityDescription( @@ -364,11 +360,11 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) @@ -384,11 +380,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"filesystem_{partition.mount_point.replace(':', '')}", - name=f"{partition.mount_point} space used", + translation_key="space_used", + translation_placeholders={"partition": partition.mount_point}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, - icon="mdi:harddisk", value=( lambda data, dk=index_device, pk=index_partition: ( partition_usage(data, dk, pk) @@ -431,10 +427,10 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display.id}_resolution_x", - name=f"Display {display.id} resolution x", + translation_key="display_resolution_x", + translation_placeholders={"display_id": display.id}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, - icon="mdi:monitor", value=lambda data, k=index: display_resolution_horizontal( data, k ), @@ -445,10 +441,10 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display.id}_resolution_y", - name=f"Display {display.id} resolution y", + translation_key="display_resolution_y", + translation_placeholders={"display_id": display.id}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, - icon="mdi:monitor", value=lambda data, k=index: display_resolution_vertical( data, k ), @@ -459,12 +455,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display.id}_refresh_rate", - name=f"Display {display.id} refresh rate", + translation_key="display_refresh_rate", + translation_placeholders={"display_id": display.id}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, suggested_display_precision=0, - icon="mdi:monitor", value=lambda data, k=index: display_refresh_rate(data, k), ), entry.data[CONF_PORT], @@ -478,13 +474,13 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_core_clock_speed", - name=f"{gpu.name} clock speed", + translation_key="gpu_core_clock_speed", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, suggested_display_precision=0, - icon="mdi:speedometer", value=lambda data, k=index: gpu_core_clock_speed(data, k), ), entry.data[CONF_PORT], @@ -493,13 +489,13 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_memory_clock_speed", - name=f"{gpu.name} memory clock speed", + translation_key="gpu_memory_clock_speed", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, suggested_display_precision=0, - icon="mdi:speedometer", value=lambda data, k=index: gpu_memory_clock_speed(data, k), ), entry.data[CONF_PORT], @@ -508,12 +504,12 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_memory_free", - name=f"{gpu.name} memory free", + translation_key="gpu_memory_free", + translation_placeholders={"gpu_name": gpu.name}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, suggested_display_precision=0, - icon="mdi:memory", value=lambda data, k=index: gpu_memory_free(data, k), ), entry.data[CONF_PORT], @@ -522,11 +518,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_memory_used_percentage", - name=f"{gpu.name} memory used %", + translation_key="gpu_memory_used_percentage", + translation_placeholders={"gpu_name": gpu.name}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, - icon="mdi:memory", value=lambda data, k=index: gpu_memory_used_percentage(data, k), ), entry.data[CONF_PORT], @@ -535,13 +531,13 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_memory_used", - name=f"{gpu.name} memory used", + translation_key="gpu_memory_used", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, suggested_display_precision=0, - icon="mdi:memory", value=lambda data, k=index: gpu_memory_used(data, k), ), entry.data[CONF_PORT], @@ -550,11 +546,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_fan_speed", - name=f"{gpu.name} fan speed", + translation_key="gpu_fan_speed", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, - icon="mdi:fan", value=lambda data, k=index: gpu_fan_speed(data, k), ), entry.data[CONF_PORT], @@ -563,7 +559,8 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_power_usage", - name=f"{gpu.name} power usage", + translation_key="gpu_power_usage", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -575,7 +572,8 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_temperature", - name=f"{gpu.name} temperature", + translation_key="gpu_temperature", + translation_placeholders={"gpu_name": gpu.name}, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -589,11 +587,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{gpu.id}_usage_percentage", - name=f"{gpu.name} usage %", + translation_key="gpu_usage_percentage", + translation_placeholders={"gpu_name": gpu.name}, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, - icon="mdi:percent", value=lambda data, k=index: gpu_usage_percentage(data, k), ), entry.data[CONF_PORT], @@ -609,11 +607,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"processes_load_cpu_{cpu.id}", - name=f"Load CPU {cpu.id}", + translation_key="processes_load_cpu", + translation_placeholders={"cpu_id": str(cpu.id)}, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k), ), @@ -623,11 +621,11 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"cpu_power_core_{cpu.id}", - name=f"CPU Core {cpu.id} Power", + translation_key="cpu_power_core", + translation_placeholders={"cpu_id": str(cpu.id)}, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:chip", suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k), ), @@ -657,8 +655,6 @@ class SystemBridgeSensor(SystemBridgeEntity, SensorEntity): description.key, ) self.entity_description = description - if description.name != UNDEFINED: - self._attr_has_entity_name = False @property def native_value(self) -> StateType: diff --git a/homeassistant/components/system_bridge/services.py b/homeassistant/components/system_bridge/services.py new file mode 100644 index 00000000000..2eaa3bce35b --- /dev/null +++ b/homeassistant/components/system_bridge/services.py @@ -0,0 +1,269 @@ +"""Service registration for System Bridge integration.""" + +from dataclasses import asdict +import logging +from typing import Any + +from systembridgeconnector.models.keyboard_key import KeyboardKey +from systembridgeconnector.models.keyboard_text import KeyboardText +from systembridgeconnector.models.modules.processes import Process +from systembridgeconnector.models.open_path import OpenPath +from systembridgeconnector.models.open_url import OpenUrl +import voluptuous as vol + +from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) + +from .const import DOMAIN +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +CONF_BRIDGE = "bridge" +CONF_KEY = "key" +CONF_TEXT = "text" + +POWER_COMMAND_MAP = { + "hibernate": "power_hibernate", + "lock": "power_lock", + "logout": "power_logout", + "restart": "power_restart", + "shutdown": "power_shutdown", + "sleep": "power_sleep", +} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for System Bridge integration.""" + + hass.services.async_register( + DOMAIN, + "get_process_by_id", + handle_get_process_by_id, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_ID): cv.positive_int, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "get_processes_by_name", + handle_get_processes_by_name, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_NAME): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "open_path", + handle_open_path, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_PATH): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "power_command", + handle_power_command, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP), + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "open_url", + handle_open_url, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_URL): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "send_keypress", + handle_send_keypress, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_KEY): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + description_placeholders={ + "syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys" + }, + ) + + hass.services.async_register( + DOMAIN, + "send_text", + handle_send_text, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_TEXT): cv.string, + }, + ), + supports_response=SupportsResponse.ONLY, + ) + + +def _get_coordinator( + hass: HomeAssistant, device_id: str +) -> SystemBridgeDataUpdateCoordinator: + """Return the coordinator for a device id.""" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) + try: + entry_id = next( + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) from e + entry: SystemBridgeConfigEntry = service.async_get_config_entry( + hass, DOMAIN, entry_id + ) + return entry.runtime_data + + +async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: + """Handle the get process by id service call.""" + _LOGGER.debug("Get process by id: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + processes: list[Process] = coordinator.data.processes + + # Find process.id from list, raise ServiceValidationError if not found + try: + return asdict( + next( + process + for process in processes + if process.id == service_call.data[CONF_ID] + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="process_not_found", + translation_placeholders={"id": service_call.data[CONF_ID]}, + ) from e + + +async def handle_get_processes_by_name( + service_call: ServiceCall, +) -> ServiceResponse: + """Handle the get process by name service call.""" + _LOGGER.debug("Get process by name: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + + # Find processes from list + items: list[dict[str, Any]] = [ + asdict(process) + for process in coordinator.data.processes + if process.name is not None + and service_call.data[CONF_NAME].lower() in process.name.lower() + ] + + return { + "count": len(items), + "processes": list(items), + } + + +async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: + """Handle the open path service call.""" + _LOGGER.debug("Open path: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.open_path( + OpenPath(path=service_call.data[CONF_PATH]) + ) + return asdict(response) + + +async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: + """Handle the power command service call.""" + _LOGGER.debug("Power command: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await getattr( + coordinator.websocket_client, + POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], + )() + return asdict(response) + + +async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: + """Handle the open url service call.""" + _LOGGER.debug("Open URL: %s", service_call.data) + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.open_url( + OpenUrl(url=service_call.data[CONF_URL]) + ) + return asdict(response) + + +async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: + """Handle the send_keypress service call.""" + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.keyboard_keypress( + KeyboardKey(key=service_call.data[CONF_KEY]) + ) + return asdict(response) + + +async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: + """Handle the send_text service call.""" + coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE]) + response = await coordinator.websocket_client.keyboard_text( + KeyboardText(text=service_call.data[CONF_TEXT]) + ) + return asdict(response) diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index 14f621d99fc..d36d5f5fc70 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -57,7 +57,72 @@ send_keypress: required: true example: "audio_play" selector: - text: + select: + options: + - backspace + - delete + - enter + - tab + - escape + - up + - down + - right + - left + - home + - end + - pageup + - pagedown + - f1 + - f2 + - f3 + - f4 + - f5 + - f6 + - f7 + - f8 + - f9 + - f10 + - f11 + - f12 + - command + - alt + - control + - shift + - right_shift + - space + - printscreen + - insert + - audio_mute + - audio_vol_down + - audio_vol_up + - audio_play + - audio_stop + - audio_pause + - audio_prev + - audio_next + - audio_rewind + - audio_forward + - audio_repeat + - audio_random + - numpad_0 + - numpad_1 + - numpad_2 + - numpad_3 + - numpad_4 + - numpad_5 + - numpad_6 + - numpad_7 + - numpad_8 + - numpad_9 + - lights_mon_up + - lights_mon_down + - lights_kbd_toggle + - lights_kbd_up + - lights_kbd_down + mode: dropdown + custom_value: true + translation_key: key + sort: false send_text: fields: bridge: @@ -89,3 +154,4 @@ power_command: - "restart" - "shutdown" - "sleep" + translation_key: "power_command" diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 933a89feb87..f6f918611a4 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -54,6 +54,9 @@ "boot_time": { "name": "Boot time" }, + "cpu_power_core": { + "name": "CPU core {cpu_id} power" + }, "cpu_power_package": { "name": "CPU package power" }, @@ -66,9 +69,45 @@ "cpu_voltage": { "name": "CPU voltage" }, + "display_refresh_rate": { + "name": "Display {display_id} refresh rate" + }, + "display_resolution_x": { + "name": "Display {display_id} resolution x" + }, + "display_resolution_y": { + "name": "Display {display_id} resolution y" + }, "displays_connected": { "name": "Displays connected" }, + "gpu_core_clock_speed": { + "name": "{gpu_name} clock speed" + }, + "gpu_fan_speed": { + "name": "{gpu_name} fan speed" + }, + "gpu_memory_clock_speed": { + "name": "{gpu_name} memory clock speed" + }, + "gpu_memory_free": { + "name": "{gpu_name} memory free" + }, + "gpu_memory_used": { + "name": "{gpu_name} memory used" + }, + "gpu_memory_used_percentage": { + "name": "{gpu_name} memory used %" + }, + "gpu_power_usage": { + "name": "{gpu_name} power usage" + }, + "gpu_temperature": { + "name": "{gpu_name} temperature" + }, + "gpu_usage_percentage": { + "name": "{gpu_name} usage %" + }, "kernel": { "name": "Kernel" }, @@ -81,6 +120,9 @@ "memory_used": { "name": "Memory used" }, + "memory_used_percentage": { + "name": "Memory used %" + }, "os": { "name": "Operating system" }, @@ -90,6 +132,12 @@ "processes": { "name": "Processes" }, + "processes_load_cpu": { + "name": "Load CPU {cpu_id}" + }, + "space_used": { + "name": "{partition} space used" + }, "version": { "name": "Version" }, @@ -114,6 +162,9 @@ "process_not_found": { "message": "Could not find process with ID {id}." }, + "send_message_failed": { + "message": "Failed to send message to {title} ({host}) due to a connection error" + }, "timeout": { "message": "A timeout occurred for {title} ({host})" }, @@ -127,6 +178,82 @@ "title": "System Bridge upgrade required" } }, + "selector": { + "key": { + "options": { + "alt": "Alt", + "audio_forward": "Fast-Forward track", + "audio_mute": "Mute", + "audio_next": "Next track", + "audio_pause": "Pause media", + "audio_play": "Play media", + "audio_prev": "Previous track", + "audio_random": "Random track", + "audio_repeat": "Repeat track", + "audio_rewind": "Rewind track", + "audio_stop": "Stop media", + "audio_vol_down": "Volume down", + "audio_vol_up": "Volume up", + "backspace": "Backspace", + "command": "Command", + "control": "Control", + "delete": "Delete", + "down": "[%key:common::entity::button::down::name%]", + "end": "End", + "enter": "Enter", + "escape": "Escape", + "f1": "F1", + "f10": "F10", + "f11": "F11", + "f12": "F12", + "f2": "F2", + "f3": "F3", + "f4": "F4", + "f5": "F5", + "f6": "F6", + "f7": "F7", + "f8": "F8", + "f9": "F9", + "home": "[%key:common::entity::button::home::name%]", + "insert": "Insert", + "left": "[%key:common::entity::button::left::name%]", + "lights_kbd_down": "Keyboard backlight brightness down", + "lights_kbd_toggle": "Toggle keyboard backlight", + "lights_kbd_up": "Keyboard backlight brightness up", + "lights_mon_down": "Display brightness down", + "lights_mon_up": "Display brightness up", + "numpad_0": "NumPad 0", + "numpad_1": "NumPad 1", + "numpad_2": "NumPad 2", + "numpad_3": "NumPad 3", + "numpad_4": "NumPad 4", + "numpad_5": "NumPad 5", + "numpad_6": "NumPad 6", + "numpad_7": "NumPad 7", + "numpad_8": "NumPad 8", + "numpad_9": "NumPad 9", + "pagedown": "Page down", + "pageup": "Page up", + "printscreen": "Print screen", + "right": "[%key:common::entity::button::right::name%]", + "right_shift": "Shift right", + "shift": "Shift", + "space": "Space", + "tab": "Tab", + "up": "[%key:common::entity::button::up::name%]" + } + }, + "power_command": { + "options": { + "hibernate": "Hibernate", + "lock": "Lock", + "logout": "Logout", + "restart": "[%key:common::action::restart%]", + "shutdown": "Shutdown", + "sleep": "Sleep" + } + } + }, "services": { "get_process_by_id": { "description": "Gets a process by the ID.", diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index 12060c28669..8eb0ba20187 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -1,25 +1,21 @@ """Support for System Bridge updates.""" -from __future__ import annotations - from homeassistant.components.update import UpdateEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .entity import SystemBridgeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge update based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ @@ -36,6 +32,7 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity): _attr_has_entity_name = True _attr_title = "System Bridge" + _attr_name = None def __init__( self, @@ -48,7 +45,6 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity): api_port, "update", ) - self._attr_name = coordinator.data.system.hostname @property def installed_version(self) -> str | None: diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 37e9ee3d929..e570e6ddabe 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -1,7 +1,5 @@ """Support for System health .""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses @@ -20,7 +18,6 @@ from homeassistant.helpers import ( integration_platform, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -40,7 +37,6 @@ class SystemHealthProtocol(Protocol): """Register system health callbacks.""" -@bind_hass @callback def async_register_info( hass: HomeAssistant, diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index facfb270627..bcd3117cc34 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -1,7 +1,5 @@ """Support for system log.""" -from __future__ import annotations - from collections import OrderedDict, deque import logging import re diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index 453b1240b1b..e8e80f4ccab 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for System Monitor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import lru_cache diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 66c4913f19e..5d86465bd15 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for System Monitor.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 29467daa28b..1c84c6f089a 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinators for the System monitor integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime import logging diff --git a/homeassistant/components/systemmonitor/diagnostics.py b/homeassistant/components/systemmonitor/diagnostics.py index 7a81f1598ea..bcfc473c6b7 100644 --- a/homeassistant/components/systemmonitor/diagnostics.py +++ b/homeassistant/components/systemmonitor/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Sensibo.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index fe57ada5318..37059307ef2 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the local system.""" -from __future__ import annotations - from collections.abc import Callable import contextlib from dataclasses import dataclass @@ -283,8 +281,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "last_boot": SysMonitorSensorEntityDescription( key="last_boot", - translation_key="last_boot", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, value_fn=lambda entity: entity.coordinator.data.boot_time, add_to_update=lambda entity: ("boot", ""), ), diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 6041dc02c08..97f8f16af84 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -73,9 +73,6 @@ "ipv6_address": { "name": "IPv6 address {ip_address}" }, - "last_boot": { - "name": "Last boot" - }, "load_15m": { "name": "Load (15 min)" }, diff --git a/homeassistant/components/systemnexa2/coordinator.py b/homeassistant/components/systemnexa2/coordinator.py index d52702148f6..b3a05ca26f0 100644 --- a/homeassistant/components/systemnexa2/coordinator.py +++ b/homeassistant/components/systemnexa2/coordinator.py @@ -99,7 +99,9 @@ class SystemNexa2DataUpdateCoordinator(DataUpdateCoordinator[SystemNexa2Data]): except DeviceInitializationError as e: _LOGGER.error( - "Failed to initialize device with IP/Hostname %s, please verify that the device is powered on and reachable on port 3000", + "Failed to initialize device with IP/Hostname" + " %s, please verify that the device is powered" + " on and reachable on port 3000", self.config_entry.data[CONF_HOST], ) raise ConfigEntryNotReady( diff --git a/homeassistant/components/systemnexa2/diagnostics.py b/homeassistant/components/systemnexa2/diagnostics.py index 10c1e0d7836..0fd7cb6268d 100644 --- a/homeassistant/components/systemnexa2/diagnostics.py +++ b/homeassistant/components/systemnexa2/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for System Nexa 2.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 029a3ebd217..fda4c6e07db 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool ) def create_tado_instance() -> tuple[Tado, str]: - """Create a Tado instance, this time with a previously obtained refresh token.""" + """Create a Tado instance with a previously obtained refresh token.""" tado = Tado( saved_refresh_token=entry.data[CONF_REFRESH_TOKEN], user_agent=f"{APPLICATION_NAME}/{HA_VERSION}", @@ -97,7 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool coordinator = TadoDataUpdateCoordinator(hass, entry, tado) await coordinator.async_config_entry_first_refresh() - # Pre-register the bridge device to ensure it exists before other devices reference it + # Pre-register the bridge device to ensure it exists + # before other devices reference it device_registry = dr.async_get(hass) for device in coordinator.data["device"].values(): if device["deviceType"] in TADO_BRIDGE_MODELS: diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index b7b5ac33aef..344e3bf3ca6 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tado sensors for each zone.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index c92f3d4df22..60d61fbc164 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -1,7 +1,5 @@ """Support for Tado thermostats.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -783,11 +781,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): duration=duration, device_type=self.zone_type, mode=self._current_tado_hvac_mode, - fan_speed=fan_speed, # api defaults to not sending fanSpeed if None specified - swing=swing, # api defaults to not sending swing if None specified - fan_level=fan_level, # api defaults to not sending fanLevel if fanSpeend not None - vertical_swing=vertical_swing, # api defaults to not sending verticalSwing if swing not None - horizontal_swing=horizontal_swing, # api defaults to not sending horizontalSwing if swing not None + fan_speed=fan_speed, + swing=swing, + fan_level=fan_level, + vertical_swing=vertical_swing, + horizontal_swing=horizontal_swing, ) def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a581a5b7647..eeb90e7ca56 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tado integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging @@ -42,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): login_task: asyncio.Task | None = None refresh_token: str | None = None tado: Tado | None = None + tado_device_url: str = "" + user_code: str = "" async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -71,8 +71,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Error while initiating Tado") return self.async_abort(reason="cannot_connect") assert self.tado is not None - tado_device_url = self.tado.device_verification_url() - user_code = URL(tado_device_url).query["user_code"] + self.tado_device_url = self.tado.device_verification_url() + self.user_code = URL(self.tado_device_url).query["user_code"] async def _wait_for_login() -> None: """Wait for the user to login.""" @@ -86,7 +86,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): ) if ratelimit.get("remaining") == "0": _LOGGER.error( - "Tado API rate limit reached while waiting for device activation: %s", + "Tado API rate limit reached while" + " waiting for device activation: %s", ex, ) raise TadoRateLimitExceeded from ex @@ -120,8 +121,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", progress_action="wait_for_device", description_placeholders={ - "url": tado_device_url, - "code": user_code, + "url": self.tado_device_url, + "code": self.user_code, }, progress_task=self.login_task, ) diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 6c9c5fa3c02..6ee33f39cb3 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -1,12 +1,12 @@ """Coordinator for the Tado integration.""" -from __future__ import annotations - -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta import logging from typing import Any +from zoneinfo import ZoneInfo from PyTado.interface import Tado +from PyTado.zone import TadoZone from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME @@ -33,7 +33,7 @@ SCAN_INTERVAL = timedelta(minutes=5) type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator] -class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): +class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage API calls from and to Tado via PyTado.""" tado: Tado @@ -67,28 +67,40 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.home_name: str self.zones: list[dict[Any, Any]] = [] self.devices: list[dict[Any, Any]] = [] - self.data: dict[str, dict] = { + self.data: dict[str, Any] = { "device": {}, "weather": {}, "geofence": {}, "zone": {}, } + self._current_interval: float = 0 + self._next_update: datetime | None = None + self._time_until_reset: float = 0 + @property def fallback(self) -> str: """Return fallback flag to Smart Schedule.""" return self._fallback - async def _async_update_data(self) -> dict[str, dict]: + async def _async_update_data(self) -> dict[str, Any]: """Fetch the (initial) latest data from Tado.""" - try: - _LOGGER.debug("Preloading home data") - tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me) - _LOGGER.debug("Preloading zones and devices") - self.zones = await self.hass.async_add_executor_job(self._tado.get_zones) - self.devices = await self.hass.async_add_executor_job( - self._tado.get_devices + + def _load_tado_data() -> tuple[dict, list, list]: + """Load Tado data in one call.""" + _LOGGER.debug("Preloading Tado data") + return ( + self._tado.get_me(), + self._tado.get_zones(), + self._tado.get_devices(), ) + + try: + ( + tado_home_call, + self.zones, + self.devices, + ) = await self.hass.async_add_executor_job(_load_tado_data) except RequestException as err: _LOGGER.debug("Checking rate limit") ratelimit = self.get_rate_limit() @@ -121,8 +133,77 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token}, ) + # Calculate the most recent update interval + self._calculate_update_interval() + return self.data + @property + def _is_any_zone_active(self) -> bool: + """Check if any zone is currently active (heating or AC running).""" + return any( + ( + zone_data.heating_power_percentage is not None + and zone_data.heating_power_percentage > 0 + ) + or zone_data.ac_power == "ON" + for zone_data in self.data.get("zone", {}).values() + ) + + def _calculate_update_interval(self) -> None: + """Calculate an update interval based on remaining calls and estimates.""" + + # Tado resets somewhere between 12:00 and 13:00, Berlin time + # So let's pretend we're in Berlin... + reset_time = datetime.now(ZoneInfo("Europe/Berlin")) + + today_reset = datetime.combine( + reset_time.date(), + time(hour=12, minute=0), + tzinfo=ZoneInfo("Europe/Berlin"), + ) + + next_reset = today_reset + if reset_time >= today_reset: + next_reset = today_reset + timedelta(days=1) + + self._time_until_reset = (next_reset - reset_time).total_seconds() + + # When any zone is actively heating, we use a shorter minimum + # To prevent overshooting in temperature, + # check if there's heating/cooling activity + # Accept five minutes to "overshoot", else reset back to 30 minutes + min_interval = 300 if self._is_any_zone_active else 1800 + + remaining_calls = int(self.data.get("rate_limit", {}).get("remaining", 0)) + if remaining_calls is None or remaining_calls <= 0: + # If rate limit info is unavailable, fall back to the static interval. + self._current_interval = SCAN_INTERVAL.total_seconds() + self.update_interval = SCAN_INTERVAL + self._next_update = reset_time + timedelta(seconds=self._current_interval) + _LOGGER.debug( + "Rate limit info unavailable;" + " using default update interval: %s seconds", + self._current_interval, + ) + return + + # Each refresh cycle costs 9 + len(zones) calls + # Also take 10% of the remaining calls as buffer + self._current_interval = max( + min_interval, + (self._time_until_reset * (9 + len(self.zones))) / (remaining_calls * 0.9), + ) + + self._next_update = reset_time + timedelta(seconds=self._current_interval) + self.update_interval = timedelta(seconds=self._current_interval) + + _LOGGER.debug( + "Calculated new update interval: %s seconds, for remaining calls: %s", + self._current_interval, + remaining_calls, + ) + async def _async_update_devices(self) -> dict[str, dict]: """Update the device data from Tado.""" @@ -168,7 +249,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return mapped_devices - async def _async_update_zones(self) -> dict[int, dict]: + async def _async_update_zones(self) -> dict[int, TadoZone]: """Update the zone data from Tado.""" try: @@ -180,16 +261,31 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): _LOGGER.error("Error updating Tado zones: %s", err) raise UpdateFailed(f"Error updating Tado zones: {err}") from err - mapped_zones: dict[int, dict] = {} - for zone in zone_states: - mapped_zones[int(zone)] = await self._update_zone(int(zone)) + mapped_zones: dict[int, TadoZone] = {} + for zone_id_str, raw_state in zone_states.items(): + zone_id = int(zone_id_str) + mapped_zones[zone_id] = await self._build_zone(zone_id, raw_state) return mapped_zones - async def _update_zone(self, zone_id: int) -> dict[str, str]: - """Update the internal data of a zone.""" - + async def _build_zone(self, zone_id: int, raw_state: dict[str, Any]) -> TadoZone: + """Fetch defaultOverlay for a zone and construct a TadoZone.""" _LOGGER.debug("Updating zone %s", zone_id) + try: + overlay_default = await self.hass.async_add_executor_job( + self._tado.get_zone_overlay_default, zone_id + ) + except RequestException as err: + _LOGGER.error("Error updating Tado zone %s: %s", zone_id, err) + raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err + + data = TadoZone.from_data(zone_id, {**raw_state, **overlay_default}) + _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) + return data + + async def _update_zone(self, zone_id: int) -> TadoZone: + """Fetch the latest state for a single zone (used after overlay changes).""" + _LOGGER.debug("Refreshing zone %s after overlay change", zone_id) try: data = await self.hass.async_add_executor_job( self._tado.get_zone_state, zone_id @@ -204,9 +300,12 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" + def _get_home_data() -> tuple[dict, dict]: + """Get the weather and geofence data for the home.""" + return self._tado.get_weather(), self._tado.get_home_state() + try: - weather = await self.hass.async_add_executor_job(self._tado.get_weather) - geofence = await self.hass.async_add_executor_job(self._tado.get_home_state) + weather, geofence = await self.hass.async_add_executor_job(_get_home_data) except RequestException as err: _LOGGER.error("Error updating Tado home: %s", err) raise UpdateFailed(f"Error updating Tado home: {err}") from err @@ -280,7 +379,10 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Set a zone overlay.""" _LOGGER.debug( - "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s", + "Set overlay for zone %s: overlay_mode=%s," + " temp=%s, duration=%s, type=%s, mode=%s," + " fan_speed=%s, swing=%s, fan_level=%s," + " vertical_swing=%s, horizontal_swing=%s", zone_id, overlay_mode, temperature, diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py index 42e3138cbc3..2128072cbc3 100644 --- a/homeassistant/components/tado/diagnostics.py +++ b/homeassistant/components/tado/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Tado.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 5c515e00cf0..838222dc768 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -38,7 +38,7 @@ def decide_duration( zone_id: int, overlay_mode: str | None = None, ) -> None | int: - """Return correct duration based on the selected overlay mode/duration and tado config.""" + """Return correct duration based on overlay mode and tado config.""" # If we ended up with a timer but no duration, set a default duration # If we ended up with a timer but no duration, set a default duration diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index bce88d52de0..90f906863c9 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,7 +1,5 @@ """Support for Tado sensors for each zone.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -58,7 +56,7 @@ def get_tado_mode(data: dict[str, str]) -> str | None: def get_automatic_geofencing(data: dict[str, str]) -> bool: - """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + """Return whether Automatic Geofencing is enabled.""" if "presenceLocked" in data: if data["presenceLocked"]: return False diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 8d33705cb67..ee0bf7964c6 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -1,7 +1,5 @@ """The Tag integration.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, final diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py index 4f5f637982b..6827448f4db 100644 --- a/homeassistant/components/tag/trigger.py +++ b/homeassistant/components/tag/trigger.py @@ -1,7 +1,5 @@ """Support for tag triggers.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import CONF_PLATFORM diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py index 549bf07e181..e1ec34f0049 100644 --- a/homeassistant/components/tailscale/__init__.py +++ b/homeassistant/components/tailscale/__init__.py @@ -1,31 +1,24 @@ """The Tailscale integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import TailscaleDataUpdateCoordinator +from .coordinator import TailscaleConfigEntry, TailscaleDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TailscaleConfigEntry) -> bool: """Set up Tailscale from a config entry.""" coordinator = TailscaleDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TailscaleConfigEntry) -> bool: """Unload Tailscale config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index c17b6c0d984..d8a77c564a1 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tailscale binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -12,14 +10,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import TailscaleConfigEntry from .entity import TailscaleEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -97,11 +96,11 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailscaleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale binary sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TailscaleBinarySensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py index ab57e9eadc6..76319c0b368 100644 --- a/homeassistant/components/tailscale/config_flow.py +++ b/homeassistant/components/tailscale/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Tailscale integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/tailscale/const.py b/homeassistant/components/tailscale/const.py index 8af45906a61..665420dc1f3 100644 --- a/homeassistant/components/tailscale/const.py +++ b/homeassistant/components/tailscale/const.py @@ -1,7 +1,5 @@ """Constants for the Tailscale integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py index d1a0b540f47..5506f2160b5 100644 --- a/homeassistant/components/tailscale/coordinator.py +++ b/homeassistant/components/tailscale/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Tailscale integration.""" -from __future__ import annotations - from tailscale import Device, Tailscale, TailscaleAuthenticationError from homeassistant.config_entries import ConfigEntry @@ -14,13 +12,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_TAILNET, DOMAIN, LOGGER, SCAN_INTERVAL +type TailscaleConfigEntry = ConfigEntry[TailscaleDataUpdateCoordinator] + class TailscaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """The Tailscale Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: TailscaleConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: TailscaleConfigEntry) -> None: """Initialize the Tailscale coordinator.""" session = async_get_clientsession(hass) self.tailscale = Tailscale( diff --git a/homeassistant/components/tailscale/diagnostics.py b/homeassistant/components/tailscale/diagnostics.py index f9e63491660..788b4efdcb5 100644 --- a/homeassistant/components/tailscale/diagnostics.py +++ b/homeassistant/components/tailscale/diagnostics.py @@ -1,17 +1,14 @@ """Diagnostics support for Tailscale.""" -from __future__ import annotations - import json from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from .const import CONF_TAILNET, DOMAIN -from .coordinator import TailscaleDataUpdateCoordinator +from .const import CONF_TAILNET +from .coordinator import TailscaleConfigEntry TO_REDACT = { CONF_API_KEY, @@ -22,16 +19,19 @@ TO_REDACT = { "hostname", "machine_key", "name", + "node_id", "node_key", + "tailnet_lock_key", "user", } async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TailscaleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TailscaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Round-trip via JSON to trigger serialization - devices = [json.loads(device.to_json()) for device in coordinator.data.values()] + devices = [ + json.loads(device.to_json()) for device in entry.runtime_data.data.values() + ] return async_redact_data({"devices": devices}, TO_REDACT) diff --git a/homeassistant/components/tailscale/entity.py b/homeassistant/components/tailscale/entity.py index a14b873a00f..5f970de5f64 100644 --- a/homeassistant/components/tailscale/entity.py +++ b/homeassistant/components/tailscale/entity.py @@ -1,20 +1,16 @@ """The Tailscale integration.""" -from __future__ import annotations - from tailscale import Device as TailscaleDevice from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import TailscaleDataUpdateCoordinator -class TailscaleEntity(CoordinatorEntity): +class TailscaleEntity(CoordinatorEntity[TailscaleDataUpdateCoordinator]): """Defines a Tailscale base entity.""" _attr_has_entity_name = True @@ -22,7 +18,7 @@ class TailscaleEntity(CoordinatorEntity): def __init__( self, *, - coordinator: DataUpdateCoordinator, + coordinator: TailscaleDataUpdateCoordinator, device: TailscaleDevice, description: EntityDescription, ) -> None: diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 8c005888387..8a8a7f3e851 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tailscale", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["tailscale==0.6.2"] + "requirements": ["tailscale==0.7.0"] } diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index cf944aa73ef..8835eceaeef 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -1,7 +1,5 @@ """Support for Tailscale sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -13,14 +11,15 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import TailscaleConfigEntry from .entity import TailscaleEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TailscaleSensorEntityDescription(SensorEntityDescription): @@ -54,11 +53,11 @@ SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailscaleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TailscaleSensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index b191d78f2a6..9ca6dd4f64a 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -1,7 +1,5 @@ """Integration for Tailwind devices.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 4d927b0769e..e40a9b61803 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor entity platform for Tailwind.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -19,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index 380eb7ccd7e..754fd1e857c 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -1,7 +1,5 @@ """Button entity platform for Tailwind.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -22,6 +20,8 @@ from .const import DOMAIN from .coordinator import TailwindConfigEntry from .entity import TailwindEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class TailwindButtonEntityDescription(ButtonEntityDescription): @@ -66,7 +66,6 @@ class TailwindButtonEntity(TailwindEntity, ButtonEntity): await self.entity_description.press_fn(self.coordinator.tailwind) except TailwindError as exc: raise HomeAssistantError( - str(exc), translation_domain=DOMAIN, translation_key="communication_error", ) from exc diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index daf0fbd32b7..ef186af678b 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Tailwind integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -15,7 +13,12 @@ from gotailwind import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -143,6 +146,46 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing Tailwind device.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=reconfigure_entry.data[CONF_HOST], + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -219,6 +262,17 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + if self.source == SOURCE_RECONFIGURE: + await self.async_set_unique_id(format_mac(status.mac_address)) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: host, + CONF_TOKEN: token, + }, + ) + await self.async_set_unique_id( format_mac(status.mac_address), raise_on_progress=False ) diff --git a/homeassistant/components/tailwind/const.py b/homeassistant/components/tailwind/const.py index f4320d04374..d5fcf467726 100644 --- a/homeassistant/components/tailwind/const.py +++ b/homeassistant/components/tailwind/const.py @@ -1,7 +1,5 @@ """Constants for the Tailwind integration.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 770751ccc3b..10daaec8ac9 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from gotailwind import ( Tailwind, TailwindAuthenticationError, + TailwindConnectionError, TailwindDeviceStatus, TailwindError, ) @@ -45,5 +46,13 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]) return await self.tailwind.status() except TailwindAuthenticationError as err: raise ConfigEntryAuthFailed from err + except TailwindConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err except TailwindError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 84f38c7d579..25089ea77c8 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -1,7 +1,5 @@ """Cover entity platform for Tailwind.""" -from __future__ import annotations - from typing import Any from gotailwind import ( @@ -26,6 +24,8 @@ from .const import DOMAIN, LOGGER from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py index b7a51b56775..3683cb63ef9 100644 --- a/homeassistant/components/tailwind/diagnostics.py +++ b/homeassistant/components/tailwind/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Tailwind.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index dafb46e6f63..d3c148392d9 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -1,7 +1,5 @@ """Base entity for the Tailwind integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 136492d884f..bf90aa391dc 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,8 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.3.0"], + "quality_scale": "platinum", + "requirements": ["gotailwind==0.4.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index ca6b610c351..866a85bc640 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -1,7 +1,5 @@ """Number entity platform for Tailwind.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -18,6 +16,8 @@ from .const import DOMAIN from .coordinator import TailwindConfigEntry from .entity import TailwindEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class TailwindNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml index 90c5d0d5837..2777d1a15ca 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -9,10 +9,10 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: todo - docs-high-level-description: todo + docs-actions: done + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -27,12 +27,12 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold @@ -40,13 +40,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -55,12 +55,9 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: exempt - comment: | - The coordinator needs translation when the update failed. + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 8cb059a74d0..ca7aac47564 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The entered information is for a different Tailwind device.", "no_device_id": "The discovered Tailwind device did not provide a device ID.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." }, @@ -23,6 +25,17 @@ }, "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below." }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "host": "[%key:component::tailwind::config::step::user::data_description::host%]", + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + }, + "description": "Reconfigure your Tailwind garage door opener.\n\nThis allows you to change the IP address and local control key of your Tailwind device." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -70,6 +83,9 @@ }, "door_locked_out": { "message": "The door is locked out and cannot be operated." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Tailwind device." } } } diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 8b9a5e1a90f..3ecbe7ffc43 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -1,21 +1,18 @@ """The Tami4Edge integration.""" -from __future__ import annotations - from Tami4EdgeAPI import Tami4EdgeAPI, exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN -from .coordinator import Tami4EdgeCoordinator +from .const import CONF_REFRESH_TOKEN +from .coordinator import Tami4ConfigEntry, Tami4EdgeCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Tami4ConfigEntry) -> bool: """Set up tami4 from a config entry.""" refresh_token = entry.data.get(CONF_REFRESH_TOKEN) @@ -29,19 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = Tami4EdgeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - API: api, - COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Tami4ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py index a1b8db79674..bdd3e64ea47 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -8,12 +8,11 @@ from Tami4EdgeAPI import Tami4EdgeAPI from Tami4EdgeAPI.drink import Drink from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API, DOMAIN +from .coordinator import Tami4ConfigEntry from .entity import Tami4EdgeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -42,12 +41,12 @@ BOIL_WATER_BUTTON = Tami4EdgeButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: Tami4ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" - api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] + api = entry.runtime_data.api buttons: list[Tami4EdgeBaseEntity] = [Tami4EdgeButton(api, BOIL_WATER_BUTTON)] device = await hass.async_add_executor_job(api.get_device) diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index a58c801c403..126332e648c 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -1,7 +1,5 @@ """Config flow for edge integration.""" -from __future__ import annotations - import logging import re from typing import Any @@ -71,6 +69,7 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN): refresh_token = await self.hass.async_add_executor_job( Tami4EdgeAPI.submit_otp, self.phone, otp ) + # pylint: disable-next=home-assistant-sequential-executor-jobs api = await self.hass.async_add_executor_job( Tami4EdgeAPI, refresh_token ) diff --git a/homeassistant/components/tami4/const.py b/homeassistant/components/tami4/const.py index be737b5c974..9717181eb4a 100644 --- a/homeassistant/components/tami4/const.py +++ b/homeassistant/components/tami4/const.py @@ -3,5 +3,3 @@ DOMAIN = "tami4" CONF_PHONE = "phone" CONF_REFRESH_TOKEN = "refresh_token" -API = "api" -COORDINATOR = "coordinator" diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py index f65c819b3d8..e872d61dd37 100644 --- a/homeassistant/components/tami4/coordinator.py +++ b/homeassistant/components/tami4/coordinator.py @@ -13,6 +13,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type Tami4ConfigEntry = ConfigEntry[Tami4EdgeCoordinator] + @dataclass class FlattenedWaterQuality: @@ -37,10 +39,10 @@ class FlattenedWaterQuality: class Tami4EdgeCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): """Tami4Edge water quality coordinator.""" - config_entry: ConfigEntry + config_entry: Tami4ConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: Tami4EdgeAPI + self, hass: HomeAssistant, config_entry: Tami4ConfigEntry, api: Tami4EdgeAPI ) -> None: """Initialize the water quality coordinator.""" super().__init__( @@ -50,12 +52,12 @@ class Tami4EdgeCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): name="Tami4Edge water quality coordinator", update_interval=timedelta(minutes=60), ) - self._api = api + self.api = api async def _async_update_data(self) -> FlattenedWaterQuality: """Fetch data from the API endpoint.""" try: - device = await self.hass.async_add_executor_job(self._api.get_device) + device = await self.hass.async_add_executor_job(self.api.get_device) return FlattenedWaterQuality(device.water_quality) except exceptions.APIRequestFailedException as ex: diff --git a/homeassistant/components/tami4/entity.py b/homeassistant/components/tami4/entity.py index b99ca21663d..af5ffb37711 100644 --- a/homeassistant/components/tami4/entity.py +++ b/homeassistant/components/tami4/entity.py @@ -1,7 +1,5 @@ """Base entity for Tami4Edge.""" -from __future__ import annotations - from Tami4EdgeAPI import Tami4EdgeAPI from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py index 2bfd3079c19..c87694e3187 100644 --- a/homeassistant/components/tami4/sensor.py +++ b/homeassistant/components/tami4/sensor.py @@ -2,22 +2,18 @@ import logging -from Tami4EdgeAPI import Tami4EdgeAPI - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import API, COORDINATOR, DOMAIN -from .coordinator import Tami4EdgeCoordinator +from .coordinator import Tami4ConfigEntry, Tami4EdgeCoordinator from .entity import Tami4EdgeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -53,18 +49,15 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: Tami4ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" - data = hass.data[DOMAIN][entry.entry_id] - api: Tami4EdgeAPI = data[API] - coordinator: Tami4EdgeCoordinator = data[COORDINATOR] + coordinator = entry.runtime_data async_add_entities( Tami4EdgeSensorEntity( coordinator=coordinator, - api=api, entity_description=entity_description, ) for entity_description in ENTITY_DESCRIPTIONS @@ -81,11 +74,10 @@ class Tami4EdgeSensorEntity( def __init__( self, coordinator: Tami4EdgeCoordinator, - api: Tami4EdgeAPI, entity_description: SensorEntityDescription, ) -> None: """Initialize the Tami4Edge sensor entity.""" - Tami4EdgeBaseEntity.__init__(self, api, entity_description) + Tami4EdgeBaseEntity.__init__(self, coordinator.api, entity_description) CoordinatorEntity.__init__(self, coordinator) self._update_attr() diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 2ccfb48b32d..988d8fd4c36 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -1,7 +1,5 @@ """Support for the Tank Utility propane monitor.""" -from __future__ import annotations - import datetime import logging diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 2a85b1f31e1..46387ef34f6 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -1,7 +1,5 @@ """Ask tankerkoenig.de for petrol price information.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index d571dfe99d2..c52b4c9e8bd 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -1,7 +1,5 @@ """Tankerkoenig binary sensor integration.""" -from __future__ import annotations - import logging from aiotankerkoenig import PriceInfo, Station, Status diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 6207c7261b0..b30c697f3a2 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tankerkoenig.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -169,6 +167,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_NAME, default=user_input.get(CONF_NAME, "") ): cv.string, diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index dbd826b9359..43d43c29846 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -1,7 +1,5 @@ """The Tankerkoenig update coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from math import ceil @@ -72,14 +70,20 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf station_id, err, ) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except TankerkoenigConnectionError as err: _LOGGER.debug( "connection error occur during setup of station %s %s", station_id, err, ) - raise ConfigEntryNotReady(err) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err except TankerkoenigError as err: _LOGGER.error("Error when adding station %s %s", station_id, err) continue @@ -119,7 +123,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf station_ids = list(self.stations) prices = {} - # The API seems to only return at most 10 results, so split the list in chunks of 10 + # The API seems to only return at most 10 results, + # so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): stations = station_ids[index * 10 : (index + 1) * 10] diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py index 874a73712eb..dceeeba20ab 100644 --- a/homeassistant/components/tankerkoenig/diagnostics.py +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Tankerkoenig.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 1b4f146f35b..db2cb4b58cf 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], "quality_scale": "platinum", - "requirements": ["aiotankerkoenig==0.5.1"] + "requirements": ["aiotankerkoenig==0.5.3"] } diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 9964a300d6f..8de3837c7e6 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -1,7 +1,5 @@ """Tankerkoenig sensor integration.""" -from __future__ import annotations - import logging from aiotankerkoenig import GasType, Station diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index ef120597bf3..792d8e582de 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -164,6 +164,9 @@ } }, "exceptions": { + "connection_error": { + "message": "Failed to connect to the Tankerkoenig API. Please check your network connection." + }, "invalid_api_key": { "message": "The provided API key is invalid. Please check your API key." }, diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index b754b0f2b87..14c624bcadf 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Taps Affs.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index f1acfa644bf..f44d996c282 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -1,7 +1,5 @@ """The Tasmota integration.""" -from __future__ import annotations - import logging from hatasmota.const import ( @@ -44,8 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _publish( topic: str, payload: mqtt.PublishPayloadType, - qos: int | None, - retain: bool | None, + qos: int, + retain: bool, ) -> None: await mqtt.async_publish(hass, topic, payload, qos, retain) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 3b2e640b807..649fcddfe45 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tasmota binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime from typing import Any diff --git a/homeassistant/components/tasmota/camera.py b/homeassistant/components/tasmota/camera.py index beacb23504b..76b2ac26774 100644 --- a/homeassistant/components/tasmota/camera.py +++ b/homeassistant/components/tasmota/camera.py @@ -1,7 +1,5 @@ """Support for Tasmota Camera.""" -from __future__ import annotations - import asyncio import logging from typing import Any diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 5b1adc839ac..10efecdd5d3 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tasmota.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -52,9 +50,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - if self.show_advanced_options: - return await self.async_step_config() - return await self.async_step_confirm() + return await self.async_step_config() async def async_step_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 1d7aa8316b6..96038de645f 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -1,7 +1,5 @@ """Support for Tasmota covers.""" -from __future__ import annotations - from typing import Any from hatasmota import const as tasmota_const, shutter as tasmota_shutter diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py index af14efbd65c..347c4ac64c2 100644 --- a/homeassistant/components/tasmota/device_automation.py +++ b/homeassistant/components/tasmota/device_automation.py @@ -1,7 +1,5 @@ """Provides device automations for Tasmota.""" -from __future__ import annotations - from hatasmota.const import AUTOMATION_TYPE_TRIGGER from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index a357dfff1c0..4f132fb0b8a 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Tasmota.""" -from __future__ import annotations - from collections.abc import Callable import logging diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index 44a1ac9e38b..de64f6725d8 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -1,7 +1,5 @@ """Support for Tasmota device discovery.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable import logging from typing import TypedDict, cast diff --git a/homeassistant/components/tasmota/entity.py b/homeassistant/components/tasmota/entity.py index 8c0da1bcc2a..964c7c116ef 100644 --- a/homeassistant/components/tasmota/entity.py +++ b/homeassistant/components/tasmota/entity.py @@ -1,7 +1,5 @@ """Tasmota entity mixins.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index c89b36577be..710af67679a 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -1,7 +1,5 @@ """Support for Tasmota fans.""" -from __future__ import annotations - from typing import Any from hatasmota import const as tasmota_const, fan as tasmota_fan diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index 546612f6fd6..bc170b96032 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -1,7 +1,5 @@ """Support for Tasmota lights.""" -from __future__ import annotations - from typing import Any from hatasmota import light as tasmota_light diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index ec20e1c0348..52047bfc4b0 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -1,7 +1,5 @@ """Support for Tasmota sensors.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json index c791aaa0e57..033a161a1b5 100644 --- a/homeassistant/components/tasmota/strings.json +++ b/homeassistant/components/tasmota/strings.json @@ -10,6 +10,9 @@ "config": { "data": { "discovery_prefix": "Discovery topic prefix" + }, + "data_description": { + "discovery_prefix": "Only change this if you use a custom Tasmota binary that does not use the default `tasmota/discovery` discovery topic prefix" } }, "confirm": { diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index 41089016fac..fe5a1b3ae87 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -1,7 +1,5 @@ """The Tautulli integration.""" -from __future__ import annotations - from pytautulli import PyTautulli, PyTautulliHostConfiguration from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform diff --git a/homeassistant/components/tautulli/config_flow.py b/homeassistant/components/tautulli/config_flow.py index 44f57de2e3f..80dc996dfb4 100644 --- a/homeassistant/components/tautulli/config_flow.py +++ b/homeassistant/components/tautulli/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tautulli.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py index 5d0f26b83b6..935b80772a0 100644 --- a/homeassistant/components/tautulli/coordinator.py +++ b/homeassistant/components/tautulli/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Tautulli integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta diff --git a/homeassistant/components/tautulli/entity.py b/homeassistant/components/tautulli/entity.py index 692c2141954..b6a86f0c18c 100644 --- a/homeassistant/components/tautulli/entity.py +++ b/homeassistant/components/tautulli/entity.py @@ -1,7 +1,5 @@ """The Tautulli integration.""" -from __future__ import annotations - from pytautulli import PyTautulliApiUser from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index c8d35623c21..140b4cb7032 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,7 +1,5 @@ """A platform which allows you to get information from Tautulli.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/tcp/binary_sensor.py b/homeassistant/components/tcp/binary_sensor.py index 13fd0787b5d..f1f9e22e7b8 100644 --- a/homeassistant/components/tcp/binary_sensor.py +++ b/homeassistant/components/tcp/binary_sensor.py @@ -1,7 +1,5 @@ """Provides a binary sensor which gets its values from a TCP socket.""" -from __future__ import annotations - from typing import Final from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index 1263effa96b..accb7ac6206 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -1,7 +1,5 @@ """Common code for TCP component.""" -from __future__ import annotations - from typing import Any, Final import voluptuous as vol diff --git a/homeassistant/components/tcp/const.py b/homeassistant/components/tcp/const.py index 98cdfa002fd..e5324644317 100644 --- a/homeassistant/components/tcp/const.py +++ b/homeassistant/components/tcp/const.py @@ -1,7 +1,5 @@ """Constants for TCP platform.""" -from __future__ import annotations - from typing import Final CONF_BUFFER_SIZE: Final = "buffer_size" diff --git a/homeassistant/components/tcp/entity.py b/homeassistant/components/tcp/entity.py index eaf5cb6963e..67e9c4feeb8 100644 --- a/homeassistant/components/tcp/entity.py +++ b/homeassistant/components/tcp/entity.py @@ -1,7 +1,5 @@ """Common code for TCP component.""" -from __future__ import annotations - import logging import select import socket diff --git a/homeassistant/components/tcp/model.py b/homeassistant/components/tcp/model.py index 8cbe10e0b0c..9b01f222803 100644 --- a/homeassistant/components/tcp/model.py +++ b/homeassistant/components/tcp/model.py @@ -1,7 +1,5 @@ """Models for TCP platform.""" -from __future__ import annotations - from typing import TypedDict from homeassistant.helpers.template import Template diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py index 1d53b21bc2e..a5e2e15e0e6 100644 --- a/homeassistant/components/tcp/sensor.py +++ b/homeassistant/components/tcp/sensor.py @@ -1,7 +1,5 @@ """Support for TCP socket based sensors.""" -from __future__ import annotations - from typing import Final from homeassistant.components.sensor import ( diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index df4fc7713aa..42d8461c367 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -1,7 +1,5 @@ """The TechnoVE integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index ac52a19884e..5d3db313fa1 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -1,7 +1,5 @@ """Support for TechnoVE binary sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 7ad9829b631..0949b859884 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -6,7 +6,11 @@ from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionErr import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -32,7 +36,23 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): except TechnoVEConnectionError: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(station.info.mac_address) + await self.async_set_unique_id( + station.info.mac_address, raise_on_progress=False + ) + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + assert entry.unique_id is not None + self._abort_if_unique_id_mismatch( + reason="unique_id_mismatch", + description_placeholders={ + "expected_mac": entry.unique_id.upper(), + "actual_mac": station.info.mac_address.upper(), + }, + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) self._abort_if_unique_id_configured( updates={CONF_HOST: user_input[CONF_HOST]} ) @@ -43,12 +63,25 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + data_schema = vol.Schema({vol.Required(CONF_HOST): str}) + if self.source == SOURCE_RECONFIGURE: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self._get_reconfigure_entry().data, + ) + return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=data_schema, errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the TechnoVE station.""" + return await self.async_step_user(user_input) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py index 53108463301..15add5308d2 100644 --- a/homeassistant/components/technove/coordinator.py +++ b/homeassistant/components/technove/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for TechnoVE.""" -from __future__ import annotations - from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/technove/diagnostics.py b/homeassistant/components/technove/diagnostics.py index 7ac0f6f44fd..039143749d7 100644 --- a/homeassistant/components/technove/diagnostics.py +++ b/homeassistant/components/technove/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for TechnoVE.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/technove/helpers.py b/homeassistant/components/technove/helpers.py index a4aebf5f1fe..1dd6e743c63 100644 --- a/homeassistant/components/technove/helpers.py +++ b/homeassistant/components/technove/helpers.py @@ -1,7 +1,5 @@ """Helpers for TechnoVE.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 746c2280aaa..ea77023d0cc 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==2.0.0"], + "requirements": ["python-technove==2.1.1"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py index 11d8f281276..92ad518f711 100644 --- a/homeassistant/components/technove/number.py +++ b/homeassistant/components/technove/number.py @@ -1,7 +1,5 @@ """Support for TechnoVE number entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index 398c1911cd4..52e79e29531 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 98bb4b9562b..c2e27854ccc 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" @@ -60,11 +62,15 @@ "status": { "name": "Status", "state": { + "evse_fault": "EVSE fault", + "ground_fault": "Ground fault", "high_tariff_period": "High tariff period", "out_of_activation_period": "Out of activation period", + "pilot_fault": "Pilot fault", "plugged_charging": "Plugged, charging", "plugged_waiting": "Plugged, waiting", - "unplugged": "Unplugged" + "unplugged": "Unplugged", + "ventilation_required": "Ventilation required" } }, "voltage_in": { diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index 19688075b35..7e50960e6db 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -1,7 +1,5 @@ """Support for TechnoVE switches.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index 7be221b3b85..919f2377c81 100644 --- a/homeassistant/components/ted5000/manifest.json +++ b/homeassistant/components/ted5000/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ted5000", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["xmltodict==1.0.2"] + "requirements": ["xmltodict==1.0.4"] } diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 26f469349b4..ea660c689a8 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -1,7 +1,5 @@ """Support gathering ted5000 information.""" -from __future__ import annotations - from contextlib import suppress from datetime import timedelta import logging diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index c7724b09bc3..d630ae847bf 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -31,6 +31,7 @@ class TedeeBinarySensorEntityDescription( is_on_fn: Callable[[TedeeLock], bool | None] supported_fn: Callable[[TedeeLock], bool] = lambda _: True available_fn: Callable[[TedeeLock], bool] = lambda _: True + always_available: bool = False ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( @@ -75,6 +76,13 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( not in [TedeeDoorState.UNCALIBRATED, TedeeDoorState.DISCONNECTED] ), ), + TedeeBinarySensorEntityDescription( + key="connectivity", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda lock: lock.is_connected, + entity_category=EntityCategory.DIAGNOSTIC, + always_available=True, + ), ) @@ -111,4 +119,6 @@ class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): @property def available(self) -> bool: """Return true if the binary sensor is available.""" + if self.entity_description.always_available: + return True return self.entity_description.available_fn(self._lock) and super().available diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 693f6234873..4c41bdc8112 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Tedee locks.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from datetime import timedelta import logging @@ -98,9 +96,10 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): try: await update_fn() except TedeeLocalAuthException as ex: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryAuthFailed( translation_domain=DOMAIN, - translation_key="authentification_failed", + translation_key="authentication_failed", ) from ex except TedeeDataUpdateException as ex: diff --git a/homeassistant/components/tedee/diagnostics.py b/homeassistant/components/tedee/diagnostics.py index ccf71eda6b8..83ba71e5d4b 100644 --- a/homeassistant/components/tedee/diagnostics.py +++ b/homeassistant/components/tedee/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for tedee.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 4c522d1feb1..552dbb17567 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -36,6 +36,11 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): via_device=(DOMAIN, coordinator.bridge.serial), ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 6d5131d07e9..b895b9b0ede 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -89,11 +89,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self._lock.is_connected - and self._lock.state != TedeeLockState.UNCALIBRATED - ) + return super().available and self._lock.state != TedeeLockState.UNCALIBRATED async def async_unlock(self, **kwargs: Any) -> None: """Unlock the door.""" diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index e649514d418..940f36cc4de 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -1,7 +1,5 @@ """Telegram platform for notify component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e757830811f..2fdd009bcd2 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -1,7 +1,5 @@ """Support to send and receive Telegram messages.""" -from __future__ import annotations - import logging from typing import Protocol, cast @@ -62,6 +60,7 @@ from .const import ( ATTR_DIRECTORY_PATH, ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, + ATTR_DRAFT_ID, ATTR_FILE, ATTR_FILE_ID, ATTR_FILE_NAME, @@ -129,6 +128,7 @@ from .const import ( SERVICE_SEND_LOCATION, SERVICE_SEND_MEDIA_GROUP, SERVICE_SEND_MESSAGE, + SERVICE_SEND_MESSAGE_DRAFT, SERVICE_SEND_PHOTO, SERVICE_SEND_POLL, SERVICE_SEND_STICKER, @@ -176,6 +176,19 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.All( ), ) +SERVICE_SCHEMA_SEND_MESSAGE_DRAFT = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + vol.Required(ATTR_DRAFT_ID): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + } +) + SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All( cv.deprecated(ATTR_TIMEOUT), vol.Schema( @@ -424,6 +437,7 @@ SERVICE_SCHEMA_DOWNLOAD_FILE = vol.Schema( SERVICE_MAP: dict[str, VolSchemaType] = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, + SERVICE_SEND_MESSAGE_DRAFT: SERVICE_SCHEMA_SEND_MESSAGE_DRAFT, SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_MEDIA_GROUP: SERVICE_SCHEMA_SEND_MEDIA_GROUP, @@ -615,6 +629,8 @@ async def _call_service( await notify_service.set_message_reaction(context=service.context, **kwargs) elif service_name == SERVICE_EDIT_MESSAGE_MEDIA: await notify_service.edit_message_media(context=service.context, **kwargs) + elif service_name == SERVICE_SEND_MESSAGE_DRAFT: + await notify_service.send_message_draft(context=service.context, **kwargs) elif service_name == SERVICE_DOWNLOAD_FILE: return await notify_service.download_file(context=service.context, **kwargs) else: @@ -632,7 +648,8 @@ def _deprecate_timeout(service: ServiceCall) -> None: if ATTR_TIMEOUT not in service.data: return - # default: service was called using frontend such as developer tools or automation editor + # default: service was called using frontend such as + # developer tools or automation editor service_call_origin = "call_service" origin = service.context.origin_event @@ -769,7 +786,8 @@ def _build_targets( ) if not chat_ids and not targets: - # no targets from service data, so we default to the first allowed chat IDs of the config entry + # no targets from service data, so we default + # to the first allowed chat IDs of the config entry subentries = list(config_entry.subentries.values()) if not subentries: raise ServiceValidationError( @@ -841,7 +859,8 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: else service.data[ATTR_TARGET] ) - # default: service was called using frontend such as developer tools or automation editor + # default: service was called using frontend such as + # developer tools or automation editor service_call_origin = "call_service" origin = service.context.origin_event @@ -867,9 +886,23 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), "action_origin": service_call_origin, "telegram_bot_entities_url": "/config/entities?domain=telegram_bot", - "example_old": f"```yaml\naction: {service.service}\ndata:\n target: # to be updated\n - 1234567890\n...\n```", - "example_new_entity_id": f"```yaml\naction: {service.service}\ndata:\n entity_id:\n - notify.telegram_bot_1234567890_1234567890 # replace with your notify entity\n...\n```", - "example_new_chat_id": f"```yaml\naction: {service.service}\ndata:\n chat_id:\n - 1234567890 # replace with your chat_id\n...\n```", + "example_old": ( + f"```yaml\naction: {service.service}\ndata:\n" + " target: # to be updated\n" + " - 1234567890\n...\n```" + ), + "example_new_entity_id": ( + f"```yaml\naction: {service.service}\ndata:\n" + " entity_id:\n" + " - notify.telegram_bot_1234567890_1234567890" + " # replace with your notify entity\n...\n```" + ), + "example_new_chat_id": ( + f"```yaml\naction: {service.service}\ndata:\n" + " chat_id:\n" + " - 1234567890" + " # replace with your chat_id\n...\n```" + ), }, learn_more_url="https://github.com/home-assistant/core/pull/154868", ) @@ -883,6 +916,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) try: await bot.get_me() except InvalidToken as err: + # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryAuthFailed("Invalid API token for Telegram Bot.") from err except TelegramError as err: raise ConfigEntryNotReady from err @@ -913,6 +947,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) async def update_listener(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> None: """Handle config changes.""" entry.runtime_data.parse_mode = entry.options[ATTR_PARSER] + if entry.runtime_data.old_config_data != entry.data: + # Reload if config data has changed + hass.config_entries.async_schedule_reload(entry.entry_id) + return # reload entities await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index fd45e4c219d..40296f149f4 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -39,6 +39,7 @@ from telegram.request import HTTPXRequest from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_COMMAND, + ATTR_DATE, CONF_API_KEY, HTTP_BASIC_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, @@ -48,6 +49,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path from homeassistant.util.json import JsonValueType from .const import ( @@ -57,7 +59,6 @@ from .const import ( ATTR_CHAT_ID, ATTR_CHAT_INSTANCE, ATTR_DATA, - ATTR_DATE, ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, ATTR_FILE, @@ -157,7 +158,8 @@ class BaseTelegramBot: # establish event type: text, command or callback_query if update.callback_query: - # NOTE: Check for callback query first since effective message will be populated with the message + # NOTE: Check for callback query first since + # effective message will be populated with the message # in .callback_query (python-telegram-bot docs are wrong) event_type, event_data = self._get_callback_query_event_data( update.callback_query @@ -201,7 +203,8 @@ class BaseTelegramBot: ATTR_MESSAGE_THREAD_ID: message.message_thread_id, } if filters.COMMAND.filter(message): - # This is a command message - set event type to command and split data into command and args + # This is a command message - set event type + # to command and split data into command and args event_type = EVENT_TELEGRAM_COMMAND event_data.update(self._get_command_event_data(message.text)) elif filters.ATTACHMENT.filter(message): @@ -223,7 +226,7 @@ class BaseTelegramBot: photos = cast(Sequence[PhotoSize], message.effective_attachment) return { ATTR_FILE_ID: photos[-1].file_id, - ATTR_FILE_MIME_TYPE: "image/jpeg", # telegram always uses jpeg for photos + ATTR_FILE_MIME_TYPE: "image/jpeg", ATTR_FILE_SIZE: photos[-1].file_size, } return { @@ -302,6 +305,7 @@ class TelegramNotificationService: """Initialize the service.""" self.app = app self.config = config + self.old_config_data = config.data.copy() self._parsers: dict[str, str | None] = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, @@ -542,7 +546,7 @@ class TelegramNotificationService: ) -> dict[str, JsonValueType]: """Send media group to a chat ID. - :returns: a dict mapping each chat_id to a list of message_ids for the sent media group. + Returns a dict mapping each chat_id to message_ids. """ params = self._get_msg_kwargs(kwargs) @@ -560,7 +564,7 @@ class TelegramNotificationService: authentication=entry.get(ATTR_AUTHENTICATION), verify_ssl=entry[ATTR_VERIFY_SSL], ) - _LOGGER.debug("downloaded: %s", entry[ATTR_URL]) + _LOGGER.debug("downloaded: %s", entry.get(ATTR_URL) or entry.get(ATTR_FILE)) caption: str | None = entry.get(ATTR_CAPTION) if entry[ATTR_MEDIA_TYPE] == InputMediaType.AUDIO: @@ -1012,6 +1016,36 @@ class TelegramNotificationService: context=context, ) + async def send_message_draft( + self, + message: str, + chat_id: int, + draft_id: int, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> None: + """Stream a partial message to a user while the message is being generated.""" + params = self._get_msg_kwargs(kwargs) + + _LOGGER.debug( + "Sending message draft %s in chat ID %s with params: %s", + draft_id, + chat_id, + params, + ) + + await self._send_msg( + self.bot.send_message_draft, + None, + chat_id=chat_id, + draft_id=draft_id, + text=message, + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + parse_mode=params[ATTR_PARSER], + read_timeout=params[ATTR_TIMEOUT], + context=context, + ) + async def download_file( self, file_id: str, @@ -1021,8 +1055,28 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: """Download a file from Telegram.""" - if not directory_path: + if directory_path: + try: + raise_if_invalid_path(directory_path) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_directory_path", + translation_placeholders={"directory_path": directory_path}, + ) from err + else: directory_path = self.hass.config.path(DOMAIN) + + if file_name: + try: + raise_if_invalid_filename(file_name) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_file_name", + translation_placeholders={"file_name": file_name}, + ) from err + file: File = await self._send_msg( self.bot.get_file, None, diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index c2d6ed368ed..99b480ad1e4 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -369,7 +369,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_RECONFIGURE: user_input.update(self._step_user_data) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reconfigure_entry(), title=self._bot_name, data_updates=user_input, @@ -408,7 +408,9 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "no_url_available" description_placeholders[ERROR_FIELD] = "URL" description_placeholders[ERROR_MESSAGE] = ( - "URL is required since you have not configured an external URL in Home Assistant" + "URL is required since you have not" + " configured an external URL" + " in Home Assistant" ) return elif ( @@ -489,13 +491,15 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): CONF_API_ENDPOINT ] if ( - self._get_reconfigure_entry().state == ConfigEntryState.LOADED + self._get_reconfigure_entry().state is ConfigEntryState.LOADED and user_input[CONF_API_ENDPOINT] != DEFAULT_API_ENDPOINT and existing_api_endpoint == DEFAULT_API_ENDPOINT ): # logout existing bot from the official Telegram bot API - # logout is only used when changing the API endpoint from official to a custom one - # there is a 10-minute lockout period after logout so we only logout if necessary + # logout is only used when changing the API + # endpoint from official to a custom one + # there is a 10-minute lockout period after + # logout so we only logout if necessary service: TelegramNotificationService = ( self._get_reconfigure_entry().runtime_data ) @@ -534,7 +538,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): if user_input[CONF_PLATFORM] != PLATFORM_WEBHOOKS: await self._shutdown_bot() - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reconfigure_entry(), title=bot_name, data_updates=user_input ) @@ -579,7 +583,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=description_placeholders, ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reauth_entry(), title=bot_name, data_updates=updated_data ) @@ -592,7 +596,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Create allowed chat ID.""" - if self._get_entry().state != ConfigEntryState.LOADED: + if self._get_entry().state is not ConfigEntryState.LOADED: return self.async_abort( reason="entry_not_loaded", description_placeholders={"telegram_bot": self._get_entry().title}, @@ -659,8 +663,11 @@ async def _get_most_recent_chat( ) -> tuple[int, str | None] | None: """Get the most recent chat ID and name. - For broadcast bot, this is retrieved using get_updates() to find the most recent message received. - For polling or webhook bot, this is retrieved from the runtime data which is updated whenever a message is received. + For broadcast bot, this is retrieved using + get_updates() to find the most recent message received. + For polling or webhook bot, this is retrieved from + the runtime data which is updated whenever a + message is received. """ if service.app is not None: diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 230b42f3040..69606b2fd44 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -31,6 +31,7 @@ DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4 SERVICE_SEND_CHAT_ACTION = "send_chat_action" SERVICE_SEND_MESSAGE = "send_message" +SERVICE_SEND_MESSAGE_DRAFT = "send_message_draft" SERVICE_SEND_PHOTO = "send_photo" SERVICE_SEND_MEDIA_GROUP = "send_media_group" SERVICE_SEND_STICKER = "send_sticker" @@ -86,10 +87,10 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" -ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_DIRECTORY_PATH = "directory_path" +ATTR_DRAFT_ID = "draft_id" ATTR_EDITED_MSG = "edited_message" ATTR_FILE = "file" ATTR_FILE_ID = "file_id" diff --git a/homeassistant/components/telegram_bot/diagnostics.py b/homeassistant/components/telegram_bot/diagnostics.py index 91f9b390f4e..fb4b05e34c3 100644 --- a/homeassistant/components/telegram_bot/diagnostics.py +++ b/homeassistant/components/telegram_bot/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Telegram bot integration.""" -from __future__ import annotations - from typing import Any from yarl import URL diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json index 41165632989..b7c92258027 100644 --- a/homeassistant/components/telegram_bot/icons.json +++ b/homeassistant/components/telegram_bot/icons.json @@ -49,6 +49,9 @@ "send_message": { "service": "mdi:send" }, + "send_message_draft": { + "service": "mdi:chat-processing" + }, "send_photo": { "service": "mdi:camera" }, diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d3bb993376f..a0bb16d6ad7 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -58,7 +58,7 @@ send_message: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -101,7 +101,7 @@ send_chat_action: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -195,7 +195,7 @@ send_photo: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -287,7 +287,7 @@ send_media_group: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -372,7 +372,7 @@ send_sticker: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -466,7 +466,7 @@ send_animation: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -560,7 +560,7 @@ send_video: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -645,7 +645,7 @@ send_voice: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -739,7 +739,7 @@ send_document: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -804,7 +804,7 @@ send_location: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -861,7 +861,7 @@ send_poll: selector: number: mode: box - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -913,7 +913,7 @@ edit_message: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -991,7 +991,7 @@ edit_message_media: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1028,7 +1028,7 @@ edit_caption: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1061,7 +1061,7 @@ edit_replymarkup: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1108,7 +1108,7 @@ delete_message: example: "{{ trigger.event.data.message.message_id }}" selector: text: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1129,7 +1129,7 @@ leave_chat: filter: domain: notify integration: telegram_bot - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1164,7 +1164,7 @@ set_message_reaction: required: false selector: boolean: - advanced: + additional_fields: collapsed: true fields: config_entry_id: @@ -1198,3 +1198,50 @@ download_file: example: "my_downloaded_file" selector: text: + +send_message_draft: + fields: + entity_id: + selector: + entity: + filter: + domain: notify + integration: telegram_bot + multiple: true + reorder: true + message_thread_id: + selector: + number: + mode: box + draft_id: + required: true + selector: + number: + mode: box + min: 1 + message: + example: The garage door has been o + required: true + selector: + text: + parse_mode: + selector: + select: + options: + - "html" + - "markdown" + - "markdownv2" + - "plain_text" + translation_key: "parse_mode" + additional_fields: + collapsed: true + fields: + config_entry_id: + selector: + config_entry: + integration: telegram_bot + chat_id: + example: "[12345, 67890] or 12345" + selector: + text: + multiple: true diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index c332484911c..9b613a98df3 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -200,6 +200,12 @@ "invalid_chat_ids": { "message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}." }, + "invalid_directory_path": { + "message": "Invalid directory path: {directory_path}. The path must not contain `~` or `..`." + }, + "invalid_file_name": { + "message": "Invalid file name: {file_name}. The file name must not contain `~`, `..`, `/` or `\\`." + }, "invalid_inline_keyboard": { "message": "Invalid value for inline keyboard. Only strings or lists are accepted." }, @@ -361,8 +367,8 @@ }, "name": "Delete message", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -419,8 +425,8 @@ }, "name": "Edit caption", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -466,8 +472,8 @@ }, "name": "Edit message", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -529,8 +535,8 @@ }, "name": "Edit message media", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -563,8 +569,8 @@ }, "name": "Edit reply markup", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -586,8 +592,8 @@ }, "name": "Leave chat", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -665,8 +671,8 @@ }, "name": "Send animation", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -699,8 +705,8 @@ }, "name": "Send chat action", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -778,8 +784,8 @@ }, "name": "Send document", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -836,8 +842,8 @@ }, "name": "Send location", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -883,8 +889,8 @@ }, "name": "Send media group", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -946,8 +952,47 @@ }, "name": "Send message", "sections": { - "advanced": { - "name": "Advanced" + "additional_fields": { + "name": "Additional options" + } + } + }, + "send_message_draft": { + "description": "Shows a partial message (draft) in Telegram while the full message is still being generated.", + "fields": { + "chat_id": { + "description": "One or more pre-authorized chat IDs to send the message draft to.", + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]" + }, + "config_entry_id": { + "description": "The config entry representing the Telegram bot to send the message draft.", + "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]" + }, + "draft_id": { + "description": "Unique identifier of the message draft. Changes of drafts with the same identifier are animated.", + "name": "Draft ID" + }, + "entity_id": { + "description": "[%key:component::telegram_bot::services::send_message::fields::entity_id::description%]", + "name": "[%key:component::telegram_bot::services::send_message::fields::entity_id::name%]" + }, + "message": { + "description": "Available part of the message for temporary notification.\nCan't parse entities? Format your message according to the [formatting options]({formatting_options_url}).", + "name": "[%key:component::telegram_bot::services::send_message::fields::message::name%]" + }, + "message_thread_id": { + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]", + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]" + }, + "parse_mode": { + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]", + "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]" + } + }, + "name": "Send message draft", + "sections": { + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -1025,8 +1070,8 @@ }, "name": "Send photo", "sections": { - "advanced": { - "name": "Advanced" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "URL options" @@ -1083,8 +1128,8 @@ }, "name": "Send poll", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } }, @@ -1158,8 +1203,8 @@ }, "name": "Send sticker", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -1240,8 +1285,8 @@ }, "name": "Send video", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -1318,8 +1363,8 @@ }, "name": "Send voice", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" }, "url_options": { "name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]" @@ -1356,8 +1401,8 @@ }, "name": "Set message reaction", "sections": { - "advanced": { - "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + "additional_fields": { + "name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]" } } } diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 31255dee71c..1b9d128f306 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -69,7 +69,7 @@ def _get_trusted_networks(config: TelegramBotConfigEntry) -> list[IPv4Network]: class PushBot(BaseTelegramBot): - """Handles all the push/webhook logic and passes telegram updates to `self.handle_update`.""" + """Handles push/webhook logic, passes updates to `self.handle_update`.""" def __init__( self, @@ -82,7 +82,8 @@ class PushBot(BaseTelegramBot): self.bot = bot self.trusted_networks = _get_trusted_networks(config) self.secret_token = secret_token - # Dumb Application that just gets our updates to our handler callback (self.handle_update) + # Application that gets our updates to our handler + # callback (self.handle_update) self.application = ApplicationBuilder().bot(bot).updater(None).build() self.application.add_handler(TypeHandler(Update, self.handle_update)) super().__init__(hass, config, bot) diff --git a/homeassistant/components/teleinfo/__init__.py b/homeassistant/components/teleinfo/__init__.py new file mode 100644 index 00000000000..82465203ac8 --- /dev/null +++ b/homeassistant/components/teleinfo/__init__.py @@ -0,0 +1,22 @@ +"""The Teleinfo integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TeleinfoConfigEntry, TeleinfoCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: TeleinfoConfigEntry) -> bool: + """Set up Teleinfo from a config entry.""" + coordinator = TeleinfoCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TeleinfoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/teleinfo/config_flow.py b/homeassistant/components/teleinfo/config_flow.py new file mode 100644 index 00000000000..434b90a1594 --- /dev/null +++ b/homeassistant/components/teleinfo/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow for the Teleinfo integration.""" + +import logging +from typing import TYPE_CHECKING, Any + +import serial +from teleinfo import decode, read_frame +import voluptuous as vol + +from homeassistant.components import usb +from homeassistant.components.usb import human_readable_device_name +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .const import CONF_SERIAL_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): str, + } +) + + +class TeleinfoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Teleinfo.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the Teleinfo config flow.""" + self._discovered_device: str | None = None + + async def _validate_serial_port( + self, serial_port: str + ) -> tuple[dict[str, str], dict[str, str] | None]: + """Validate the serial port by reading and decoding a Teleinfo frame. + + Returns a tuple of (errors, decoded_data). On success errors is empty and + decoded_data contains the label/value pairs. On failure decoded_data is None. + """ + errors: dict[str, str] = {} + try: + frame = await self.hass.async_add_executor_job(read_frame, serial_port) + decoded_data: dict[str, str] = decode(frame) + except serial.SerialException: + errors["base"] = "cannot_connect" + return errors, None + except TimeoutError: + errors["base"] = "timeout_connect" + return errors, None + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors, None + return errors, decoded_data + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, decoded_data = await self._validate_serial_port( + user_input[CONF_SERIAL_PORT] + ) + if not errors: + assert decoded_data is not None + adco = decoded_data["ADCO"] + await self.async_set_unique_id(adco) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"Teleinfo ({user_input[CONF_SERIAL_PORT]})", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Handle USB discovery.""" + # Resolve stable /dev/serial/by-id/ path + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + + self._discovered_device = dev_path + self.context["title_placeholders"] = { + "name": human_readable_device_name( + discovery_info.device, + discovery_info.serial_number, + discovery_info.manufacturer, + discovery_info.description, + discovery_info.vid, + discovery_info.pid, + ) + } + return await self.async_step_usb_confirm() + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle USB discovery confirmation.""" + if TYPE_CHECKING: + assert self._discovered_device is not None + if user_input is not None: + # Validate by reading a real Teleinfo frame — silent abort on failure + errors, decoded_data = await self._validate_serial_port( + self._discovered_device + ) + if errors or decoded_data is None: + return self.async_abort(reason="not_teleinfo_device") + + # Use ADCO (meter serial number) as unique_id — same as manual entry + adco = decoded_data["ADCO"] + await self.async_set_unique_id(adco) + self._abort_if_unique_id_configured( + updates={CONF_SERIAL_PORT: self._discovered_device} + ) + + return self.async_create_entry( + title=f"Teleinfo ({self._discovered_device})", + data={CONF_SERIAL_PORT: self._discovered_device}, + ) + self._set_confirm_only() + return self.async_show_form(step_id="usb_confirm") diff --git a/homeassistant/components/teleinfo/const.py b/homeassistant/components/teleinfo/const.py new file mode 100644 index 00000000000..85adf3cd705 --- /dev/null +++ b/homeassistant/components/teleinfo/const.py @@ -0,0 +1,4 @@ +"""Constants for the Teleinfo integration.""" + +DOMAIN = "teleinfo" +CONF_SERIAL_PORT = "serial_port" diff --git a/homeassistant/components/teleinfo/coordinator.py b/homeassistant/components/teleinfo/coordinator.py new file mode 100644 index 00000000000..b5a67b54b02 --- /dev/null +++ b/homeassistant/components/teleinfo/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for the Teleinfo integration.""" + +from datetime import timedelta +import logging + +import serial +from teleinfo import decode, read_frame + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_SERIAL_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + +type TeleinfoConfigEntry = ConfigEntry[TeleinfoCoordinator] + + +class TeleinfoCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Teleinfo data update coordinator.""" + + config_entry: TeleinfoConfigEntry + + def __init__(self, hass: HomeAssistant, entry: TeleinfoConfigEntry) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, str]: + """Read a Teleinfo frame from the serial port and decode it.""" + port = self.config_entry.data[CONF_SERIAL_PORT] + + try: + frame = await self.hass.async_add_executor_job(read_frame, port) + except serial.SerialException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except TimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from err + + try: + return decode(frame) + except Exception as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="decode_error", + ) from err diff --git a/homeassistant/components/teleinfo/icons.json b/homeassistant/components/teleinfo/icons.json new file mode 100644 index 00000000000..4aca973dedd --- /dev/null +++ b/homeassistant/components/teleinfo/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "current_tariff_period": { + "default": "mdi:cash-clock" + }, + "tomorrow_color": { + "default": "mdi:calendar-arrow-right" + } + } + } +} diff --git a/homeassistant/components/teleinfo/manifest.json b/homeassistant/components/teleinfo/manifest.json new file mode 100644 index 00000000000..5bb7f861531 --- /dev/null +++ b/homeassistant/components/teleinfo/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "teleinfo", + "name": "Teleinfo", + "codeowners": ["@esciara"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/teleinfo", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["pyteleinfo==0.4.0"] +} diff --git a/homeassistant/components/teleinfo/quality_scale.yaml b/homeassistant/components/teleinfo/quality_scale.yaml new file mode 100644 index 00000000000..1f3d656ce63 --- /dev/null +++ b/homeassistant/components/teleinfo/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions/services in this integration. + appropriate-polling: + status: done + comment: 10s interval is valid for local serial (min 5s). + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions/services in this integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No event entities in this integration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No actions/services in this integration. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: done + comment: CoordinatorEntity marks entities unavailable automatically when UpdateFailed is raised. + integration-owner: done + log-when-unavailable: + status: done + comment: DataUpdateCoordinator logs UpdateFailed with translation keys on communication/timeout/decode errors. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Teleinfo protocol has no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single device per config entry. + entity-category: + status: exempt + comment: No entities qualify as diagnostic or config — tariff/color sensors report real-world data, not device metadata. + entity-device-class: done + entity-disabled-by-default: + status: done + comment: Less-used sensors (instantaneous_current, tomorrow_color) are disabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: done + comment: icons.json provides icons for sensors without device class defaults. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Read-only serial device with no user-actionable repair scenarios. Failures are transient I/O errors handled by UpdateFailed. + stale-devices: + status: exempt + comment: Single device per config entry. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: No HTTP calls — N/A for this integration. + strict-typing: done diff --git a/homeassistant/components/teleinfo/sensor.py b/homeassistant/components/teleinfo/sensor.py new file mode 100644 index 00000000000..95130a30e64 --- /dev/null +++ b/homeassistant/components/teleinfo/sensor.py @@ -0,0 +1,243 @@ +"""Sensor platform for the Teleinfo integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TeleinfoConfigEntry, TeleinfoCoordinator + +PARALLEL_UPDATES = 0 + +# PTEC (Période Tarifaire en Cours) raw protocol values → clean option keys +PTEC_OPTIONS: dict[str, str] = { + "TH..": "all_hours", + "HC..": "off_peak", + "HP..": "peak", + "HN..": "normal_hours", + "PM..": "mobile_peak", + "HCJB": "off_peak_blue_day", + "HCJW": "off_peak_white_day", + "HCJR": "off_peak_red_day", + "HPJB": "peak_blue_day", + "HPJW": "peak_white_day", + "HPJR": "peak_red_day", +} + +# DEMAIN (Couleur du lendemain) raw protocol values → clean option keys +DEMAIN_OPTIONS: dict[str, str | None] = { + "BLEU": "blue", + "BLAN": "white", + "ROUG": "red", + "----": None, +} + + +@dataclass(frozen=True, kw_only=True) +class TeleinfoSensorEntityDescription(SensorEntityDescription): + """Describes a Teleinfo sensor entity.""" + + value_fn: Callable[[str], StateType] = int + + +SENSOR_DESCRIPTIONS: tuple[TeleinfoSensorEntityDescription, ...] = ( + # ------------------------------------------------------------------ + # Common sensors (present in all contract types) + # ------------------------------------------------------------------ + TeleinfoSensorEntityDescription( + key="PAPP", + translation_key="apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + ), + TeleinfoSensorEntityDescription( + key="IINST", + translation_key="instantaneous_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + ), + TeleinfoSensorEntityDescription( + key="PTEC", + translation_key="current_tariff_period", + device_class=SensorDeviceClass.ENUM, + options=list(PTEC_OPTIONS.values()), + value_fn=PTEC_OPTIONS.get, + ), + # ------------------------------------------------------------------ + # BASE contract (OPTARIF = "BASE") + # ------------------------------------------------------------------ + TeleinfoSensorEntityDescription( + key="BASE", + translation_key="base_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + # ------------------------------------------------------------------ + # HC contract — Heures Creuses (OPTARIF = "HC..") + # ------------------------------------------------------------------ + TeleinfoSensorEntityDescription( + key="HCHC", + translation_key="off_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="HCHP", + translation_key="peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + # ------------------------------------------------------------------ + # EJP contract — Effacement Jours de Pointe (OPTARIF = "EJP.") + # ------------------------------------------------------------------ + TeleinfoSensorEntityDescription( + key="EJPHN", + translation_key="normal_hours_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="EJPHPM", + translation_key="peak_mobile_hours_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="PEJP", + translation_key="ejp_warning", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_registry_enabled_default=False, + ), + # ------------------------------------------------------------------ + # Tempo / BBR contract (OPTARIF = "BBR(" and variants) + # ------------------------------------------------------------------ + TeleinfoSensorEntityDescription( + key="BBRHCJB", + translation_key="blue_day_off_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="BBRHPJB", + translation_key="blue_day_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="BBRHCJW", + translation_key="white_day_off_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="BBRHPJW", + translation_key="white_day_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="BBRHCJR", + translation_key="red_day_off_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="BBRHPJR", + translation_key="red_day_peak_index", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + TeleinfoSensorEntityDescription( + key="DEMAIN", + translation_key="tomorrow_color", + device_class=SensorDeviceClass.ENUM, + options=[v for v in DEMAIN_OPTIONS.values() if v is not None], + entity_registry_enabled_default=False, + value_fn=DEMAIN_OPTIONS.get, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeleinfoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Teleinfo sensor entities.""" + coordinator = entry.runtime_data + adco = coordinator.data["ADCO"] + + async_add_entities( + TeleinfoSensor(coordinator, description, adco) + for description in SENSOR_DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TeleinfoSensor(CoordinatorEntity[TeleinfoCoordinator], SensorEntity): + """Representation of a Teleinfo sensor entity.""" + + _attr_has_entity_name = True + entity_description: TeleinfoSensorEntityDescription + + def __init__( + self, + coordinator: TeleinfoCoordinator, + description: TeleinfoSensorEntityDescription, + adco: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{adco}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, adco)}, + name=f"Teleinfo {adco}", + manufacturer="Enedis", + ) + + @property + def available(self) -> bool: + """Return True if the required label is present in the frame.""" + return ( + super().available and self.entity_description.key in self.coordinator.data + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + data = self.coordinator.data[self.entity_description.key] + return self.entity_description.value_fn(data) diff --git a/homeassistant/components/teleinfo/strings.json b/homeassistant/components/teleinfo/strings.json new file mode 100644 index 00000000000..91779436803 --- /dev/null +++ b/homeassistant/components/teleinfo/strings.json @@ -0,0 +1,108 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_teleinfo_device": "The device does not appear to be a Teleinfo module" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", + "step": { + "usb_confirm": { + "description": "A Teleinfo device was detected. Do you want to set it up?" + }, + "user": { + "data": { + "serial_port": "Serial port" + }, + "data_description": { + "serial_port": "The path to the serial port connected to the Teleinfo module, e.g. /dev/ttyUSB0." + } + } + } + }, + "entity": { + "sensor": { + "apparent_power": { + "name": "Apparent power" + }, + "base_index": { + "name": "Index" + }, + "blue_day_off_peak_index": { + "name": "Blue day off-peak index" + }, + "blue_day_peak_index": { + "name": "Blue day peak index" + }, + "current_tariff_period": { + "name": "Current tariff period", + "state": { + "all_hours": "All hours", + "mobile_peak": "Mobile peak", + "normal_hours": "Normal hours", + "off_peak": "Off-peak", + "off_peak_blue_day": "Off-peak blue day", + "off_peak_red_day": "Off-peak red day", + "off_peak_white_day": "Off-peak white day", + "peak": "Peak", + "peak_blue_day": "Peak blue day", + "peak_red_day": "Peak red day", + "peak_white_day": "Peak white day" + } + }, + "ejp_warning": { + "name": "EJP warning" + }, + "instantaneous_current": { + "name": "Instantaneous current" + }, + "normal_hours_index": { + "name": "Normal hours index" + }, + "off_peak_index": { + "name": "Off-peak index" + }, + "peak_index": { + "name": "Peak index" + }, + "peak_mobile_hours_index": { + "name": "Peak mobile hours index" + }, + "red_day_off_peak_index": { + "name": "Red day off-peak index" + }, + "red_day_peak_index": { + "name": "Red day peak index" + }, + "tomorrow_color": { + "name": "Tomorrow color", + "state": { + "blue": "Blue", + "red": "Red", + "white": "White" + } + }, + "white_day_off_peak_index": { + "name": "White day off-peak index" + }, + "white_day_peak_index": { + "name": "White day peak index" + } + } + }, + "exceptions": { + "communication_error": { + "message": "Failed to communicate with Teleinfo dongle" + }, + "decode_error": { + "message": "Failed to decode Teleinfo frame" + }, + "timeout_error": { + "message": "Timeout waiting for Teleinfo data" + } + } +} diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index 4f88b47b531..93d9ed84489 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -84,6 +84,8 @@ async def async_new_client(hass, session, entry): interval = entry.data[KEY_SCAN_INTERVAL] _LOGGER.debug("Update interval %s seconds", interval) client = TelldusLiveClient(hass, entry, session, interval) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = client dev_reg = dr.async_get(hass) for hub in await client.async_get_hubs(): diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index bfa3f25f735..a76bfd538e5 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -20,6 +20,8 @@ async def async_setup_entry( async def async_discover_binary_sensor(device_id): """Discover and add a discovered sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data client = hass.data[DOMAIN] async_add_entities([TelldusLiveSensor(client, device_id)]) diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 2554acc428c..9203e7ce600 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -23,6 +23,8 @@ async def async_setup_entry( async def async_discover_cover(device_id): """Discover and add a discovered sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data client: TelldusLiveClient = hass.data[DOMAIN] async_add_entities([TelldusLiveCover(client, device_id)]) diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 86fdb4d1d64..9b15fd5f059 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -25,6 +25,8 @@ async def async_setup_entry( async def async_discover_light(device_id): """Discover and add a discovered sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data client = hass.data[DOMAIN] async_add_entities([TelldusLiveLight(client, device_id)]) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 782f240cc41..95706d72523 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -1,7 +1,5 @@ """Support for Tellstick Net/Telstick Live sensors.""" -from __future__ import annotations - from homeassistant.components import sensor from homeassistant.components.sensor import ( SensorDeviceClass, @@ -127,6 +125,8 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data client = hass.data[DOMAIN] async_add_entities([TelldusLiveSensor(client, device_id)]) @@ -176,7 +176,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): @property def _value_as_humidity(self): """Return the value as humidity.""" - return int(round(float(self._value))) + return round(float(self._value)) @property def native_value(self): diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index 346417f8989..57833de5de6 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -22,6 +22,8 @@ async def async_setup_entry( async def async_discover_switch(device_id): """Discover and add a discovered sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data client = hass.data[DOMAIN] async_add_entities([TelldusLiveSwitch(client, device_id)]) diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index 255892c1f6c..16e1ea73ac3 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -1,7 +1,5 @@ """Support for Tellstick covers.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import CoverEntity diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py index 4b335f69558..1784c3ddb9b 100644 --- a/homeassistant/components/tellstick/light.py +++ b/homeassistant/components/tellstick/light.py @@ -1,7 +1,5 @@ """Support for Tellstick lights.""" -from __future__ import annotations - from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index c777aa6f01f..744040ac060 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -1,7 +1,5 @@ """Support for Tellstick sensors.""" -from __future__ import annotations - from collections import namedtuple import logging diff --git a/homeassistant/components/tellstick/switch.py b/homeassistant/components/tellstick/switch.py index 6179daa3f24..7e45f4f24e1 100644 --- a/homeassistant/components/tellstick/switch.py +++ b/homeassistant/components/tellstick/switch.py @@ -1,7 +1,5 @@ """Support for Tellstick switches.""" -from __future__ import annotations - from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 0fa1076c943..7801a4fc553 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -1,7 +1,5 @@ """Support for switch controlled using a telnet connection.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/teltonika/__init__.py b/homeassistant/components/teltonika/__init__.py index 56685afc957..f3ed7cf5012 100644 --- a/homeassistant/components/teltonika/__init__.py +++ b/homeassistant/components/teltonika/__init__.py @@ -1,7 +1,5 @@ """The Teltonika integration.""" -from __future__ import annotations - import logging from teltasync import Teltasync diff --git a/homeassistant/components/teltonika/config_flow.py b/homeassistant/components/teltonika/config_flow.py index 2d6f06bc35d..129de4022bb 100644 --- a/homeassistant/components/teltonika/config_flow.py +++ b/homeassistant/components/teltonika/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Teltonika integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -13,6 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -61,6 +60,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: device_info = await client.get_device_info() auth_valid = await client.validate_credentials() + device_id = device_info.device_identifier + if auth_valid and device_id is None: + system_info = await client.get_system_info() + device_id = system_info.mnf_info.serial except TeltonikaConnectionError as err: _LOGGER.debug( "Failed to connect to Teltonika device at %s: %s", base_url, err @@ -78,7 +81,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return { "title": device_info.device_name, - "device_id": device_info.device_identifier, + "device_id": device_id, "host": base_url, } @@ -195,7 +198,8 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): # Store discovered host for later use self._discovered_host = host - # Try to get device info without authentication to get device identifier and name + # Try to get device info without authentication + # to get device identifier and name session = async_get_clientsession(self.hass) for base_url in get_url_variants(host): @@ -222,8 +226,29 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): # No URL variant worked, device not reachable, don't autodiscover return self.async_abort(reason="cannot_connect") - # Set unique ID and check for existing conf - await self.async_set_unique_id(device_id) + formatted_mac = dr.format_mac(discovery_info.macaddress) + + if device_id is None: + # FW with API v1.0 doesn't expose any unique identifier on the + # unauthorized endpoint. Match existing entries by MAC so it + # aborts without asking for credentials again. + device_reg = dr.async_get(self.hass) + if existing := device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)} + ): + for entry_id in existing.config_entries: + entry = self.hass.config_entries.async_get_entry(entry_id) + if ( + entry is not None + and entry.domain == DOMAIN + and entry.unique_id is not None + ): + device_id = entry.unique_id + break + + # Use the MAC as a placeholder unique_id when nothing matched, so + # parallel DHCP advertisements don't both reach dhcp_confirm. + await self.async_set_unique_id(device_id or formatted_mac) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) # Store discovery info for the user step @@ -245,21 +270,28 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): # Get the host from the discovery host = getattr(self, "_discovered_host", "") + data = { + CONF_HOST: host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: False, + } try: - # Validate credentials with discovered host - data = { - CONF_HOST: host, - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_VERIFY_SSL: False, - } info = await validate_input(self.hass, data) - - # Update unique ID to device identifier if we didn't get it during discovery + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during DHCP confirm") + errors["base"] = "unknown" + else: + # Update unique ID to device identifier + # if we didn't get it during discovery await self.async_set_unique_id( info["device_id"], raise_on_progress=False ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: info["host"]}) return self.async_create_entry( title=info["title"], @@ -270,13 +302,6 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_VERIFY_SSL: False, }, ) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception during DHCP confirm") - errors["base"] = "unknown" return self.async_show_form( step_id="dhcp_confirm", diff --git a/homeassistant/components/teltonika/coordinator.py b/homeassistant/components/teltonika/coordinator.py index 7d1a614d141..877c821d7ee 100644 --- a/homeassistant/components/teltonika/coordinator.py +++ b/homeassistant/components/teltonika/coordinator.py @@ -1,19 +1,21 @@ """DataUpdateCoordinator for Teltonika.""" -from __future__ import annotations - from datetime import timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from aiohttp import ClientResponseError, ContentTypeError from teltasync import Teltasync, TeltonikaAuthenticationError, TeltonikaConnectionError from teltasync.error_codes import TeltonikaErrorCode -from teltasync.modems import Modems +from teltasync.modems import Modems, ModemStatusFull from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -33,7 +35,7 @@ AUTH_ERROR_CODES = frozenset( ) -class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, ModemStatusFull]]): """Class to manage fetching Teltonika data.""" device_info: DeviceInfo @@ -75,6 +77,14 @@ class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Store device info for use by entities self.device_info = DeviceInfo( identifiers={(DOMAIN, system_info_response.mnf_info.serial)}, + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) + for mac in ( + system_info_response.mnf_info.mac_eth, + system_info_response.mnf_info.mac, + ) + if mac + }, name=system_info_response.static.device_name, manufacturer="Teltonika", model=system_info_response.static.model, @@ -83,7 +93,7 @@ class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): configuration_url=self.base_url, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> dict[str, ModemStatusFull]: """Fetch data from Teltonika device.""" modems = Modems(self.client.auth) try: @@ -116,14 +126,10 @@ class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(f"Error communicating with device: {error_message}") # Return only modems which are online - modem_data: dict[str, Any] = {} - if modems_response.data: - modem_data.update( - { - modem.id: modem - for modem in modems_response.data - if Modems.is_online(modem) - } - ) - - return modem_data + if not modems_response.data: + return {} + return { + modem.id: modem + for modem in modems_response.data + if isinstance(modem, ModemStatusFull) + } diff --git a/homeassistant/components/teltonika/manifest.json b/homeassistant/components/teltonika/manifest.json index e6359073e70..029fa618756 100644 --- a/homeassistant/components/teltonika/manifest.json +++ b/homeassistant/components/teltonika/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["teltasync==0.2.0"] + "requirements": ["teltasync==0.3.1"] } diff --git a/homeassistant/components/teltonika/quality_scale.yaml b/homeassistant/components/teltonika/quality_scale.yaml index 8ac4004ef8e..a1a52692bfc 100644 --- a/homeassistant/components/teltonika/quality_scale.yaml +++ b/homeassistant/components/teltonika/quality_scale.yaml @@ -67,4 +67,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/teltonika/sensor.py b/homeassistant/components/teltonika/sensor.py index 623d73c987b..10feb23339f 100644 --- a/homeassistant/components/teltonika/sensor.py +++ b/homeassistant/components/teltonika/sensor.py @@ -1,12 +1,10 @@ """Teltonika sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging -from teltasync.modems import ModemStatus +from teltasync.modems import ModemStatusFull from homeassistant.components.sensor import ( SensorDeviceClass, @@ -36,7 +34,7 @@ PARALLEL_UPDATES = 0 class TeltonikaSensorEntityDescription(SensorEntityDescription): """Describes Teltonika sensor entity.""" - value_fn: Callable[[ModemStatus], StateType] + value_fn: Callable[[ModemStatusFull], StateType] SENSOR_DESCRIPTIONS: tuple[TeltonikaSensorEntityDescription, ...] = ( @@ -155,7 +153,7 @@ class TeltonikaSensorEntity( device_info: DeviceInfo, description: TeltonikaSensorEntityDescription, modem_id: str, - modem: ModemStatus, + modem: ModemStatusFull, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/teltonika/util.py b/homeassistant/components/teltonika/util.py index 54cc0c4fedf..d1e1e9581b6 100644 --- a/homeassistant/components/teltonika/util.py +++ b/homeassistant/components/teltonika/util.py @@ -1,7 +1,5 @@ """Utility helpers for the Teltonika integration.""" -from __future__ import annotations - from yarl import URL diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index 92b7fe3de43..f2d5efcb33c 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -1,7 +1,5 @@ """Support for getting temperature from TEMPer devices.""" -from __future__ import annotations - import logging from temperusb.temper import TemperHandler diff --git a/homeassistant/components/temperature/__init__.py b/homeassistant/components/temperature/__init__.py index 4479fdbefc7..1a1382b3ded 100644 --- a/homeassistant/components/temperature/__init__.py +++ b/homeassistant/components/temperature/__init__.py @@ -1,7 +1,5 @@ """Integration for temperature triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index 3bae43cc03b..589883baf57 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -1,7 +1,5 @@ """Provides conditions for temperature.""" -from __future__ import annotations - from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, @@ -48,6 +46,21 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase): _domain_specs = TEMPERATURE_DOMAIN_SPECS _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the temperature attribute. + + Mirrors the temperature trigger: for climate / water_heater / + weather (attribute-based), the entity is filtered when the source + attribute is absent; sensor entities (state-value-based) fall + through to the base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + def _get_entity_unit(self, entity_state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if entity_state.domain == SENSOR_DOMAIN: diff --git a/homeassistant/components/temperature/conditions.yaml b/homeassistant/components/temperature/conditions.yaml index a979b371e00..aa611b494e0 100644 --- a/homeassistant/components/temperature/conditions.yaml +++ b/homeassistant/components/temperature/conditions.yaml @@ -23,11 +23,13 @@ is_value: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: threshold: required: true selector: diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json index c970474b78e..d39b92e0f5e 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::temperature::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::temperature::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Temperature value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Temperature", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::temperature::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::temperature::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index 79995349e66..50e42c237a9 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for temperature.""" -from __future__ import annotations - from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, @@ -42,12 +40,29 @@ TEMPERATURE_DOMAIN_SPECS = { class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase): - """Mixin for temperature triggers providing entity filtering, value extraction, and unit conversion.""" + """Mixin for temperature triggers with filtering and conversion.""" _base_unit = UnitOfTemperature.CELSIUS _domain_specs = TEMPERATURE_DOMAIN_SPECS _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the temperature attribute. + + For domains whose tracked value comes from an attribute + (climate / water_heater / weather), require the attribute to be + present; otherwise the all/count check would treat an entity that + cannot report a temperature as a non-match and block behavior=last. + Sensor entities source their value from `state.state`, so they + fall through to the base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + def _get_entity_unit(self, state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if state.domain == SENSOR_DOMAIN: diff --git a/homeassistant/components/temperature/triggers.yaml b/homeassistant/components/temperature/triggers.yaml index 1db551aedf8..74ef28351fa 100644 --- a/homeassistant/components/temperature/triggers.yaml +++ b/homeassistant/components/temperature/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .temperature_units: &temperature_units - "°C" @@ -47,6 +48,7 @@ crossed_threshold: target: *trigger_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index c1a136a29ef..529560569d8 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -1,7 +1,5 @@ """The template component.""" -from __future__ import annotations - import asyncio from collections.abc import Coroutine import logging @@ -26,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryError, HomeAssistantError -from homeassistant.helpers import discovery, issue_registry as ir +from homeassistant.helpers import discovery from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_current_device, ) @@ -41,21 +39,12 @@ from homeassistant.util.hass_dict import HassKey from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS from .coordinator import TriggerUpdateCoordinator -from .helpers import DATA_DEPRECATION, async_get_blueprints +from .helpers import async_get_blueprints _LOGGER = logging.getLogger(__name__) DATA_COORDINATORS: HassKey[list[TriggerUpdateCoordinator]] = HassKey(DOMAIN) -def _clean_up_legacy_template_deprecations(hass: HomeAssistant) -> None: - if (found_issues := hass.data.pop(DATA_DEPRECATION, None)) is not None: - issue_registry = ir.async_get(hass) - for domain, issue_id in set(issue_registry.issues): - if domain != DOMAIN or issue_id in found_issues: - continue - ir.async_delete_issue(hass, DOMAIN, issue_id) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the template integration.""" @@ -74,14 +63,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload top-level + platforms.""" - hass.data.pop(DATA_DEPRECATION, None) await async_get_blueprints(hass).async_reset_cache() try: unprocessed_conf = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: - _LOGGER.error(err) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_reload_template_entities", + translation_placeholders={"error": str(err)}, + ) from err integration = await async_get_integration(hass, DOMAIN) conf = await conf_util.async_process_component_and_handle_errors( @@ -96,7 +87,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN in conf: await _process_config(hass, conf) - _clean_up_legacy_template_deprecations(hass) hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) @@ -206,7 +196,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: # Remove old ones if coordinators: for coordinator in coordinators: - coordinator.async_remove() + await coordinator.async_shutdown() async def init_coordinator( hass: HomeAssistant, conf_section: dict[str, Any] @@ -234,7 +224,9 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: "entities": [ { **entity_conf, - "raw_blueprint_inputs": conf_section.raw_blueprint_inputs, + "raw_blueprint_inputs": ( + conf_section.raw_blueprint_inputs + ), "raw_configs": conf_section.raw_config, } for entity_conf in conf_section[platform_domain] diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 90c0bb0a56f..2546224405d 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Template alarm control panels.""" -from __future__ import annotations - from enum import Enum import logging from typing import TYPE_CHECKING, Any @@ -11,7 +9,6 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -22,8 +19,6 @@ from homeassistant.const import ( ATTR_CODE, CONF_NAME, CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -56,7 +51,6 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_ALARM_CONTROL_PANELS = "panels" CONF_ARM_AWAY_ACTION = "arm_away" CONF_ARM_CUSTOM_BYPASS_ACTION = "arm_custom_bypass" CONF_ARM_HOME_ACTION = "arm_home" @@ -76,10 +70,6 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -LEGACY_FIELDS = { - CONF_VALUE_TEMPLATE: CONF_STATE, -} - SCRIPT_FIELDS = ( CONF_ARM_AWAY_ACTION, CONF_ARM_CUSTOM_BYPASS_ACTION, @@ -117,33 +107,6 @@ ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( ).schema ) -ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( - TemplateCodeFormat - ), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } -) - -PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA - ), - } -) - ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -181,8 +144,6 @@ async def async_setup_platform( TriggerAlarmControlPanelEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_ALARM_CONTROL_PANELS, script_options=SCRIPT_FIELDS, ) @@ -211,7 +172,8 @@ class AbstractTemplateAlarmControlPanel( _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__. + # The super init is not called because + # TemplateEntity calls AbstractTemplateEntity.__init__. def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called """Setup the templates and scripts.""" diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 8bccb47687d..699e032e70a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -1,7 +1,5 @@ """Support for exposing a templated binary sensor.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from datetime import datetime, timedelta @@ -15,23 +13,13 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, - CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON_TEMPLATE, - CONF_NAME, - CONF_SENSORS, CONF_STATE, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, - CONF_VALUE_TEMPLATE, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -56,8 +44,6 @@ from .helpers import ( async_setup_template_preview, ) from .schemas import ( - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, make_template_entity_common_modern_attributes_schema, ) @@ -70,10 +56,6 @@ CONF_DELAY_ON = "delay_on" CONF_DELAY_OFF = "delay_off" CONF_AUTO_OFF = "auto_off" -LEGACY_FIELDS = { - CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_VALUE_TEMPLATE: CONF_STATE, -} BINARY_SENSOR_COMMON_SCHEMA = vol.Schema( { @@ -96,34 +78,6 @@ BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(ATTR_ENTITY_ID), - vol.Schema( - { - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, - vol.Optional(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template), - vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), -) - - -PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( - BINARY_SENSOR_LEGACY_YAML_SCHEMA - ), - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -140,8 +94,6 @@ async def async_setup_platform( TriggerBinarySensorEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_SENSORS, ) @@ -178,8 +130,11 @@ class AbstractTemplateBinarySensor( _entity_id_format = ENTITY_ID_FORMAT _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" @@ -281,6 +236,9 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor): domain = BINARY_SENSOR_DOMAIN + # delay on and delay off are validated when the state is validated. + skip_rendered_result = (CONF_DELAY_ON, CONF_DELAY_OFF) + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 48f9ed19530..0ae403d36e5 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -1,7 +1,5 @@ """Support for buttons which integrates with other components.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index cc261ce3288..a65d3abeb0a 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -18,6 +18,7 @@ from homeassistant.components.blueprint import ( ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN @@ -59,6 +60,7 @@ from . import ( binary_sensor as binary_sensor_platform, button as button_platform, cover as cover_platform, + device_tracker as device_tracker_platform, event as event_platform, fan as fan_platform, image as image_platform, @@ -73,11 +75,7 @@ from . import ( weather as weather_platform, ) from .const import CONF_DEFAULT_ENTITY_ID, DOMAIN, PLATFORMS, TemplateConfig -from .helpers import ( - async_get_blueprints, - create_legacy_template_issue, - rewrite_legacy_to_modern_configs, -) +from .helpers import async_get_blueprints _LOGGER = logging.getLogger(__name__) @@ -123,7 +121,10 @@ def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], invalid = {CONF_TRIGGERS, CONF_ACTIONS} if found_invalid := invalid.intersection(set(obj.keys())): raise vol.Invalid( - f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration", + f"Unsupported option(s) found for domain" + f" {found_domains.pop()}, please remove" + f" ({', '.join(found_invalid)})" + " from your configuration", ) return obj @@ -158,7 +159,8 @@ def validate_trigger_format( [CONF_SENSORS, CONF_BINARY_SENSORS, *PLATFORMS] ): _LOGGER.warning( - "Invalid template configuration found, trigger option is missing matching domain" + "Invalid template configuration found," + " trigger option is missing matching domain" ) create_trigger_format_issue(hass, raw_config, CONF_TRIGGERS) @@ -182,13 +184,7 @@ CONFIG_SECTION_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.BINARY_SENSOR_LEGACY_YAML_SCHEMA - ), vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, - vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.SENSOR_LEGACY_YAML_SCHEMA - ), vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, @@ -205,6 +201,9 @@ CONFIG_SECTION_SCHEMA = vol.All( vol.Optional(COVER_DOMAIN): vol.All( cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), + vol.Optional(DEVICE_TRACKER_DOMAIN): vol.All( + cv.ensure_list, [device_tracker_platform.TRACKER_YAML_SCHEMA] + ), vol.Optional(EVENT_DOMAIN): vol.All( cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA] ), @@ -309,8 +308,10 @@ async def _async_resolve_template_config( # Trigger based template entities retain CONF_VARIABLES because the variables are # always executed between the trigger and action. elif CONF_TRIGGERS not in config and CONF_VARIABLES in config: - # State based template entities have 2 layers of variables. Variables at the section level - # and variables at the entity level should be merged together at the entity level. + # State based template entities have 2 layers of + # variables. Variables at the section level and + # variables at the entity level should be merged + # together at the entity level. section_variables = config.pop(CONF_VARIABLES) platform_config: list[ConfigType] | ConfigType platforms = [platform for platform in PLATFORMS if platform in config] @@ -377,42 +378,6 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf async_notify_setup_error(hass, DOMAIN) continue - legacy_warn_printed = False - - for old_key, new_key, legacy_fields in ( - ( - CONF_SENSORS, - SENSOR_DOMAIN, - sensor_platform.LEGACY_FIELDS, - ), - ( - CONF_BINARY_SENSORS, - BINARY_SENSOR_DOMAIN, - binary_sensor_platform.LEGACY_FIELDS, - ), - ): - if old_key not in template_config: - continue - - if not legacy_warn_printed: - legacy_warn_printed = True - _LOGGER.warning( - "The entity definition format under template: differs from the" - " platform " - "configuration format. See " - "https://www.home-assistant.io/integrations/template#configuration-for-trigger-based-template-sensors" - ) - - definitions = ( - list(template_config[new_key]) if new_key in template_config else [] - ) - for definition in rewrite_legacy_to_modern_configs( - hass, new_key, template_config[old_key], legacy_fields - ): - create_legacy_template_issue(hass, definition, new_key) - definitions.append(definition) - template_config = TemplateConfig({**template_config, new_key: definitions}) - config_sections.append(template_config) # Create a copy of the configuration with all config for current diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 52c6ac1ed2f..bbfc717f3a7 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Template integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, cast @@ -13,6 +11,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.event import EventDeviceClass +from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -24,6 +23,8 @@ from homeassistant.components.update import UpdateDeviceClass from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, @@ -75,6 +76,11 @@ from .cover import ( STOP_ACTION, async_create_preview_cover, ) +from .device_tracker import ( + CONF_IN_ZONES, + CONF_LOCATION_ACCURACY, + async_create_preview_tracker, +) from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event from .fan import ( CONF_OFF_ACTION, @@ -151,6 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = { def generate_schema(domain: str, flow_type: str) -> vol.Schema: """Generate schema.""" schema: dict[vol.Marker, Any] = {} + advanced_options: dict[vol.Marker, Any] = {} if flow_type == "config": schema = {vol.Required(CONF_NAME): selector.TextSelector()} @@ -180,18 +187,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: } if domain == Platform.BINARY_SENSOR: - schema |= _SCHEMA_STATE - if flow_type == "config": - schema |= { - vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[cls.value for cls in BinarySensorDeviceClass], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="binary_sensor_device_class", - sort=True, - ), + schema |= _SCHEMA_STATE | { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + sort=True, ), - } + ), + } if domain == Platform.BUTTON: schema |= { @@ -229,6 +234,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.DEVICE_TRACKER: + schema |= { + vol.Optional(CONF_IN_ZONES): selector.TemplateSelector(), + vol.Optional(CONF_LATITUDE): selector.TemplateSelector(), + vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(), + } + advanced_options |= { + vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(), + } + if domain == Platform.EVENT: schema |= { vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(), @@ -288,6 +303,14 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: if domain == Platform.NUMBER: schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in NumberDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="number_device_class", + sort=True, + ), + ), vol.Required(CONF_STATE): selector.TemplateSelector(), vol.Required(CONF_MIN, default=DEFAULT_MIN_VALUE): selector.NumberSelector( selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), @@ -426,6 +449,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Schema( { vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), + **advanced_options, } ), {"collapsed": True}, @@ -535,6 +559,7 @@ TEMPLATE_TYPES = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, Platform.IMAGE, @@ -570,6 +595,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.DEVICE_TRACKER: SchemaFlowFormStep( + config_schema(Platform.DEVICE_TRACKER), + preview="template", + validate_user_input=validate_user_input(Platform.DEVICE_TRACKER), + ), Platform.EVENT: SchemaFlowFormStep( config_schema(Platform.EVENT), preview="template", @@ -655,6 +685,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.DEVICE_TRACKER: SchemaFlowFormStep( + options_schema(Platform.DEVICE_TRACKER), + preview="template", + validate_user_input=validate_user_input(Platform.DEVICE_TRACKER), + ), Platform.EVENT: SchemaFlowFormStep( options_schema(Platform.EVENT), preview="template", @@ -725,6 +760,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, Platform.COVER: async_create_preview_cover, + Platform.DEVICE_TRACKER: async_create_preview_tracker, Platform.EVENT: async_create_preview_event, Platform.FAN: async_create_preview_fan, Platform.LIGHT: async_create_preview_light, @@ -842,7 +878,12 @@ def ws_start_preview( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": attributes, "listeners": listeners, "state": state}, + { + "attributes": attributes, + "domain": template_type, + "listeners": listeners, + "state": state, + }, ) ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index f5b584f4c16..cbb9c3beb27 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -26,6 +26,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, Platform.IMAGE, diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index a2823233336..53ec947f43e 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -1,8 +1,8 @@ """Data update coordinator for trigger based template entities.""" -from collections.abc import Callable, Mapping +from collections.abc import Callable import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( @@ -37,7 +37,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" ) self.config = config - self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None + self._cond_func: condition.ConditionsChecker | None = None self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None @@ -59,13 +59,19 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): """Return unique ID for the entity.""" return self.config.get("unique_id") - @callback - def async_remove(self) -> None: - """Signal that the entities need to remove themselves.""" + async def async_shutdown(self) -> None: + """Shut down the coordinator and clean up resources.""" + await super().async_shutdown() if self._unsub_start: self._unsub_start() + self._unsub_start = None if self._unsub_trigger: self._unsub_trigger() + self._unsub_trigger = None + if self._script is not None: + await self._script.async_unload() + if self._cond_func is not None: + self._cond_func.async_unload() async def async_setup(self, hass_config: ConfigType) -> None: """Set up the trigger and create entities.""" @@ -154,10 +160,11 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): def _check_condition(self, run_variables: TemplateVarsType) -> bool: if not self._cond_func: return True - condition_result = self._cond_func(run_variables) + condition_result = self._cond_func.async_check(variables=run_variables) if condition_result is False: _LOGGER.debug( - "Conditions not met, aborting template trigger update. Condition summary: %s", + "Conditions not met, aborting template" + " trigger update. Condition summary: %s", trace_get(clear=False), ) return condition_result diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e7cf443ee70..3dd87f3de81 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -1,7 +1,5 @@ """Support for covers which integrate with other components.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -12,22 +10,12 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA, DOMAIN as COVER_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, CoverEntityFeature, CoverState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_COVERS, - CONF_DEVICE_CLASS, - CONF_ENTITY_ID, - CONF_FRIENDLY_NAME, - CONF_NAME, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -46,7 +34,6 @@ from .helpers import ( ) from .schemas import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, - TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, ) @@ -59,9 +46,7 @@ CLOSED_STATE = "closed" CLOSING_STATE = "closing" CONF_POSITION = "position" -CONF_POSITION_TEMPLATE = "position_template" CONF_TILT = "tilt" -CONF_TILT_TEMPLATE = "tilt_template" OPEN_ACTION = "open_cover" CLOSE_ACTION = "close_cover" STOP_ACTION = "stop_cover" @@ -86,12 +71,6 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) -LEGACY_FIELDS = { - CONF_VALUE_TEMPLATE: CONF_STATE, - CONF_POSITION_TEMPLATE: CONF_POSITION, - CONF_TILT_TEMPLATE: CONF_TILT, -} - DEFAULT_NAME = "Template Cover" COVER_COMMON_SCHEMA = vol.Schema( @@ -122,34 +101,6 @@ COVER_YAML_SCHEMA = vol.All( cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) -COVER_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(CONF_ENTITY_ID), - vol.Schema( - { - vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_POSITION_TEMPLATE): cv.template, - vol.Optional(CONF_TILT_TEMPLATE): cv.template, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, - vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema) - .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), - cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), -) - -PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} -) - COVER_CONFIG_ENTRY_SCHEMA = vol.All( COVER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), @@ -171,8 +122,6 @@ async def async_setup_platform( TriggerCoverEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_COVERS, script_options=SCRIPT_FIELDS, ) @@ -217,8 +166,11 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): _extra_optimistic_options = (CONF_POSITION,) _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py new file mode 100644 index 00000000000..3c7f013ddbc --- /dev/null +++ b/homeassistant/components/template/device_tracker.py @@ -0,0 +1,250 @@ +"""Support for device trackers which integrates with other components.""" + +from collections.abc import Callable +from typing import Any + +import voluptuous as vol + +from homeassistant.components import zone +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + ENTITY_ID_FORMAT, + TrackerEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import TriggerUpdateCoordinator, validators as template_validators +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .schemas import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + make_template_entity_common_modern_schema, +) +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +DEFAULT_NAME = "Template Device Tracker" + +CONF_IN_ZONES = "in_zones" +CONF_LOCATION_ACCURACY = "location_accuracy" + + +def _validate_in_zones_or_lat_and_lon(obj: dict) -> dict: + if CONF_IN_ZONES not in obj: + if CONF_LATITUDE not in obj or CONF_LONGITUDE not in obj: + raise vol.Invalid( + f"Either '{CONF_IN_ZONES}' or both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified" + ) + elif (CONF_LATITUDE in obj and CONF_LONGITUDE not in obj) or ( + CONF_LATITUDE not in obj and CONF_LONGITUDE in obj + ): + raise vol.Invalid( + f"Both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified" + ) + + return obj + + +def validate_in_zones( + entity: AbstractTemplateTracker, +) -> Callable[[Any], list[str] | None]: + """Convert the result to a list of entity_ids. + + This ensures the result is a list of zone entity_ids. + All other values that are not lists will result in None. + """ + + def convert(result: Any) -> list[str] | None: + if template_validators.check_result_for_none(result): + return None + + if not isinstance(result, list): + template_validators.log_validation_result_error( + entity, + CONF_IN_ZONES, + result, + "expected a list of zone entity_ids", + ) + return None + + zone_entity_ids = [] + failed = [] + for v in result: + try: + zone_entity_ids.append( + vol.All(cv.entity_id, cv.entity_domain(zone.DOMAIN))(v) + ) + except vol.Invalid: + failed.append(v) + + if failed: + template_validators.log_validation_result_error( + entity, + CONF_IN_ZONES, + failed, + "expected a list of zone entity_ids", + ) + + return zone_entity_ids + + return convert + + +TRACKER_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_IN_ZONES): cv.template, + vol.Optional(CONF_LATITUDE): cv.template, + vol.Optional(CONF_LOCATION_ACCURACY): cv.template, + vol.Optional(CONF_LONGITUDE): cv.template, + } +) + + +TRACKER_YAML_SCHEMA = vol.All( + _validate_in_zones_or_lat_and_lon, + TRACKER_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema( + DEVICE_TRACKER_DOMAIN, DEFAULT_NAME + ).schema + ), +) + +TRACKER_CONFIG_ENTRY_SCHEMA = vol.All( + _validate_in_zones_or_lat_and_lon, + TRACKER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the template device trackers.""" + await async_setup_template_platform( + hass, + DEVICE_TRACKER_DOMAIN, + config, + StateTrackerEntity, + TriggerTrackerEntity, + async_add_entities, + discovery_info, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateTrackerEntity, + TRACKER_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_tracker( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateTrackerEntity: + """Create a preview device tracker.""" + return async_setup_template_preview( + hass, + name, + config, + StateTrackerEntity, + TRACKER_CONFIG_ENTRY_SCHEMA, + ) + + +class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): + """Representation of a template device tracker features.""" + + _entity_id_format = ENTITY_ID_FORMAT + + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. + def __init__(self) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + self.setup_template( + CONF_IN_ZONES, + "_attr_in_zones", + validate_in_zones(self), + ) + self.setup_template( + CONF_LATITUDE, + "_attr_latitude", + template_validators.number(self, CONF_LATITUDE, -90.0, 90.0), + ) + self.setup_template( + CONF_LONGITUDE, + "_attr_longitude", + template_validators.number(self, CONF_LONGITUDE, -180.0, 180.0), + ) + self.setup_template( + CONF_LOCATION_ACCURACY, + "_attr_location_accuracy", + on_update=self._update_location_accuracy, + none_on_template_error=False, + ) + + self._location_accuracy_validator = template_validators.number( + self, CONF_LOCATION_ACCURACY, 0.0 + ) + + def _update_location_accuracy(self, value: float | None) -> None: + """Update the location accuracy.""" + self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0 + + +class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker): + """Representation of a Template device tracker.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the Template device tracker.""" + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateTracker.__init__(self) + + +class TriggerTrackerEntity(TriggerEntity, AbstractTemplateTracker): + """Tracker entity based on trigger data.""" + + domain = DEVICE_TRACKER_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateTracker.__init__(self) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index f7b5c3ff989..9f0eed02cf1 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -91,9 +91,11 @@ class AbstractTemplateEntity(Entity): ) -> None: """Set up a template that manages the main state of the entity. - Requires _state_option to be set on the inheriting class. _state_option represents - the configuration option that derives the state. E.g. Template weather entities main state option - is 'condition', where switch is 'state'. + Requires _state_option to be set on the inheriting + class. _state_option represents the configuration + option that derives the state. E.g. Template weather + entities main state option is 'condition', where + switch is 'state'. """ @abstractmethod @@ -168,6 +170,17 @@ class AbstractTemplateEntity(Entity): domain, ) + async def async_will_remove_from_hass(self) -> None: + """Clean up scripts when removing from Home Assistant.""" + if not self.registry_entry or self.registry_entry.entity_id == self.entity_id: + # Entity ID not changed, unload scripts as they will not be reused. + for action_script in self._action_scripts.values(): + await action_script.async_unload() + else: + # Entity ID changed, just stop scripts + for action_script in self._action_scripts.values(): + await action_script.async_stop() + async def async_run_script( self, script: Script, diff --git a/homeassistant/components/template/event.py b/homeassistant/components/template/event.py index 92c7f330ce0..aac64614772 100644 --- a/homeassistant/components/template/event.py +++ b/homeassistant/components/template/event.py @@ -1,7 +1,5 @@ """Support for events which integrates with other components.""" -from __future__ import annotations - import logging from typing import Any, Final @@ -120,8 +118,11 @@ class AbstractTemplateEvent(AbstractTemplateEntity, EventEntity): _entity_id_format = ENTITY_ID_FORMAT - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self._attr_device_class = config.get(CONF_DEVICE_CLASS) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 4e29a77f058..5793310a1fc 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -1,7 +1,5 @@ """Support for Template fans.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any @@ -20,14 +18,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ENTITY_ID, - CONF_FRIENDLY_NAME, - CONF_NAME, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -46,7 +37,6 @@ from .helpers import ( async_setup_template_preview, ) from .schemas import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, @@ -56,13 +46,8 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_FANS = "fans" CONF_SPEED_COUNT = "speed_count" CONF_PRESET_MODES = "preset_modes" -CONF_PERCENTAGE_TEMPLATE = "percentage_template" -CONF_PRESET_MODE_TEMPLATE = "preset_mode_template" -CONF_OSCILLATING_TEMPLATE = "oscillating_template" -CONF_DIRECTION_TEMPLATE = "direction_template" CONF_ON_ACTION = "turn_on" CONF_OFF_ACTION = "turn_off" CONF_SET_PERCENTAGE_ACTION = "set_percentage" @@ -77,14 +62,6 @@ CONF_OSCILLATING = "oscillating" CONF_PERCENTAGE = "percentage" CONF_PRESET_MODE = "preset_mode" -LEGACY_FIELDS = { - CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, - CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, - CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, - CONF_PRESET_MODE_TEMPLATE: CONF_PRESET_MODE, - CONF_VALUE_TEMPLATE: CONF_STATE, -} - DEFAULT_NAME = "Template Fan" SCRIPT_FIELDS = ( @@ -118,34 +95,6 @@ FAN_YAML_SCHEMA = FAN_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).ex make_template_entity_common_modern_schema(FAN_DOMAIN, DEFAULT_NAME).schema ) -FAN_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(CONF_ENTITY_ID), - vol.Schema( - { - vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, - vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, - vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } - ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), -) - -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} -) - FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -166,8 +115,6 @@ async def async_setup_platform( TriggerFanEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_FANS, script_options=SCRIPT_FIELDS, ) @@ -209,8 +156,11 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self.setup_state_template( @@ -218,7 +168,8 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): template_validators.boolean(self, CONF_STATE), ) - # Ensure legacy template entity functionality by setting percentage to None instead + # Ensure legacy template entity functionality by + # setting percentage to None instead # of the FanEntity default of 0. self._attr_percentage = None self.setup_template( diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 38465d26813..959fbcb0bc3 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -1,9 +1,6 @@ """Helpers for template integration.""" from collections.abc import Callable -from enum import StrEnum -import hashlib -import itertools import logging from typing import Any @@ -13,13 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, - CONF_ICON, - CONF_ICON_TEMPLATE, CONF_NAME, - CONF_PLATFORM, CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -27,48 +18,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -from homeassistant.helpers import issue_registry as ir, template +from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, + async_create_platform_config_not_supported_issue, async_get_platforms, ) -from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.script import async_validate_actions_config -from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import yaml as yaml_util -from homeassistant.util.hass_dict import HassKey +from homeassistant.util import slugify -from .const import ( - CONF_ADVANCED_OPTIONS, - CONF_ATTRIBUTE_TEMPLATES, - CONF_ATTRIBUTES, - CONF_AVAILABILITY, - CONF_AVAILABILITY_TEMPLATE, - CONF_DEFAULT_ENTITY_ID, - CONF_PICTURE, - DOMAIN, - PLATFORMS, -) +from .const import CONF_ADVANCED_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity -LEGACY_TEMPLATE_DEPRECATION_KEY = "deprecate_legacy_templates" - DATA_BLUEPRINTS = "template_blueprints" -DATA_DEPRECATION: HassKey[list[str]] = HassKey(LEGACY_TEMPLATE_DEPRECATION_KEY) - -LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, - CONF_FRIENDLY_NAME: CONF_NAME, -} _LOGGER = logging.getLogger(__name__) @@ -128,57 +96,6 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: ) -def rewrite_legacy_to_modern_config( - hass: HomeAssistant, - entity_cfg: dict[str, Any], - extra_legacy_fields: dict[str, str], -) -> dict[str, Any]: - """Rewrite legacy config.""" - entity_cfg = {**entity_cfg} - - # Remove deprecated entity_id field from legacy syntax - entity_cfg.pop(ATTR_ENTITY_ID, None) - - for from_key, to_key in itertools.chain( - LEGACY_FIELDS.items(), extra_legacy_fields.items() - ): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = template.Template(val, hass) - entity_cfg[to_key] = val - - if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): - entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) - - return entity_cfg - - -def rewrite_legacy_to_modern_configs( - hass: HomeAssistant, - domain: str, - entity_cfg: dict[str, dict], - extra_legacy_fields: dict[str, str], -) -> list[dict]: - """Rewrite legacy configuration definitions to modern ones.""" - entities = [] - for object_id, entity_conf in entity_cfg.items(): - entity_conf = {**entity_conf, CONF_DEFAULT_ENTITY_ID: f"{domain}.{object_id}"} - - entity_conf = rewrite_legacy_to_modern_config( - hass, entity_conf, extra_legacy_fields - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - entities.append(entity_conf) - - return entities - - @callback def async_create_template_tracking_entities( entity_cls: type[Entity], @@ -197,19 +114,6 @@ def async_create_template_tracking_entities( async_add_entities(entities) -def _format_template(value: Any, field: str | None = None) -> Any: - if isinstance(value, template.Template): - return value.template - - if isinstance(value, StrEnum): - return value.value - - if isinstance(value, (int, float, str, bool)): - return value - - return str(value) - - def _get_config_breadcrumbs(config: ConfigType) -> str: """Try to coerce entity information from the config.""" breadcrumb = "Template Entity" @@ -225,85 +129,6 @@ def _get_config_breadcrumbs(config: ConfigType) -> str: return breadcrumb -def format_migration_config( - config: ConfigType | list[ConfigType], depth: int = 0 -) -> ConfigType | list[ConfigType]: - """Recursive method to format templates as strings from ConfigType.""" - if depth > 9: - raise RecursionError - - if isinstance(config, list): - items = [] - for item in config: - if isinstance(item, (dict, list)): - if len(item) > 0: - items.append(format_migration_config(item, depth + 1)) - else: - items.append(_format_template(item)) - return items # type: ignore[return-value] - - formatted_config = {} - for field, value in config.items(): - if isinstance(value, dict): - if len(value) > 0: - formatted_config[field] = format_migration_config(value, depth + 1) - elif isinstance(value, list): - if len(value) > 0: - formatted_config[field] = format_migration_config(value, depth + 1) - else: - formatted_config[field] = [] - elif isinstance(value, ScriptVariables): - formatted_config[field] = format_migration_config( - value.as_dict(), depth + 1 - ) - else: - formatted_config[field] = _format_template(value) - - return formatted_config - - -def create_legacy_template_issue( - hass: HomeAssistant, config: ConfigType, domain: str -) -> None: - """Create a repair for legacy template entities.""" - if domain not in PLATFORMS: - return - - breadcrumb = _get_config_breadcrumbs(config) - - issue_id = f"{LEGACY_TEMPLATE_DEPRECATION_KEY}_{domain}_{breadcrumb}_{hashlib.md5(','.join(config.keys()).encode()).hexdigest()}" - - if (deprecation_list := hass.data.get(DATA_DEPRECATION)) is None: - hass.data[DATA_DEPRECATION] = deprecation_list = [] - - deprecation_list.append(issue_id) - - try: - config.pop(CONF_PLATFORM, None) - modified_yaml = format_migration_config(config) - yaml_config = ( - f"```\n{yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})}\n```" - ) - except RecursionError: - yaml_config = f"{DOMAIN}:\n - {domain}: - ..." - - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2026.6", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_templates", - translation_placeholders={ - "domain": domain, - "breadcrumb": breadcrumb, - "config": yaml_config, - "filename": "", - }, - ) - - async def validate_template_scripts( hass: HomeAssistant, config: ConfigType, @@ -337,6 +162,24 @@ async def validate_template_scripts( ) +def async_create_platform_template_not_supported_issue( + hass: HomeAssistant, domain: str +): + """Create a platform: template not supported issue.""" + learn_more_url = ( + "https://www.home-assistant.io/integrations/template/" + f"#{slugify(domain, separator='-')}" + ) + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + domain, + yaml_config_under_integration_supported=True, + learn_more_url=learn_more_url, + logger=_LOGGER, + ) + + async def async_setup_template_platform( hass: HomeAssistant, domain: str, @@ -345,35 +188,12 @@ async def async_setup_template_platform( trigger_entity_cls: type[TriggerEntity] | None, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None, - legacy_fields: dict[str, str] | None = None, - legacy_key: str | None = None, script_options: tuple[str, ...] | None = None, ) -> None: """Set up the Template platform.""" if discovery_info is None: # Legacy Configuration - if legacy_fields is not None: - if legacy_key: - configs = rewrite_legacy_to_modern_configs( - hass, domain, config[legacy_key], legacy_fields - ) - else: - configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)] - - for definition in configs: - create_legacy_template_issue(hass, definition, domain) - - async_create_template_tracking_entities( - state_entity_cls, - async_add_entities, - hass, - configs, - None, - ) - else: - _LOGGER.warning( - "Template %s entities can only be configured under template:", domain - ) + async_create_platform_template_not_supported_issue(hass, domain) return # Trigger Configuration diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 13388e90dcc..a83aafb86aa 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -1,7 +1,5 @@ """Support for image which integrates with other components.""" -from __future__ import annotations - import logging from typing import Any @@ -99,8 +97,11 @@ class AbstractTemplateImage(AbstractTemplateEntity, ImageEntity): _entity_id_format = ENTITY_ID_FORMAT _attr_image_url: str | None = None - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 0ff0df03e71..41ce19a0e44 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1,7 +1,5 @@ """Support for Template lights.""" -from __future__ import annotations - from collections.abc import Callable import contextlib import logging @@ -22,24 +20,13 @@ from homeassistant.components.light import ( DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EFFECT, - CONF_ENTITY_ID, - CONF_FRIENDLY_NAME, - CONF_LIGHTS, - CONF_NAME, - CONF_RGB, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_EFFECT, CONF_HS, CONF_NAME, CONF_RGB, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -59,7 +46,6 @@ from .helpers import ( ) from .schemas import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, - TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, ) @@ -70,63 +56,28 @@ _LOGGER = logging.getLogger(__name__) # Legacy ATTR_COLOR_TEMP = "color_temp" -CONF_COLOR_ACTION = "set_color" -CONF_COLOR_TEMPLATE = "color_template" -CONF_HS = "hs" CONF_HS_ACTION = "set_hs" -CONF_HS_TEMPLATE = "hs_template" CONF_RGB_ACTION = "set_rgb" -CONF_RGB_TEMPLATE = "rgb_template" CONF_RGBW = "rgbw" CONF_RGBW_ACTION = "set_rgbw" -CONF_RGBW_TEMPLATE = "rgbw_template" CONF_RGBWW = "rgbww" CONF_RGBWW_ACTION = "set_rgbww" -CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" -CONF_EFFECT_TEMPLATE = "effect_template" CONF_LEVEL = "level" CONF_LEVEL_ACTION = "set_level" -CONF_LEVEL_TEMPLATE = "level_template" CONF_MAX_MIREDS = "max_mireds" -CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template" CONF_MIN_MIREDS = "min_mireds" -CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" CONF_SUPPORTS_TRANSITION = "supports_transition" -CONF_SUPPORTS_TRANSITION_TEMPLATE = "supports_transition_template" CONF_TEMPERATURE_ACTION = "set_temperature" CONF_TEMPERATURE = "temperature" -CONF_TEMPERATURE_TEMPLATE = "temperature_template" -CONF_WHITE_VALUE_ACTION = "set_white_value" -CONF_WHITE_VALUE = "white_value" -CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LEGACY_FIELDS = { - CONF_COLOR_ACTION: CONF_HS_ACTION, - CONF_COLOR_TEMPLATE: CONF_HS, - CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, - CONF_EFFECT_TEMPLATE: CONF_EFFECT, - CONF_HS_TEMPLATE: CONF_HS, - CONF_LEVEL_TEMPLATE: CONF_LEVEL, - CONF_MAX_MIREDS_TEMPLATE: CONF_MAX_MIREDS, - CONF_MIN_MIREDS_TEMPLATE: CONF_MIN_MIREDS, - CONF_RGB_TEMPLATE: CONF_RGB, - CONF_RGBW_TEMPLATE: CONF_RGBW, - CONF_RGBWW_TEMPLATE: CONF_RGBWW, - CONF_SUPPORTS_TRANSITION_TEMPLATE: CONF_SUPPORTS_TRANSITION, - CONF_TEMPERATURE_TEMPLATE: CONF_TEMPERATURE, - CONF_VALUE_TEMPLATE: CONF_STATE, - CONF_WHITE_VALUE_TEMPLATE: CONF_WHITE_VALUE, -} - DEFAULT_NAME = "Template Light" SCRIPT_FIELDS = ( @@ -171,49 +122,6 @@ LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(LIGHT_DOMAIN, DEFAULT_NAME).schema) -LIGHT_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(CONF_ENTITY_ID), - vol.Schema( - { - vol.Exclusive(CONF_COLOR_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, - vol.Exclusive(CONF_COLOR_TEMPLATE, "hs_legacy_template"): cv.template, - vol.Exclusive(CONF_HS_ACTION, "hs_legacy_action"): cv.SCRIPT_SCHEMA, - vol.Exclusive(CONF_HS_TEMPLATE, "hs_legacy_template"): cv.template, - vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGB_TEMPLATE): cv.template, - vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBW_TEMPLATE): cv.template, - vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBWW_TEMPLATE): cv.template, - vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, - vol.Inclusive(CONF_EFFECT_LIST_TEMPLATE, "effect"): cv.template, - vol.Inclusive(CONF_EFFECT_TEMPLATE, "effect"): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, - vol.Optional(CONF_MAX_MIREDS_TEMPLATE): cv.template, - vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SUPPORTS_TRANSITION_TEMPLATE): cv.template, - vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), -) - -PLATFORM_SCHEMA = vol.All( - # CONF_WHITE_VALUE_* is deprecated, support will be removed in release 2022.9 - cv.removed(CONF_WHITE_VALUE_ACTION), - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_LEGACY_YAML_SCHEMA)} - ), -) - LIGHT_CONFIG_ENTRY_SCHEMA = LIGHT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -234,8 +142,6 @@ async def async_setup_platform( TriggerLightEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_LIGHTS, script_options=SCRIPT_FIELDS, ) @@ -361,8 +267,11 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__( # pylint: disable=super-init-not-called self, name: str, config: dict[str, Any] ) -> None: diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index cc527a4a050..988c5534ad4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,7 +1,5 @@ """Support for locks which integrates with other components.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -9,20 +7,12 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, LockState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_CODE, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import ATTR_CODE, CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv @@ -42,7 +32,6 @@ from .helpers import ( async_setup_template_preview, ) from .schemas import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, @@ -50,7 +39,6 @@ from .schemas import ( from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity -CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_CODE_FORMAT = "code_format" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" @@ -59,11 +47,6 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False -LEGACY_FIELDS = { - CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, - CONF_VALUE_TEMPLATE: CONF_STATE, -} - SCRIPT_FIELDS = ( CONF_LOCK, CONF_OPEN, @@ -85,19 +68,6 @@ LOCK_YAML_SCHEMA = LOCK_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA). make_template_entity_common_modern_schema(LOCK_DOMAIN, DEFAULT_NAME).schema ) -PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, - vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - } -).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) - LOCK_CONFIG_ENTRY_SCHEMA = LOCK_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -118,7 +88,6 @@ async def async_setup_platform( TriggerLockEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, script_options=SCRIPT_FIELDS, ) @@ -160,8 +129,11 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self._code_format_template_error: TemplateError | None = None diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 9dd62100917..a74db699b9e 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -1,7 +1,5 @@ """Support for numbers which integrates with other components.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -11,12 +9,18 @@ from homeassistant.components.number import ( DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASSES_SCHEMA, DOMAIN as NUMBER_DOMAIN, ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_STATE, + CONF_UNIT_OF_MEASUREMENT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -50,6 +54,7 @@ SCRIPT_FIELDS = (CONF_SET_VALUE,) NUMBER_COMMON_SCHEMA = vol.Schema( { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, @@ -120,10 +125,14 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity): _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE diff --git a/homeassistant/components/template/schemas.py b/homeassistant/components/template/schemas.py index 4dbee1b4fba..3309079c461 100644 --- a/homeassistant/components/template/schemas.py +++ b/homeassistant/components/template/schemas.py @@ -1,7 +1,5 @@ """Shared schemas for config entry and YAML config items.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 1eb7c77b40a..08143bb35e7 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -1,7 +1,5 @@ """Support for selects which integrates with other components.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any @@ -15,7 +13,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_OPTIONS, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -42,7 +40,6 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_OPTIONS = "options" CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" @@ -118,8 +115,11 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self._attr_options = [] diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index febde76c6b0..61358486d62 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -1,7 +1,5 @@ """Allows the creation of a sensor that breaks out state_attributes.""" -from __future__ import annotations - from collections.abc import Callable from datetime import date, datetime from decimal import Decimal @@ -16,7 +14,6 @@ from homeassistant.components.sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, @@ -24,18 +21,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, - CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON_TEMPLATE, - CONF_NAME, - CONF_SENSORS, CONF_STATE, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, - CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -56,8 +44,6 @@ from .helpers import ( async_setup_template_preview, ) from .schemas import ( - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, make_template_entity_common_modern_attributes_schema, ) @@ -66,11 +52,6 @@ from .trigger_entity import TriggerEntity DEFAULT_NAME = "Template Sensor" -LEGACY_FIELDS = { - CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_VALUE_TEMPLATE: CONF_STATE, -} - def validate_last_reset(val): """Run extra validation checks.""" @@ -113,29 +94,6 @@ SENSOR_CONFIG_ENTRY_SCHEMA = SENSOR_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -SENSOR_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(ATTR_ENTITY_ID), - vol.Schema( - { - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, - vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), -) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_LEGACY_YAML_SCHEMA)} -) - _LOGGER = logging.getLogger(__name__) @@ -154,8 +112,6 @@ async def async_setup_platform( TriggerSensorEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_SENSORS, ) @@ -193,7 +149,7 @@ def validate_datetime( """Converts the template result into a datetime or date.""" def convert(result: Any) -> datetime | date | None: - if resolve_as == SensorDeviceClass.TIMESTAMP: + if resolve_as in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): if isinstance(result, datetime): return result @@ -231,8 +187,11 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor): _entity_id_format = ENTITY_ID_FORMAT _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, config: ConfigType) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -265,6 +224,7 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor): if result is None or self.device_class not in ( SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ): return result diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 3bed24520b2..09c1770fcbf 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -136,6 +136,36 @@ }, "title": "Template cover" }, + "device_tracker": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "Zones", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.", + "latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.", + "longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.", + "name": "[%key:common::config_flow::data::name%]" + }, + "sections": { + "advanced_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "location_accuracy": "Location accuracy" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]", + "location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`." + }, + "name": "[%key:component::template::common::advanced_options%]" + } + }, + "title": "Template device tracker" + }, "event": { "data": { "device_class": "[%key:component::template::common::device_class%]", @@ -292,6 +322,7 @@ }, "number": { "data": { + "device_class": "[%key:component::template::common::device_class%]", "device_id": "[%key:common::config_flow::data::device%]", "max": "Maximum value", "min": "Minimum value", @@ -453,6 +484,7 @@ "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", "cover": "[%key:component::cover::title%]", + "device_tracker": "[%key:component::device_tracker::title%]", "event": "[%key:component::event::title%]", "fan": "[%key:component::fan::title%]", "image": "[%key:component::image::title%]", @@ -544,6 +576,9 @@ "exceptions": { "code_format_template_error": { "message": "Error evaluating code format template \"{code_format_template}\" for {entity_id}: {cause}" + }, + "failed_to_reload_template_entities": { + "message": "Error reloading template entities: {error}" } }, "issues": { @@ -558,10 +593,6 @@ "deprecated_battery_level": { "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name}).", "title": "Deprecated battery level option in {entity_name}" - }, - "deprecated_legacy_templates": { - "description": "The legacy `platform: template` syntax for `{domain}` is being removed. Please migrate `{breadcrumb}` to the modern template syntax.\n#### Step 1 - Remove legacy configuration\nRemove the `{breadcrumb}` template definition from the `configuration.yaml` `{domain}:` section.\n\n**Note:** If you are using `{domain}: !include {filename}.yaml` in `configuration.yaml`, remove the {domain} definition from the included `{filename}.yaml`.\n#### Step 2 - Add the modern configuration\nAdd new template definition inside `configuration.yaml`:\n{config}\n**Note:** If there are any existing `template:` sections in your configuration, make sure to omit the `template:` line from the yaml above. There can only be 1 `template:` section in `configuration.yaml`. Also, ensure the indentation is aligned with the existing entities within the `template:` section.\n#### Step 3 - Restart Home Assistant or reload template entities", - "title": "Legacy {domain} template deprecation" } }, "options": { @@ -608,6 +639,7 @@ }, "binary_sensor": { "data": { + "device_class": "[%key:component::template::common::device_class%]", "device_id": "[%key:common::config_flow::data::device%]", "state": "[%key:component::template::common::state%]" }, @@ -650,7 +682,6 @@ }, "title": "[%key:component::template::config::step::button::title%]" }, - "cover": { "data": { "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", @@ -683,6 +714,34 @@ }, "title": "[%key:component::template::config::step::cover::title%]" }, + "device_tracker": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]", + "latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]", + "longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]", + "latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]", + "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" + }, + "sections": { + "advanced_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]", + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]" + }, + "name": "[%key:component::template::common::advanced_options%]" + } + }, + "title": "[%key:component::template::config::step::device_tracker::title%]" + }, "event": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -835,6 +894,7 @@ }, "number": { "data": { + "device_class": "[%key:component::template::common::device_class%]", "device_id": "[%key:common::config_flow::data::device%]", "max": "[%key:component::template::config::step::number::data::max%]", "min": "[%key:component::template::config::step::number::data::min%]", @@ -1127,7 +1187,63 @@ "motion": "[%key:component::event::entity_component::motion::name%]" } }, - "sensor_device_class": { + "number_device_class": { + "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "sensor_device_class": { "options": { "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -1141,12 +1257,8 @@ "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", "current": "[%key:component::sensor::entity_component::current::name%]", "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", - "data_size": "[%key:component::sensor::entity_component::data_size::name%]", - "date": "[%key:component::sensor::entity_component::date::name%]", "distance": "[%key:component::sensor::entity_component::distance::name%]", - "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", - "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -1154,7 +1266,6 @@ "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", "moisture": "[%key:component::sensor::entity_component::moisture::name%]", - "monetary": "[%key:component::sensor::entity_component::monetary::name%]", "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", @@ -1177,7 +1288,6 @@ "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]", - "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 4689d96989d..0400552220b 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -1,7 +1,5 @@ """Support for switches which integrates with other components.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -9,18 +7,12 @@ import voluptuous as vol from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_NAME, CONF_STATE, - CONF_SWITCHES, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -44,17 +36,12 @@ from .helpers import ( ) from .schemas import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, - TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, ) from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity -LEGACY_FIELDS = { - CONF_VALUE_TEMPLATE: CONF_STATE, -} - DEFAULT_NAME = "Template Switch" SCRIPT_FIELDS = ( @@ -74,23 +61,6 @@ SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(SWITCH_DOMAIN, DEFAULT_NAME).schema) -SWITCH_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(ATTR_ENTITY_ID), - vol.Schema( - { - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), -) - -PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_LEGACY_YAML_SCHEMA)} -) SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema @@ -112,8 +82,6 @@ async def async_setup_platform( TriggerSwitchEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_SWITCHES, script_options=SCRIPT_FIELDS, ) @@ -157,8 +125,11 @@ class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index c98c740a9f3..ab4e3f207e6 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,7 +1,5 @@ """TemplateEntity utility class.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import contextlib import logging @@ -209,8 +207,10 @@ class TemplateEntity(AbstractTemplateEntity): CONF_AVAILABILITY, "_attr_available", on_update=self._update_available ) - # Render name, icon, and picture early. name is rendered early because it influences - # the entity_id. icon and picture are rendered early to ensure they are populated even + # Render name, icon, and picture early. name is + # rendered early because it influences the + # entity_id. icon and picture are rendered early + # to ensure they are populated even # if the entity renders unavailable. self._attr_name = None for option, attribute, validator in ( @@ -268,9 +268,11 @@ class TemplateEntity(AbstractTemplateEntity): """Create a this variable for the entity.""" entity_id = self.entity_id if self._preview_callback: - # During config flow, the registry entry and entity_id will be None. In this scenario, + # During config flow, the registry entry and + # entity_id will be None. In this scenario, # a temporary entity_id is created. - # During option flow, the preview entity_id will be None, however the registry entry + # During option flow, the preview entity_id + # will be None, however the registry entry # will contain the target entity_id. if self.registry_entry: entity_id = self.registry_entry.entity_id @@ -298,9 +300,11 @@ class TemplateEntity(AbstractTemplateEntity): ) -> None: """Set up a template that manages the main state of the entity. - Requires _state_option to be set on the inheriting class. _state_option represents - the configuration option that derives the state. E.g. Template weather entities main state option - is 'condition', where switch is 'state'. + Requires _state_option to be set on the inheriting + class. _state_option represents the configuration + option that derives the state. E.g. Template weather + entities main state option is 'condition', where + switch is 'state'. """ @callback @@ -326,7 +330,8 @@ class TemplateEntity(AbstractTemplateEntity): if self._state_option is None: raise NotImplementedError( - f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'." + f"{self.__class__.__name__} does not implement" + " '_state_option' for 'setup_state_template'." ) self.add_template( @@ -545,7 +550,8 @@ class TemplateEntity(AbstractTemplateEntity): """Suppress redundant template render errors. Preview entities render templates at least 3 times before the preview entity - is created. If template contains an error, each render will produce an error. + is created. If template contains an error, + each render will produce an error. Instead of overwhelming the client with errors, suppress them and raise a single error through the self._handle_results method. """ diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 134c42bded1..fcd044b585d 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -1,7 +1,5 @@ """Trigger entity.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any @@ -23,13 +21,15 @@ from . import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -class TriggerEntity( # pylint: disable=hass-enforce-class-module +class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator], AbstractTemplateEntity, ): """Template entity based on trigger data.""" + skip_rendered_result: tuple[str, ...] | None = None + def __init__( self, hass: HomeAssistant, @@ -45,6 +45,10 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module self._rendered_entity_variables: dict | None = None self._state_render_error = False + self._skip_rendered_result: list[str] = [] + if self.skip_rendered_result is not None: + self._skip_rendered_result.extend(self.skip_rendered_result) + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -66,13 +70,16 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module ) -> None: """Set up a template that manages the main state of the entity. - Requires _state_option to be set on the inheriting class. _state_option represents - the configuration option that derives the state. E.g. Template weather entities main state option - is 'condition', where switch is 'state'. + Requires _state_option to be set on the inheriting + class. _state_option represents the configuration + option that derives the state. E.g. Template weather + entities main state option is 'condition', where + switch is 'state'. """ if self._state_option is None: raise NotImplementedError( - f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'." + f"{self.__class__.__name__} does not implement" + " '_state_option' for 'setup_state_template'." ) if self.add_template( @@ -176,7 +183,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module rendered = dict(self._static_rendered) # If state fails to render, the entity should go unavailable. Render the - # state as a simple template because the result should always be a string or None. + # state as a simple template because the result + # should always be a string or None. if ( state_option := self._state_option ) is not None and state_option in self._to_render_simple: @@ -200,10 +208,14 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module # Handle any templates. write_state = False if self._state_render_error: - # The state errored and the entity is unavailable, do not process any values. + # The state errored and the entity is unavailable, + # do not process any values. return True for option, entity_template in self._templates.items(): + if option in self._skip_rendered_result: + continue + # Capture templates that did not render a result due to an exception and # ensure the state object updates. _SENTINEL is used to differentiate # templates that render None. diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index e06c6ccfb0d..0b6b9d3a701 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -1,7 +1,5 @@ """Support for updates which integrates with other components.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any @@ -146,8 +144,11 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): _entity_id_format = ENTITY_ID_FORMAT - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" @@ -267,9 +268,12 @@ class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate): """Return the entity picture to use in the frontend.""" # This is needed to override the base update entity functionality if self._attr_entity_picture is None: - # The default picture for update entities would use `self.platform.platform_name` in - # place of `template`. This does not work when creating an entity preview because - # the platform does not exist for that entity, therefore this is hardcoded as `template`. + # The default picture for update entities would + # use `self.platform.platform_name` in place of + # `template`. This does not work when creating + # an entity preview because the platform does + # not exist for that entity, therefore this is + # hardcoded as `template`. return "/api/brands/integration/template/icon.png" return self._attr_entity_picture diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f06ae13141b..0fed7430a43 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -1,7 +1,6 @@ """Support for Template vacuums.""" -from __future__ import annotations - +from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -17,19 +16,13 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ENTITY_ID, - CONF_FRIENDLY_NAME, - CONF_NAME, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import ( @@ -48,8 +41,6 @@ from .helpers import ( async_setup_template_preview, ) from .schemas import ( - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_attributes_schema, @@ -59,24 +50,19 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_VACUUMS = "vacuums" CONF_BATTERY_LEVEL = "battery_level" -CONF_BATTERY_LEVEL_TEMPLATE = "battery_level_template" -CONF_FAN_SPEED_LIST = "fan_speeds" +CONF_CLEAN_SEGMENTS = "clean_segments" CONF_FAN_SPEED = "fan_speed" -CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +CONF_FAN_SPEED_LIST = "fan_speeds" +CONF_SEGMENTS = "segments" +CONF_VACUUMS = "vacuums" DEFAULT_NAME = "Template Vacuum" ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" -LEGACY_FIELDS = { - CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, - CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, - CONF_VALUE_TEMPLATE: CONF_STATE, -} - SCRIPT_FIELDS = ( + CONF_CLEAN_SEGMENTS, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -86,12 +72,19 @@ SCRIPT_FIELDS = ( SERVICE_STOP, ) +CLEAN_AREA_GROUP = "clean_area_group" + VACUUM_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, vol.Optional(CONF_STATE): cv.template, + vol.Inclusive( + CONF_SEGMENTS, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS}` and `{CONF_CLEAN_SEGMENTS}` must both exist", + ): cv.template, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -99,43 +92,23 @@ VACUUM_COMMON_SCHEMA = vol.Schema( vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + vol.Inclusive( + CONF_CLEAN_SEGMENTS, + CLEAN_AREA_GROUP, + f"Options `{CONF_SEGMENTS}` and `{CONF_CLEAN_SEGMENTS}` must both exist", + ): cv.SCRIPT_SCHEMA, } ) -VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( - TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA -).extend( - make_template_entity_common_modern_attributes_schema( - VACUUM_DOMAIN, DEFAULT_NAME - ).schema -) -VACUUM_LEGACY_YAML_SCHEMA = vol.All( - cv.deprecated(CONF_ENTITY_ID), - vol.Schema( - { - vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), -) - -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} +VACUUM_YAML_SCHEMA = vol.All( + VACUUM_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_attributes_schema( + VACUUM_DOMAIN, DEFAULT_NAME + ).schema + ), + cv.key_dependency(CONF_SEGMENTS, CONF_UNIQUE_ID), + cv.key_dependency(CONF_CLEAN_SEGMENTS, CONF_UNIQUE_ID), ) VACUUM_CONFIG_ENTRY_SCHEMA = VACUUM_COMMON_SCHEMA.extend( @@ -158,8 +131,6 @@ async def async_setup_platform( TriggerVacuumEntity, async_add_entities, discovery_info, - LEGACY_FIELDS, - legacy_key=CONF_VACUUMS, script_options=SCRIPT_FIELDS, ) @@ -214,6 +185,59 @@ def create_issue( ) +def validate_segments( + entity: AbstractTemplateVacuum, + option: str, +) -> Callable[[Any], list[Segment] | None]: + """Parse segment template to list of segments.""" + + def parse(result: Any) -> list[Segment] | None: + if template_validators.check_result_for_none(result): + return None + + segments: list[Segment] = [] + + if not isinstance(result, list): + template_validators.log_validation_result_error( + entity, + option, + result, + "expected a list of dictionaries", + ) + return None + + for item in result: + if not isinstance(item, dict): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + if ( + not isinstance(item.get("id"), str) + or not isinstance(item.get("name"), str) + or ("group" in item and not isinstance(item["group"], str)) + or not set(item).issubset({"id", "name", "group"}) + ): + template_validators.log_validation_result_error( + entity, + option, + item, + "expected dictionary with keys id, name and optional group" + " and string values", + ) + return None + + segments.append(Segment(**item)) + return segments + + return parse + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -221,13 +245,17 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): _optimistic_entity = True _state_option = CONF_STATE - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" # List of valid fan speeds self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] + self._segments: list[Segment] = [] self.setup_state_template( "_attr_activity", template_validators.strenum(self, CONF_STATE, VacuumActivity), @@ -245,6 +273,13 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0), ) + self.setup_template( + CONF_SEGMENTS, + "_segments", + validate_segments(self, CONF_SEGMENTS), + self._update_segments, + ) + self._attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE ) @@ -260,11 +295,41 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + (CONF_CLEAN_SEGMENTS, VacuumEntityFeature.CLEAN_AREA), ): if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + @callback + def _update_segments(self, result: list[Segment] | None) -> None: + """Save segment templates and create issue when segments changed.""" + if result is None: + return + + self._segments = result + + if (last_seen := self.last_seen_segments) is not None and { + s.id: s for s in last_seen + } != {s.id: s for s in self._segments}: + self.async_create_segments_issue() + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() + if script := self._action_scripts.get(CONF_CLEAN_SEGMENTS): + await self.async_run_script( + script, + run_variables={"segment_ids": segment_ids}, + context=self._context, + ) + async def async_start(self) -> None: """Start or resume the cleaning task.""" if self._attr_assumed_state: diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 45b8a578b89..3c16c43a69e 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,7 +1,5 @@ """Template platform that aggregates meteorological data.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import asdict, dataclass import logging @@ -27,21 +25,24 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY_VARIANT, DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, - PLATFORM_SCHEMA as WEATHER_PLATFORM_SCHEMA, Forecast, WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_CONDITION, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ICON_TEMPLATE, CONF_NAME, CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -56,12 +57,13 @@ from homeassistant.util.unit_conversion import ( ) from . import TriggerUpdateCoordinator, validators as template_validators +from .const import CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE from .entity import AbstractTemplateEntity from .helpers import ( + async_create_platform_template_not_supported_issue, async_setup_template_entry, async_setup_template_platform, async_setup_template_preview, - rewrite_legacy_to_modern_config, ) from .schemas import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, @@ -104,7 +106,6 @@ CONF_ATTRIBUTION = "attribution" CONF_ATTRIBUTION_TEMPLATE = "attribution_template" CONF_CLOUD_COVERAGE = "cloud_coverage" CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" -CONF_CONDITION = "condition" CONF_CONDITION_TEMPLATE = "condition_template" CONF_DEW_POINT = "dew_point" CONF_DEW_POINT_TEMPLATE = "dew_point_template" @@ -140,7 +141,11 @@ CONF_WIND_SPEED_UNIT = "wind_speed_unit" DEFAULT_NAME = "Template Weather" -LEGACY_FIELDS = { +LEGACY_OPTIONS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_FRIENDLY_NAME: CONF_NAME, CONF_APPARENT_TEMPERATURE_TEMPLATE: CONF_APPARENT_TEMPERATURE, CONF_ATTRIBUTION_TEMPLATE: CONF_ATTRIBUTION, CONF_CLOUD_COVERAGE_TEMPLATE: CONF_CLOUD_COVERAGE, @@ -232,23 +237,33 @@ WEATHER_MODERN_YAML_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend( make_template_entity_common_modern_schema(WEATHER_DOMAIN, DEFAULT_NAME).schema ) -PLATFORM_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(WEATHER_COMMON_LEGACY_SCHEMA.schema) - .extend(WEATHER_PLATFORM_SCHEMA.schema) -) - - WEATHER_CONFIG_ENTRY_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) +def rewrite_legacy_options_to_modern_options( + hass: HomeAssistant, + entity_cfg: dict[str, Any], +) -> dict[str, Any]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + + for from_key, to_key in LEGACY_OPTIONS.items(): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val, hass) + entity_cfg[to_key] = val + + if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): + entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) + + return entity_cfg + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -259,21 +274,19 @@ async def async_setup_platform( # Rewrite the configuration options to modern keys. if discovery_info is None: - # Legacy - config = rewrite_legacy_to_modern_config(hass, config, LEGACY_FIELDS) - else: - # Modern and Trigger - entity_configs: list[ConfigType] = discovery_info["entities"] - modified_entity_configs = [] - for entity_config in entity_configs: - entity_config = rewrite_legacy_to_modern_config( - hass, entity_config, LEGACY_FIELDS - ) + async_create_platform_template_not_supported_issue(hass, WEATHER_DOMAIN) + return - modified_entity_configs.append(entity_config) + # Modern and Trigger + entity_configs: list[ConfigType] = discovery_info["entities"] + modified_entity_configs = [] + for entity_config in entity_configs: + entity_config = rewrite_legacy_options_to_modern_options(hass, entity_config) - if modified_entity_configs: - discovery_info["entities"] = modified_entity_configs + modified_entity_configs.append(entity_config) + + if modified_entity_configs: + discovery_info["entities"] = modified_entity_configs await async_setup_template_platform( hass, @@ -283,7 +296,6 @@ async def async_setup_platform( TriggerWeatherEntity, async_add_entities, discovery_info, - {}, ) @@ -347,7 +359,9 @@ def validate_forecast( entity, option, result, - f"expected a list of forecast dictionaries, got {forecast}, {weather_message}", + "expected a list of forecast" + f" dictionaries, got {forecast}," + f" {weather_message}", ) continue @@ -358,7 +372,9 @@ def validate_forecast( entity, option, result, - f"expected valid forecast keys, unallowed keys: ({diff_result}) for {forecast}, {weather_message}", + "expected valid forecast keys," + f" unallowed keys: ({diff_result})" + f" for {forecast}, {weather_message}", ) if forecast_type == "twice_daily" and "is_daytime" not in forecast: raised = True @@ -366,7 +382,9 @@ def validate_forecast( entity, option, result, - f"`is_daytime` is missing in twice_daily forecast {forecast}, {weather_message}", + "`is_daytime` is missing in" + f" twice_daily forecast {forecast}," + f" {weather_message}", ) if "datetime" not in forecast: raised = True @@ -374,7 +392,8 @@ def validate_forecast( entity, option, result, - f"`datetime` is missing in forecast, got {forecast}, {weather_message}", + "`datetime` is missing in forecast," + f" got {forecast}, {weather_message}", ) if raised: @@ -392,8 +411,11 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity): _state_option = CONF_CONDITION _optimistic_entity = True - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. def __init__( # pylint: disable=super-init-not-called self, config: dict[str, Any] ) -> None: diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 5ea9ebc040f..1ee87e07bba 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -244,7 +244,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) # Create the energy site device regardless of it having entities - # This is so users with a Wall Connector but without a Powerwall can still make service calls + # This is so users with a Wall Connector but + # without a Powerwall can still make service calls device_registry.async_get_or_create( config_entry_id=entry.entry_id, **device ) diff --git a/homeassistant/components/tesla_fleet/binary_sensor.py b/homeassistant/components/tesla_fleet/binary_sensor.py index 886fe304c91..28d43ce80e4 100644 --- a/homeassistant/components/tesla_fleet/binary_sensor.py +++ b/homeassistant/components/tesla_fleet/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index 2ddce2d517b..6602d6d196f 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -1,7 +1,5 @@ """Button platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 627f412a673..0ba7d1f4825 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -1,7 +1,5 @@ """Climate platform for Tesla Fleet integration.""" -from __future__ import annotations - from itertools import chain from typing import Any, cast @@ -104,7 +102,8 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): else: self._attr_hvac_mode = HVACMode.OFF - # If not scoped, prevent the user from changing the HVAC mode by making it the only option + # If not scoped, prevent the user from changing the + # HVAC mode by making it the only option if self._attr_hvac_mode and self.read_only: self._attr_hvac_modes = [self._attr_hvac_mode] @@ -245,7 +244,8 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn else: self._attr_hvac_mode = COP_MODES.get(state) - # If not scoped, prevent the user from changing the HVAC mode by making it the only option + # If not scoped, prevent the user from changing the + # HVAC mode by making it the only option if self._attr_hvac_mode and self.read_only: self._attr_hvac_modes = [self._attr_hvac_mode] diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index 14c197bc7c5..37434022467 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import re @@ -20,6 +18,7 @@ from tesla_fleet_api.exceptions import ( import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_DOMAIN from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -28,7 +27,7 @@ from homeassistant.helpers.selector import ( QrErrorCorrectionLevel, ) -from .const import CONF_DOMAIN, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER from .oauth import TeslaUserImplementation diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 761bbebf7a8..aa83a77f5c6 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -1,7 +1,5 @@ """Constants used by Tesla Fleet integration.""" -from __future__ import annotations - from enum import StrEnum import logging @@ -9,7 +7,6 @@ from tesla_fleet_api.const import Scope DOMAIN = "tesla_fleet" -CONF_DOMAIN = "domain" CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 397c11c524d..2457f98e926 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,7 +1,5 @@ """Tesla Fleet Data Coordinator.""" -from __future__ import annotations - from datetime import datetime, timedelta from random import randint from time import time @@ -115,7 +113,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.endpoints = ( ENDPOINTS if location - else [ep for ep in ENDPOINTS if ep != VehicleDataEndpoint.LOCATION_DATA] + else [ep for ep in ENDPOINTS if ep is not VehicleDataEndpoint.LOCATION_DATA] ) async def _async_update_data(self) -> dict[str, Any]: @@ -247,7 +245,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" + """Manage fetching energy site history from the Tesla Fleet API.""" config_entry: TeslaFleetConfigEntry diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 701b107f9f9..a25802c01cc 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -1,7 +1,5 @@ """Cover platform for Tesla Fleet integration.""" -from __future__ import annotations - from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index a2479d72dcb..4ac2042822d 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -1,10 +1,7 @@ """Device Tracker platform for Tesla Fleet integration.""" -from __future__ import annotations - from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -85,11 +82,3 @@ class TeslaFleetDeviceTrackerRouteEntity(TeslaFleetDeviceTrackerEntity): self.get("drive_state_active_route_longitude", False) is None or self.get("drive_state_active_route_latitude", False) is None ) - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - location = self.get("drive_state_active_route_destination") - if location == "Home": - return STATE_HOME - return location diff --git a/homeassistant/components/tesla_fleet/diagnostics.py b/homeassistant/components/tesla_fleet/diagnostics.py index 0dc4cddbfc9..d2b94fd62fd 100644 --- a/homeassistant/components/tesla_fleet/diagnostics.py +++ b/homeassistant/components/tesla_fleet/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Tesla Fleet.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 833d6988cc5..7d3f2596713 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -1,7 +1,7 @@ """Tesla Fleet parent entity class.""" from abc import abstractmethod -from typing import Any, Generic, TypeVar +from typing import Any from tesla_fleet_api.const import Scope from tesla_fleet_api.tesla.energysite import EnergySite @@ -21,17 +21,14 @@ from .coordinator import ( from .helpers import wake_up_vehicle from .models import TeslaFleetEnergyData, TeslaFleetVehicleData -_ApiT = TypeVar("_ApiT", bound=VehicleFleet | EnergySite) - -class TeslaFleetEntity( +class TeslaFleetEntity[_ApiT: VehicleFleet | EnergySite]( CoordinatorEntity[ TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator - ], - Generic[_ApiT], + ] ): """Parent class for all TeslaFleet entities.""" diff --git a/homeassistant/components/tesla_fleet/lock.py b/homeassistant/components/tesla_fleet/lock.py index cdb1d4b066b..bdd257b0cd0 100644 --- a/homeassistant/components/tesla_fleet/lock.py +++ b/homeassistant/components/tesla_fleet/lock.py @@ -1,7 +1,5 @@ """Lock platform for Tesla Fleet integration.""" -from __future__ import annotations - from typing import Any from tesla_fleet_api.const import Scope @@ -89,7 +87,6 @@ class TeslaFleetCableLockEntity(TeslaFleetVehicleEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" raise ServiceValidationError( - "Insert cable to lock", translation_domain=DOMAIN, translation_key="no_cable", ) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 4b4ff818ffc..dfab47d2f69 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.4.5"] + "requirements": ["tesla-fleet-api==1.4.7"] } diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 89f0768f082..f22e6905f0c 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -1,7 +1,5 @@ """Media player platform for Tesla Fleet integration.""" -from __future__ import annotations - from tesla_fleet_api.const import Scope from homeassistant.components.media_player import ( diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index 17a2bf50ed1..d81ceada696 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -1,7 +1,5 @@ """The Tesla Fleet integration models.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index 48c28cbb022..ceccfbdb3ca 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -1,7 +1,5 @@ """Number platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/tesla_fleet/quality_scale.yaml b/homeassistant/components/tesla_fleet/quality_scale.yaml new file mode 100644 index 00000000000..6a1c5bb8715 --- /dev/null +++ b/homeassistant/components/tesla_fleet/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom service actions in async_setup. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: Review feedback requested stronger config flow test coverage for domain registration errors and removal of unnecessary translation mocking where possible. + config-flow: + status: todo + comment: Review feedback questioned whether the custom OAuth flow implementation can be simplified, including whether `CONFIG_SCHEMA` and the custom `OAuth2FlowHandler` are both needed. + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom service actions beyond standard entity services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: todo + comment: Coordinator raises UpdateFailed but does not implement the full log-once pattern (log when becoming unavailable, log when recovering). + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: todo + comment: Review feedback requested test cleanup follow-ups, including patching API objects where they are used, preferring direct asserts over the `test_climate_offline` snapshot where appropriate, removing an unnecessary `async_setup_component(...)`, and avoiding direct `entry.runtime_data` assertions in unload tests. + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration does not implement device discovery mechanisms. + discovery: + status: exempt + comment: Tesla Fleet API requires OAuth authentication and cannot be automatically discovered. + docs-data-update: done + docs-examples: + status: todo + comment: Documentation includes NGINX configuration examples but lacks automation use case examples. + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: + status: todo + comment: Documentation does not include explicit use case scenarios. + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: PR already raised to fix these + icon-translations: done + reconfiguration-flow: + status: todo + comment: Integration does not implement async_step_reconfigure for updating settings without removal. + repair-issues: + status: exempt + comment: Integration does not have scenarios requiring user-actionable repair issues. + stale-devices: + status: todo + comment: Integration does not automatically remove devices that are no longer present in the Tesla account. + # Platinum tier + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: Integration is not yet registered in .strict-typing file and needs comprehensive type annotation review. diff --git a/homeassistant/components/tesla_fleet/select.py b/homeassistant/components/tesla_fleet/select.py index 1c495657bc1..cf6b02721b9 100644 --- a/homeassistant/components/tesla_fleet/select.py +++ b/homeassistant/components/tesla_fleet/select.py @@ -1,7 +1,5 @@ """Select platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index fefb03a97ba..0603e89ca88 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -282,6 +280,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, ), + TeslaFleetSensorEntityDescription( + key="drive_state_active_route_destination", + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 4c64acfafa6..d6048c12fe4 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -1,7 +1,5 @@ """Switch platform for Tesla Fleet integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/tesla_fleet/update.py b/homeassistant/components/tesla_fleet/update.py index 75d1a93f28e..a9fb5248243 100644 --- a/homeassistant/components/tesla_fleet/update.py +++ b/homeassistant/components/tesla_fleet/update.py @@ -1,7 +1,5 @@ """Update platform for Tesla Fleet integration.""" -from __future__ import annotations - import time from typing import Any diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index f6809c4f416..480441bf46b 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -1,25 +1,27 @@ """The Tesla Wall Connector integration.""" -from __future__ import annotations - from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WallConnectorCoordinator, WallConnectorData, get_poll_interval +from .coordinator import ( + WallConnectorConfigEntry, + WallConnectorCoordinator, + WallConnectorData, + get_poll_interval, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: WallConnectorConfigEntry +) -> bool: """Set up Tesla Wall Connector from a config entry.""" - hass.data.setdefault(DOMAIN, {}) hostname = entry.data[CONF_HOST] wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) @@ -32,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = WallConnectorCoordinator(hass, entry, hostname, wall_connector) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = WallConnectorData( + entry.runtime_data = WallConnectorData( wall_connector_client=wall_connector, hostname=hostname, part_number=version_data.part_number, @@ -48,15 +50,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: WallConnectorConfigEntry) -> None: """Handle options update.""" - wall_connector_data: WallConnectorData = hass.data[DOMAIN][entry.entry_id] - wall_connector_data.update_coordinator.update_interval = get_poll_interval(entry) + entry.runtime_data.update_coordinator.update_interval = get_poll_interval(entry) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: WallConnectorConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index a1781c8d8fb..7d8c681a384 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -8,13 +8,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS -from .coordinator import WallConnectorData +from .const import WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) @@ -47,11 +46,11 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WallConnectorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" - wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + wall_connector_data = config_entry.runtime_data all_entities = [ WallConnectorBinarySensorEntity(wall_connector_data, description) diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index d100b1e5549..40caafc5bb3 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tesla Wall Connector integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index bc43a0581dc..0a74f6c290c 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Tesla Wall Connector integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging @@ -26,6 +24,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type WallConnectorConfigEntry = ConfigEntry[WallConnectorData] + @dataclass class WallConnectorData: @@ -49,12 +49,12 @@ def get_poll_interval(entry: ConfigEntry) -> timedelta: class WallConnectorCoordinator(DataUpdateCoordinator[dict]): """Class to manage fetching Tesla Wall Connector data.""" - config_entry: ConfigEntry + config_entry: WallConnectorConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: WallConnectorConfigEntry, hostname: str, wall_connector: WallConnector, ) -> None: diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py index 1dea2d0baa1..2b2442466ae 100644 --- a/homeassistant/components/tesla_wall_connector/entity.py +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -1,7 +1,5 @@ """The Tesla Wall Connector integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 8a57bb7c2f4..7cd1059a8a2 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, @@ -22,8 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS -from .coordinator import WallConnectorData +from .const import WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) @@ -196,11 +195,11 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WallConnectorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" - wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + wall_connector_data = config_entry.runtime_data all_entities = [ WallConnectorSensorEntity(wall_connector_data, description) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 2c00094b40b..eb99d2bb2bd 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -262,7 +262,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - device = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", + configuration_url=f"https://teslemetry.com/console/vehicle/{vin}", name=product["display_name"], model=vehicle.model, model_id=vin[3], @@ -324,7 +324,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", + configuration_url=f"https://teslemetry.com/console/energy/{site_id}", name=product.get("site_name", "Energy Site"), serial_number=str(site_id), ) @@ -514,7 +514,7 @@ def async_setup_energy_device( *data.get("components_gateways", []), *data.get("components_batteries", []), ): - if part_name := component.get("part_name"): + if (part_name := component.get("part_name")) and part_name != "Unknown": models.add(part_name) if models: energysite.device["model"] = ", ".join(sorted(models)) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 165807ff495..0fdf2b89e35 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index 12772b894b6..83831755e35 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -1,7 +1,5 @@ """Button platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/teslemetry/calendar.py b/homeassistant/components/teslemetry/calendar.py index 71877344129..c83ea7ac62e 100644 --- a/homeassistant/components/teslemetry/calendar.py +++ b/homeassistant/components/teslemetry/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for Teslemetry integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index a82a712ec72..62aa9a5debc 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -1,7 +1,5 @@ """Climate platform for Teslemetry integration.""" -from __future__ import annotations - from itertools import chain from typing import Any, cast @@ -96,7 +94,6 @@ class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] _attr_preset_modes = list(PRESET_MODES.values()) _attr_fan_modes = ["off", "bioweapon"] - _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index b1788df589e..1353ac114f2 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index a66f2dfcae8..42a691aa5b5 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -1,7 +1,5 @@ """Constants used by Teslemetry integration.""" -from __future__ import annotations - from enum import StrEnum import logging diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 11d6a95d796..2d630446acc 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,7 +1,5 @@ """Teslemetry Data Coordinator.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any @@ -10,6 +8,7 @@ from tesla_fleet_api.exceptions import ( GatewayTimeout, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, ServiceUnavailable, SubscriptionRequired, @@ -85,9 +84,10 @@ class TeslemetryMetadataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Fetch latest metadata for subscription status.""" try: data = await self.teslemetry.metadata() - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -95,6 +95,7 @@ class TeslemetryMetadataCoordinator(DataUpdateCoordinator[dict[str, Any]]): retry_after=_get_retry_after(e), ) from e except TeslaFleetError as e: + # pylint: disable-next=home-assistant-exception-placeholder-mismatch raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -136,7 +137,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Update vehicle data using Teslemetry API.""" try: data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -186,7 +187,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) """Update energy site data using Teslemetry API.""" try: data: dict[str, Any] = (await self.api.live_status())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -233,7 +234,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) """Update energy site data using Teslemetry API.""" try: data = (await self.api.site_info())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -279,7 +280,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Update energy site data using Teslemetry API.""" try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index ac683b7497d..0597c5f2dd5 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -1,7 +1,5 @@ """Cover platform for Teslemetry integration.""" -from __future__ import annotations - from itertools import chain from typing import Any @@ -328,7 +326,8 @@ class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): self._attr_is_closed = False self.async_write_ha_state() - # In the future this could be extended to add aftermarket close support through a option flow + # In the future this could be extended to add + # aftermarket close support through an option flow class TeslemetryVehiclePollingFrontTrunkEntity( diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 84be1d742dc..4c4323db2fb 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,7 +11,6 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) -from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -33,12 +30,6 @@ class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription): [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]], Callable[[], None], ] - name_listener: ( - Callable[ - [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None] - ] - | None - ) = None streaming_firmware: str polling_prefix: str | None = None @@ -56,9 +47,6 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( value_listener=lambda vehicle, callback: vehicle.listen_DestinationLocation( callback ), - name_listener=lambda vehicle, callback: vehicle.listen_DestinationName( - callback - ), streaming_firmware="2024.26", ), TeslemetryDeviceTrackerEntityDescription( @@ -128,11 +116,6 @@ class TeslemetryVehiclePollingDeviceTrackerEntity( self._attr_longitude = self.get( f"{self.entity_description.polling_prefix}_longitude" ) - self._attr_location_name = self.get( - f"{self.entity_description.polling_prefix}_destination" - ) - if self._attr_location_name == "Home": - self._attr_location_name = STATE_HOME self._attr_available = ( self._attr_latitude is not None and self._attr_longitude is not None ) @@ -160,28 +143,14 @@ class TeslemetryStreamingDeviceTrackerEntity( if (state := await self.async_get_last_state()) is not None: self._attr_latitude = state.attributes.get("latitude") self._attr_longitude = state.attributes.get("longitude") - self._attr_location_name = state.attributes.get("location_name") self.async_on_remove( self.entity_description.value_listener( self.vehicle.stream_vehicle, self._location_callback ) ) - if self.entity_description.name_listener: - self.async_on_remove( - self.entity_description.name_listener( - self.vehicle.stream_vehicle, self._name_callback - ) - ) def _location_callback(self, location: TeslaLocation | None) -> None: """Update the value of the entity.""" self._attr_latitude = None if location is None else location.latitude self._attr_longitude = None if location is None else location.longitude self.async_write_ha_state() - - def _name_callback(self, name: str | None) -> None: - """Update the value of the entity.""" - self._attr_location_name = name - if self._attr_location_name == "Home": - self._attr_location_name = STATE_HOME - self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index 755935951fc..57e0b571a30 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Teslemetry.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 5065c4649d8..3c4cadd5d57 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -497,7 +497,7 @@ "default": "mdi:battery-clock" }, "forward_collision_warning": { - "default": "mdi:car-crash", + "default": "mdi:car-emergency", "state": { "average": "mdi:alert-circle", "early": "mdi:alert-octagon", @@ -634,7 +634,7 @@ "default": "mdi:key" }, "pedal_position": { - "default": "mdi:pedestal" + "default": "mdi:gauge" }, "powershare_hours_left": { "default": "mdi:clock-time-eight-outline" @@ -794,7 +794,7 @@ "service": "mdi:calendar-plus" }, "add_precondition_schedule": { - "service": "mdi:hvac-outline" + "service": "mdi:hvac" }, "navigation_gps_request": { "service": "mdi:crosshairs-gps" @@ -803,7 +803,7 @@ "service": "mdi:calendar-minus" }, "remove_precondition_schedule": { - "service": "mdi:hvac-off-outline" + "service": "mdi:hvac-off" }, "set_scheduled_charging": { "service": "mdi:timeline-clock-outline" diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 7e98d6338ba..a92c7bb8e69 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -1,7 +1,5 @@ """Lock platform for Teslemetry integration.""" -from __future__ import annotations - from itertools import chain from typing import Any @@ -142,6 +140,7 @@ class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" + # pylint: disable-next=home-assistant-exception-message-with-translation raise ServiceValidationError( "Insert cable to lock", translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index ca7b1c83354..c2397d30741 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.4.5", "teslemetry-stream==0.9.0"] + "requirements": ["tesla-fleet-api==1.4.7", "teslemetry-stream==0.9.0"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 9ffc02e4307..3c825b6a5c8 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -1,7 +1,5 @@ """Media player platform for Teslemetry integration.""" -from __future__ import annotations - from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 534e4a1bb67..2ac86c86af3 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -1,7 +1,5 @@ """The Teslemetry integration models.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, field diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index beeaf364b19..46c288eeb67 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -1,7 +1,5 @@ """Number platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/teslemetry/oauth.py b/homeassistant/components/teslemetry/oauth.py index f96a3c277a9..c2949afd8bf 100644 --- a/homeassistant/components/teslemetry/oauth.py +++ b/homeassistant/components/teslemetry/oauth.py @@ -1,7 +1,5 @@ """Provide oauth implementations for the Teslemetry integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 9139feb9818..ec98b20f498 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -1,7 +1,5 @@ """Select platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 54e463721cd..6581731bf64 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -512,6 +510,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TeslemetryVehicleSensorEntityDescription( + key="drive_state_active_route_destination", + polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_DestinationName( + callback + ), + entity_registry_enabled_default=False, + ), TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", polling=True, diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 53c7c52ac2b..d72fbc6f888 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -18,16 +18,19 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData _LOGGER = logging.getLogger(__name__) # Attributes +# pylint: disable-next=home-assistant-duplicate-const ATTR_ID = "id" ATTR_GPS = "gps" ATTR_TYPE = "type" ATTR_VALUE = "value" +# pylint: disable-next=home-assistant-duplicate-const ATTR_LOCATION = "location" ATTR_LOCALE = "locale" ATTR_ORDER = "order" ATTR_TIMESTAMP = "timestamp" ATTR_FIELDS = "fields" ATTR_ENABLE = "enable" +# pylint: disable-next=home-assistant-duplicate-const ATTR_TIME = "time" ATTR_PIN = "pin" ATTR_TOU_SETTINGS = "tou_settings" @@ -41,6 +44,7 @@ ATTR_DAYS_OF_WEEK = "days_of_week" ATTR_START_TIME = "start_time" ATTR_END_TIME = "end_time" ATTR_ONE_TIME = "one_time" +# pylint: disable-next=home-assistant-duplicate-const ATTR_NAME = "name" ATTR_PRECONDITION_TIME = "precondition_time" @@ -345,7 +349,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] - # If days_of_week is a list (from select with multiple), convert to comma-separated string + # If days_of_week is a list (from select with + # multiple), convert to comma-separated string if isinstance(days_of_week, list): days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] @@ -445,7 +450,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] - # If days_of_week is a list (from select with multiple), convert to comma-separated string + # If days_of_week is a list (from select with + # multiple), convert to comma-separated string if isinstance(days_of_week, list): days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index bfe2ed08eeb..05397037bb7 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -1,7 +1,5 @@ """Switch platform for Teslemetry integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -142,7 +140,6 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="guest_mode_enabled", - polling=False, unique_id="guest_mode_enabled", streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled( callback diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index d0e1d271636..b95d887719a 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -1,7 +1,5 @@ """Update platform for Teslemetry integration.""" -from __future__ import annotations - from typing import Any from tesla_fleet_api.const import Scope diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a9a7406e5d6..684a5eb9336 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -91,6 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo vehicle_api = tessie.vehicles.create(vin) vehicles.append( TessieVehicleData( + api=vehicle_api, vin=vin, data_coordinator=TessieStateUpdateCoordinator( hass, diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 51a5c33b0d8..92906e53e2d 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Tessie integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from itertools import chain @@ -16,8 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry -from .const import TessieState +from .const import TessieChargeStates, TessieState from .entity import TessieEnergyEntity, TessieEntity +from .helpers import charge_state_to_option from .models import TessieEnergyData, TessieVehicleData PARALLEL_UPDATES = 0 @@ -44,7 +43,9 @@ VEHICLE_DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( TessieBinarySensorEntityDescription( key="charge_state_charging_state", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - is_on=lambda x: x == "Charging", + is_on=lambda value: ( + charge_state_to_option(value) == TessieChargeStates["Charging"] + ), entity_registry_enabled_default=False, ), TessieBinarySensorEntityDescription( diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index a370f504323..ab3628235fd 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -1,18 +1,10 @@ """Button platform for Tessie integration.""" -from __future__ import annotations - -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any -from tessie_api import ( - boombox, - enable_keyless_driving, - flash_lights, - honk, - trigger_homelink, - wake, -) +from tesla_fleet_api.tessie import Vehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -29,21 +21,22 @@ PARALLEL_UPDATES = 0 class TessieButtonEntityDescription(ButtonEntityDescription): """Describes a Tessie Button entity.""" - func: Callable + func: Callable[[Vehicle], Awaitable[dict[str, Any]]] DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( - TessieButtonEntityDescription(key="wake", func=lambda: wake), - TessieButtonEntityDescription(key="flash_lights", func=lambda: flash_lights), - TessieButtonEntityDescription(key="honk", func=lambda: honk), + TessieButtonEntityDescription(key="wake", func=lambda api: api.wake()), + TessieButtonEntityDescription(key="flash_lights", func=lambda api: api.flash()), + TessieButtonEntityDescription(key="honk", func=lambda api: api.honk()), TessieButtonEntityDescription( - key="trigger_homelink", func=lambda: trigger_homelink + key="trigger_homelink", + func=lambda api: api.tessie_trigger_homelink(), ), TessieButtonEntityDescription( key="enable_keyless_driving", - func=lambda: enable_keyless_driving, + func=lambda api: api.remote_start(), ), - TessieButtonEntityDescription(key="boombox", func=lambda: boombox), + TessieButtonEntityDescription(key="boombox", func=lambda api: api.remote_boombox()), ) @@ -78,4 +71,4 @@ class TessieButtonEntity(TessieEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.run(self.entity_description.func()) + await self.run(self.entity_description.func(self.api)) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index a8aa18132ee..9fe1d06498f 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -1,7 +1,5 @@ """Climate platform for Tessie integration.""" -from __future__ import annotations - from typing import Any from tessie_api import ( diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index fc350856b0f..1ee7ad6ab08 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Tessie integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 5cd2e16913c..582fb97c593 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -1,7 +1,5 @@ """Constants used by Tessie integration.""" -from __future__ import annotations - from enum import IntEnum, StrEnum DOMAIN = "tessie" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index cbb5d1d27bf..cdb07ebdaeb 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -1,7 +1,5 @@ """Tessie Data Coordinator.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging @@ -224,10 +222,13 @@ class TessieEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): or not isinstance(data.get("time_series"), list) or not data["time_series"] ): - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="invalid_energy_history_data", + _LOGGER.warning( + "Tessie returned no energy history" + " time_series for coordinator %s;" + " skipping update", + self.config_entry.entry_id, ) + return self.data time_series = data["time_series"] output: dict[str, Any] = {} diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index bfd7b1b816c..6a0dda654f5 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -1,7 +1,5 @@ """Cover platform for Tessie integration.""" -from __future__ import annotations - from itertools import chain from typing import Any diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index 154bf8c3eb3..5c2f27e85af 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -1,7 +1,5 @@ """Device Tracker platform for Tessie integration.""" -from __future__ import annotations - from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index 8bf5d6399d1..a64c5908941 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Tessie.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index e42ed57316c..20abf3f9750 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -77,6 +77,7 @@ class TessieEntity(TessieBaseEntity): data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie vehicle entity.""" + self.api = vehicle.api self.vin = vehicle.vin self._session = vehicle.data_coordinator.session self._api_key = vehicle.data_coordinator.api_key diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py index 321ad0d9aa0..c37a9f4d0f6 100644 --- a/homeassistant/components/tessie/helpers.py +++ b/homeassistant/components/tessie/helpers.py @@ -7,15 +7,34 @@ from aiohttp import ClientError from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import StateType from . import _LOGGER -from .const import DOMAIN, TRANSLATED_ERRORS +from .const import DOMAIN, TRANSLATED_ERRORS, TessieChargeStates + + +def charge_state_to_option(value: StateType) -> str | None: + """Convert Tessie charging state values into enum sensor options.""" + if isinstance(value, str): + return TessieChargeStates.get( + value, value if value in TessieChargeStates.values() else None + ) + if isinstance(value, bool): + return ( + TessieChargeStates["Charging"] if value else TessieChargeStates["Stopped"] + ) + return None async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]: """Handle an awaitable Vehicle/EnergySite command.""" try: result = await command + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e except TeslaFleetError as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 66cb813b995..360e1697b81 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -1,7 +1,5 @@ """Lock platform for Tessie integration.""" -from __future__ import annotations - from typing import Any from tessie_api import lock, open_unlock_charge_port, unlock diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 312a5f03e74..53b259ea03a 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.5"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.7"] } diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index ecac11587c1..0a8147b4cf8 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -1,7 +1,5 @@ """Media Player platform for Tessie integration.""" -from __future__ import annotations - from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index c9b1105281e..7ca218e4ef2 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -1,7 +1,5 @@ """The Tessie integration models.""" -from __future__ import annotations - from dataclasses import dataclass from tesla_fleet_api.tessie import EnergySite, Vehicle @@ -40,7 +38,7 @@ class TessieEnergyData: class TessieVehicleData: """Data for a Tessie vehicle.""" + api: Vehicle data_coordinator: TessieStateUpdateCoordinator device: DeviceInfo vin: str - api: Vehicle | None = None diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 77d8037fb14..07d2ec7c32a 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -1,7 +1,5 @@ """Number platform for Tessie integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain diff --git a/homeassistant/components/tessie/quality_scale.yaml b/homeassistant/components/tessie/quality_scale.yaml index 7433f41a85f..4468baa6c7b 100644 --- a/homeassistant/components/tessie/quality_scale.yaml +++ b/homeassistant/components/tessie/quality_scale.yaml @@ -72,7 +72,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index ce907deb9c8..3cbe27db999 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -1,7 +1,5 @@ """Select platform for Tessie integration.""" -from __future__ import annotations - from itertools import chain from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode @@ -48,15 +46,13 @@ async def async_setup_entry( TessieSeatHeaterSelectEntity(vehicle, key) for vehicle in entry.runtime_data.vehicles for key in SEAT_HEATERS - if key - in vehicle.data_coordinator.data # not all vehicles have rear center or third row + if key in vehicle.data_coordinator.data ), ( TessieSeatCoolerSelectEntity(vehicle, key) for vehicle in entry.runtime_data.vehicles for key in SEAT_COOLERS - if key - in vehicle.data_coordinator.data # not all vehicles have ventilated seats + if key in vehicle.data_coordinator.data ), ( TessieOperationSelectEntity(energysite) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 449cd0d7073..445c2f07ace 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Tessie integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -46,6 +44,7 @@ from .entity import ( TessieEntity, TessieWallConnectorEntity, ) +from .helpers import charge_state_to_option from .models import TessieEnergyData, TessieVehicleData @@ -71,7 +70,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="charge_state_charging_state", options=list(TessieChargeStates.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: TessieChargeStates[cast(str, value)], + value_fn=charge_state_to_option, ), TessieSensorEntityDescription( key="charge_state_usable_battery_level", @@ -637,4 +636,4 @@ class TessieEnergyHistorySensorEntity(TessieEnergyHistoryEntity, SensorEntity): """Update the attributes of the sensor.""" self._attr_available = self._value is not None self._attr_native_value = self._value - self._attr_last_reset = self.coordinator.data["_period_start"] + self._attr_last_reset = self.coordinator.data.get("_period_start") diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 41134b38fda..5dcaf010962 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -1,7 +1,5 @@ """Switch platform for Tessie integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from itertools import chain @@ -30,8 +28,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TessieConfigEntry +from .const import TessieChargeStates from .entity import TessieEnergyEntity, TessieEntity -from .helpers import handle_command +from .helpers import charge_state_to_option, handle_command from .models import TessieEnergyData, TessieVehicleData @@ -71,7 +70,10 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( unique_id="charge_state_charge_enable_request", on_func=lambda: start_charging, off_func=lambda: stop_charging, - value_func=lambda state: state in {"Starting", "Charging"}, + value_func=lambda state: ( + charge_state_to_option(state) + in {TessieChargeStates["Starting"], TessieChargeStates["Charging"]} + ), ), ) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index cd3c3b32857..98ce7fa42c4 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -1,7 +1,5 @@ """Update platform for Tessie integration.""" -from __future__ import annotations - from typing import Any from tessie_api import schedule_software_update diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 27af7e3fe59..9458624fe97 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -1,7 +1,5 @@ """Component to allow setting text as platforms.""" -from __future__ import annotations - from dataclasses import asdict, dataclass from datetime import timedelta from enum import StrEnum @@ -13,7 +11,7 @@ from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription @@ -25,7 +23,6 @@ from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_MAX, ATTR_MIN, - ATTR_MODE, ATTR_PATTERN, ATTR_VALUE, DOMAIN, @@ -72,11 +69,13 @@ async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> Non ) if len(value) > entity.max: raise ValueError( - f"Value {value} for {entity.entity_id} is too long (maximum length {entity.max})" + f"Value {value} for {entity.entity_id}" + f" is too long (maximum length {entity.max})" ) if entity.pattern_cmp and not entity.pattern_cmp.match(value): raise ValueError( - f"Value {value} for {entity.entity_id} doesn't match pattern {entity.pattern}" + f"Value {value} for {entity.entity_id}" + f" doesn't match pattern {entity.pattern}" ) await entity.async_set_value(value) diff --git a/homeassistant/components/text/condition.py b/homeassistant/components/text/condition.py index 7fe4ee44568..3bfe2e2c947 100644 --- a/homeassistant/components/text/condition.py +++ b/homeassistant/components/text/condition.py @@ -45,6 +45,11 @@ class TextIsEqualToCondition(EntityConditionBase): assert config.options self._value: str = config.options[CONF_VALUE] + @property + def _needs_duration_tracking(self) -> bool: + """Return if this condition needs duration tracking.""" + return False + def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected value.""" return entity_state.state == self._value diff --git a/homeassistant/components/text/conditions.yaml b/homeassistant/components/text/conditions.yaml index 4fa290f7813..73653c4dde1 100644 --- a/homeassistant/components/text/conditions.yaml +++ b/homeassistant/components/text/conditions.yaml @@ -8,11 +8,13 @@ is_equal_to: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: value: required: true selector: diff --git a/homeassistant/components/text/const.py b/homeassistant/components/text/const.py index 3670c30120b..b94c73512f1 100644 --- a/homeassistant/components/text/const.py +++ b/homeassistant/components/text/const.py @@ -4,7 +4,6 @@ DOMAIN = "text" ATTR_MAX = "max" ATTR_MIN = "min" -ATTR_MODE = "mode" ATTR_PATTERN = "pattern" ATTR_VALUE = "value" diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index b1eca1e36b6..1fc9c3519f5 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Text.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/text/reproduce_state.py b/homeassistant/components/text/reproduce_state.py index 329ffd374dd..422e55de1f7 100644 --- a/homeassistant/components/text/reproduce_state.py +++ b/homeassistant/components/text/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce a Text entity state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0eae84e3013..2d4b6f03a80 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -1,6 +1,7 @@ { "common": { - "condition_behavior_name": "Condition passes if" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least" }, "conditions": { "is_equal_to": { @@ -9,6 +10,9 @@ "behavior": { "name": "[%key:component::text::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::text::common::condition_for_name%]" + }, "value": { "description": "The value to compare the text to.", "name": "Value" @@ -48,14 +52,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - } - }, "services": { "set_value": { "description": "Sets the value of a text entity.", diff --git a/homeassistant/components/text/trigger.py b/homeassistant/components/text/trigger.py index af2480bf888..b92f5fa97ad 100644 --- a/homeassistant/components/text/trigger.py +++ b/homeassistant/components/text/trigger.py @@ -1,8 +1,7 @@ """Provides triggers for text and input_text entities.""" from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, @@ -19,16 +18,6 @@ class TextChangedTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec(), INPUT_TEXT_DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - TRIGGERS: dict[str, type[Trigger]] = { "changed": TextChangedTrigger, diff --git a/homeassistant/components/thermobeacon/__init__.py b/homeassistant/components/thermobeacon/__init__.py index 073ff6bbdc3..7847a318df5 100644 --- a/homeassistant/components/thermobeacon/__init__.py +++ b/homeassistant/components/thermobeacon/__init__.py @@ -1,7 +1,5 @@ """The ThermoBeacon integration.""" -from __future__ import annotations - import logging from thermobeacon_ble import ThermoBeaconBluetoothDeviceData @@ -14,26 +12,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type ThermoBeaconConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ThermoBeaconConfigEntry +) -> bool: """Set up ThermoBeacon BLE device from a config entry.""" address = entry.unique_id assert address is not None data = ThermoBeaconBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + entry.runtime_data = coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( @@ -42,9 +40,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ThermoBeaconConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/thermobeacon/config_flow.py b/homeassistant/components/thermobeacon/config_flow.py index 6fa502716ca..7cc828e3882 100644 --- a/homeassistant/components/thermobeacon/config_flow.py +++ b/homeassistant/components/thermobeacon/config_flow.py @@ -1,7 +1,5 @@ """Config flow for thermobeacon ble integration.""" -from __future__ import annotations - from typing import Any from thermobeacon_ble import ThermoBeaconBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/thermobeacon/device.py b/homeassistant/components/thermobeacon/device.py index 36af211876f..ae88801b65b 100644 --- a/homeassistant/components/thermobeacon/device.py +++ b/homeassistant/components/thermobeacon/device.py @@ -1,7 +1,5 @@ """Support for ThermoBeacon devices.""" -from __future__ import annotations - from thermobeacon_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 916ec91359a..30610868d0e 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -1,18 +1,14 @@ """Support for ThermoBeacon sensors.""" -from __future__ import annotations - from thermobeacon_ble import ( SensorDeviceClass as ThermoBeaconSensorDeviceClass, SensorUpdate, Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -32,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import ThermoBeaconConfigEntry from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -112,20 +108,20 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: ThermoBeaconConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoBeacon BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( ThermoBeaconBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class ThermoBeaconBluetoothSensorEntity( diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 742449cffbe..9a7467bdcd5 100644 --- a/homeassistant/components/thermopro/__init__.py +++ b/homeassistant/components/thermopro/__init__.py @@ -1,7 +1,5 @@ """The ThermoPro Bluetooth integration.""" -from __future__ import annotations - from functools import partial import logging @@ -35,7 +33,7 @@ def process_service_info( data: ThermoProBluetoothDeviceData, service_info: BluetoothServiceInfoBleak, ) -> SensorUpdate: - """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + """Process a BluetoothServiceInfoBleak and return sensor data.""" update = data.update(service_info) async_dispatcher_send( hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update diff --git a/homeassistant/components/thermopro/button.py b/homeassistant/components/thermopro/button.py index 9faa9f22c4c..324da26651e 100644 --- a/homeassistant/components/thermopro/button.py +++ b/homeassistant/components/thermopro/button.py @@ -1,7 +1,5 @@ """Thermopro button platform.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/thermopro/config_flow.py b/homeassistant/components/thermopro/config_flow.py index 4c6d59473c2..05f683345e9 100644 --- a/homeassistant/components/thermopro/config_flow.py +++ b/homeassistant/components/thermopro/config_flow.py @@ -1,12 +1,11 @@ """Config flow for thermopro ble integration.""" -from __future__ import annotations - from typing import Any from thermopro_ble import ThermoProBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -72,6 +71,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 8608dfbc538..498670e93d9 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/thermopro", "integration_type": "device", "iot_class": "local_push", - "requirements": ["thermopro-ble==1.1.3"] + "requirements": ["thermopro-ble==1.1.4"] } diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index bc077462784..3d44b6163ce 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -1,7 +1,5 @@ """Support for thermopro ble sensors.""" -from __future__ import annotations - from thermopro_ble import ( DeviceKey, SensorDeviceClass as ThermoProSensorDeviceClass, @@ -114,6 +112,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoPro BLE sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 84eff14336a..fe45afbb9c7 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -3,8 +3,6 @@ Requires Smoke Gateway Wifi with an internet connection. """ -from __future__ import annotations - import logging from requests import RequestException diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index d3c6c8356cb..4f9bf215408 100644 --- a/homeassistant/components/thethingsnetwork/__init__.py +++ b/homeassistant/components/thethingsnetwork/__init__.py @@ -2,17 +2,16 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS, TTN_API_HOST -from .coordinator import TTNCoordinator +from .const import PLATFORMS, TTN_API_HOST +from .coordinator import TTNConfigEntry, TTNCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TTNConfigEntry) -> bool: """Establish connection with The Things Network.""" _LOGGER.debug( @@ -25,14 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TTNConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug( @@ -41,8 +40,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_HOST, TTN_API_HOST), ) - # Unload entities created for each supported platform - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/thethingsnetwork/coordinator.py b/homeassistant/components/thethingsnetwork/coordinator.py index 78ffceecf84..9a8d0c824ef 100644 --- a/homeassistant/components/thethingsnetwork/coordinator.py +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -15,13 +15,15 @@ from .const import CONF_APP_ID, POLLING_PERIOD_S _LOGGER = logging.getLogger(__name__) +type TTNConfigEntry = ConfigEntry[TTNCoordinator] + class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]): """TTN coordinator.""" - config_entry: ConfigEntry + config_entry: TTNConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: TTNConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 5aa851d99ae..334a6878e34 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -5,12 +5,12 @@ import logging from ttn_client import TTNSensorValue from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import CONF_APP_ID, DOMAIN +from .const import CONF_APP_ID +from .coordinator import TTNConfigEntry from .entity import TTNEntity _LOGGER = logging.getLogger(__name__) @@ -18,12 +18,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TTNConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for TTN.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: set[tuple[str, str]] = set() diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index ccdc1ada48e..136bfcb4702 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -1,7 +1,5 @@ """Support for ThinkingCleaner sensors.""" -from __future__ import annotations - from datetime import timedelta from pythinkingcleaner import Discovery, ThinkingCleaner diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 135045df3ff..d6a22763fc5 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -1,7 +1,5 @@ """Support for ThinkingCleaner switches.""" -from __future__ import annotations - from datetime import timedelta import time from typing import Any diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index f003264b6d7..9fbbdf726cc 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -1,7 +1,5 @@ """Support for THOMSON routers.""" -from __future__ import annotations - import logging import re diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 65a59e43f31..22a621f84fa 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -1,7 +1,5 @@ """The Thread integration.""" -from __future__ import annotations - from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -36,6 +34,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) async_setup_ws_api(hass) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data hass.data[DOMAIN] = {} return True diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 42caf5d9e32..059c63684b9 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Thread integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components import onboarding diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 5afffd102f0..15788cd6599 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,7 +1,5 @@ """Persistently store thread datasets.""" -from __future__ import annotations - from asyncio import Event, Task, wait import dataclasses from datetime import datetime @@ -29,6 +27,12 @@ STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 +# Bit 0x08 of the first security policy flags byte is the legacy "Beacons" +# flag. It was removed from the Thread security policy in v1.2.1 (2022), so +# current Thread stacks no longer set it; datasets that still carry it were +# created by older implementations. It is ignored when comparing datasets. +SECURITY_POLICY_BEACONS_FLAG = 0x08 + _LOGGER = logging.getLogger(__name__) @@ -50,6 +54,37 @@ def _format_dataset( return result +def _normalize_dataset( + dataset: dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem], +) -> dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem]: + """Normalize a dataset for equivalence comparison. + + Thread Border Routers may report functionally equivalent datasets without + incrementing the active timestamp. To recognize these as equivalent, ignore + the fields that don't affect how Home Assistant uses the dataset: + - WAKEUP_CHANNEL: added in newer OpenThread Border Router versions, but the + wake-up protocol isn't defined yet, so we treat it as if it were always + present. + - The legacy Beacons bit in the security policy flags: it was removed from + the Thread security policy in v1.2.1, so datasets created by older + implementations may still set it while current routers don't. + """ + normalized = { + key: value + for key, value in dataset.items() + if key != MeshcopTLVType.WAKEUP_CHANNEL + } + if (security_policy := normalized.get(MeshcopTLVType.SECURITYPOLICY)) and len( + security_policy.data + ) > 2: + flags = bytearray(security_policy.data) + flags[2] &= ~SECURITY_POLICY_BEACONS_FLAG + normalized[MeshcopTLVType.SECURITYPOLICY] = tlv_parser.MeshcopTLVItem( + security_policy.tag, bytes(flags) + ) + return normalized + + class DatasetPreferredError(HomeAssistantError): """Raised when attempting to delete the preferred dataset.""" @@ -282,15 +317,12 @@ class DatasetStore: old_ts = (old_timestamp.seconds, old_timestamp.ticks) new_ts = (new_timestamp.seconds, new_timestamp.ticks) if old_ts >= new_ts: - # Silently accept if the only addition is WAKEUP_CHANNEL: - # it was added in OpenThread but the wake-up protocol isn't - # defined yet, so we treat it as if it were always present. - dataset_without_wakeup = { - k: v - for k, v in dataset.items() - if k != MeshcopTLVType.WAKEUP_CHANNEL - } - if old_ts > new_ts or dataset_without_wakeup != entry.dataset: + # Silently accept datasets that are functionally equivalent but + # reported without a newer active timestamp by some OpenThread + # Border Router versions (see _normalize_dataset). + if old_ts > new_ts or _normalize_dataset(dataset) != _normalize_dataset( + entry.dataset + ): _LOGGER.warning( "Got dataset with same extended PAN ID and same or older" " active timestamp\nold:\n%s\nnew:\n%s", diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index 2d9deb9184a..d77a686755e 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -1,22 +1,27 @@ """Diagnostics support for Thread networks. -When triaging Matter and HomeKit issues you often need to check for problems with the Thread network. +When triaging Matter and HomeKit issues you often need to +check for problems with the Thread network. This report helps spot and rule out: * Is the users border router visible at all? -* Is the border router actually announcing any routes? The user could have a network boundary like - VLANs or WiFi isolation that is blocking the RA packets. -* Alternatively, if user isn't on HAOS they could have accept_ra_rt_info_max_plen set incorrectly. -* Are there any bogus routes that could be interfering. If routes don't expire they can build up. - When you have 10 routes and only 2 border routers something has gone wrong. +* Is the border router actually announcing any routes? + The user could have a network boundary like VLANs or + WiFi isolation that is blocking the RA packets. +* Alternatively, if user isn't on HAOS they could have + accept_ra_rt_info_max_plen set incorrectly. +* Are there any bogus routes that could be interfering. + If routes don't expire they can build up. When you have + 10 routes and only 2 border routers something has gone + wrong. -This does not do any connectivity checks. So user could have all their border routers visible, but -some of their thread accessories can't be pinged, but it's still a thread problem. +This does not do any connectivity checks. So user could +have all their border routers visible, but some of their +thread accessories can't be pinged, but it's still a +thread problem. """ -from __future__ import annotations - from ipaddress import IPv6Address from typing import TYPE_CHECKING, Any, TypedDict @@ -75,7 +80,8 @@ def _get_possible_thread_routes( ) -> tuple[dict[str, dict[str, Route]], dict[str, set[str]]]: # Build a list of possible thread routes # Right now, this is ipv6 /64's that have a gateway - # We cross reference with zerconf data to confirm which via's are known border routers + # We cross reference with zeroconf data to confirm + # which via's are known border routers routes: dict[str, dict[str, Route]] = {} reverse_routes: dict[str, set[str]] = {} @@ -148,13 +154,17 @@ async def async_get_config_entry_diagnostics( }, ) if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): - # We know that it is indeed a /64 mesh-local IPv6 NETWORK because Thread spec; - # However, the "prefixes" field contains no /XX (prefix length) in their entries ATM, - # so we use an IPv6Address in order to get a "prefixes" entry with no prefix length. + # We know that it is indeed a /64 mesh-local + # IPv6 NETWORK because Thread spec; + # However, the "prefixes" field contains no /XX + # (prefix length) in their entries ATM, so we + # use an IPv6Address in order to get a "prefixes" + # entry with no prefix length. prefix_address = IPv6Address(mlp_item.data.ljust(16, b"\x00")) network["prefixes"].add(str(prefix_address)) - # Find all routes currently act that might be thread related, so we can match them to + # Find all routes currently active that might be + # thread related, so we can match them to # border routers as we process the zeroconf data. # # Also find all neighbours diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 4709162ee4b..38c351d4b54 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -1,7 +1,5 @@ """The Thread integration.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses import logging @@ -24,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Amazon": "amazon", + "amazon": "amazon", "Apple": "apple", "Apple Inc.": "apple", "Aqara": "aqara_gateway", @@ -37,6 +36,7 @@ KNOWN_BRANDS: dict[str | None, str] = { "OpenThread": "openthread", "Samsung": "samsung", "SmartThings": "smartthings", + "Yeelight": "yeelight", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index a00f7480ede..4daa49dc360 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.10.0", "pyroute2==0.9.6"], "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index d436a5ffb72..6e813ba1bbc 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -1,7 +1,5 @@ """The thread websocket API.""" -from __future__ import annotations - from typing import Any from python_otbr_api.tlv_parser import TLVError diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 477237051b2..ba7bc04bb36 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -1,7 +1,5 @@ """Support for monitoring if a sensor value is below/above a threshold.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import logging from typing import Any, Final diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 93468e89b46..2a46a5d5a35 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Threshold integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -139,7 +137,7 @@ def ws_start_preview( name=name, lower=msg["user_input"].get(CONF_LOWER), upper=msg["user_input"].get(CONF_UPPER), - hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS), device_class=None, unique_id=None, ) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 0596a5a2dc0..959902db179 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -1,7 +1,5 @@ """Support for Tibber.""" -from __future__ import annotations - from dataclasses import dataclass, field import logging @@ -23,7 +21,12 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry -from .coordinator import TibberDataAPICoordinator +from .coordinator import ( + TibberDataAPICoordinator, + TibberDataCoordinator, + TibberFetchPriceCoordinator, + TibberPriceCoordinator, +) from .services import async_setup_services PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR] @@ -39,23 +42,33 @@ class TibberRuntimeData: session: OAuth2Session data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) + data_coordinator: TibberDataCoordinator | None = field(default=None) + fetch_price_coordinator: TibberFetchPriceCoordinator | None = field(default=None) + price_coordinator: TibberPriceCoordinator | None = field(default=None) _client: tibber.Tibber | None = None + async def _async_get_access_token(self) -> str: + """Return a valid Tibber access token.""" + await self.session.async_ensure_token_valid() + token = self.session.token + access_token: str | None = token.get(CONF_ACCESS_TOKEN) + if not access_token: + raise ConfigEntryAuthFailed("Access token missing from OAuth session") + return access_token + async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber: """Return an authenticated Tibber client.""" - await self.session.async_ensure_token_valid() - token = self.session.token - access_token = token.get(CONF_ACCESS_TOKEN) - if not access_token: - raise ConfigEntryAuthFailed("Access token missing from OAuth session") + access_token = await self._async_get_access_token() if self._client is None: self._client = tibber.Tibber( access_token=access_token, websession=async_get_clientsession(hass), time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), + refresh_access_token=self._async_get_access_token, ) - await self._client.set_access_token(access_token) + else: + await self._client.set_access_token(access_token) return self._client @@ -124,6 +137,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo except tibber.FatalHttpExceptionError as err: raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err + if tibber_connection.get_homes(only_active=True): + fetch_price_coordinator = TibberFetchPriceCoordinator(hass, entry) + await fetch_price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.fetch_price_coordinator = fetch_price_coordinator + + price_coordinator = TibberPriceCoordinator(hass, entry, fetch_price_coordinator) + await price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.price_coordinator = price_coordinator + + data_coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + await data_coordinator.async_config_entry_first_refresh() + entry.runtime_data.data_coordinator = data_coordinator + coordinator = TibberDataAPICoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data.data_api_coordinator = coordinator diff --git a/homeassistant/components/tibber/binary_sensor.py b/homeassistant/components/tibber/binary_sensor.py index d1da82618ca..662d84f2b02 100644 --- a/homeassistant/components/tibber/binary_sensor.py +++ b/homeassistant/components/tibber/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tibber binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index c4a2109b8f9..c4dfd86fbb0 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Tibber integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/tibber/const.py b/homeassistant/components/tibber/const.py index 4151f21e444..fe65dd1232a 100644 --- a/homeassistant/components/tibber/const.py +++ b/homeassistant/components/tibber/const.py @@ -1,7 +1,5 @@ """Constants for Tibber integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 75a76326146..20214caeff4 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -1,10 +1,9 @@ """Coordinator for Tibber sensors.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta import logging +import random from typing import TYPE_CHECKING, TypedDict, cast from aiohttp.client_exceptions import ClientError @@ -23,8 +22,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import UnitOfEnergy -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter @@ -91,11 +89,40 @@ def _build_home_data(home: tibber.TibberHome) -> TibberHomeData: return result -class TibberDataCoordinator(DataUpdateCoordinator[None]): - """Handle Tibber data and insert statistics.""" +class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base Tibber coordinator.""" config_entry: TibberConfigEntry + def __init__( + self, + hass: HomeAssistant, + config_entry: TibberConfigEntry, + *, + name: str, + update_interval: timedelta | None = None, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self._runtime_data = config_entry.runtime_data + + async def _async_get_client(self) -> tibber.Tibber: + """Get the Tibber client with error handling.""" + try: + return await self._runtime_data.async_get_client(self.hass) + except (ClientError, TimeoutError, tibber.exceptions.HttpExceptionError) as err: + raise UpdateFailed(f"Unable to create Tibber client: {err}") from err + + +class TibberDataCoordinator(TibberCoordinator[None]): + """Handle Tibber data and insert statistics.""" + def __init__( self, hass: HomeAssistant, @@ -105,17 +132,14 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): """Initialize the data handler.""" super().__init__( hass, - _LOGGER, - config_entry=config_entry, + config_entry, name=f"Tibber {tibber_connection.name}", update_interval=timedelta(minutes=20), ) async def _async_update_data(self) -> None: """Update data via API.""" - tibber_connection = await self.config_entry.runtime_data.async_get_client( - self.hass - ) + tibber_connection = await self._async_get_client() try: await tibber_connection.fetch_consumption_data_active_homes() @@ -131,9 +155,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): async def _insert_statistics(self) -> None: """Insert Tibber statistics.""" - tibber_connection = await self.config_entry.runtime_data.async_get_client( - self.hass - ) + tibber_connection = await self._async_get_client() for home in tibber_connection.get_homes(): sensors: list[tuple[str, bool, str | None, str]] = [] if home.hourly_consumption_data: @@ -253,27 +275,58 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): async_add_external_statistics(self.hass, metadata, statistics) -class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]): - """Handle Tibber price data and insert statistics.""" - - config_entry: TibberConfigEntry +class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): + """Handle Tibber price data.""" def __init__( self, hass: HomeAssistant, config_entry: TibberConfigEntry, + price_fetch_coordinator: TibberFetchPriceCoordinator, ) -> None: """Initialize the price coordinator.""" super().__init__( hass, - _LOGGER, - config_entry=config_entry, + config_entry, name=f"{DOMAIN} price", - update_interval=timedelta(minutes=1), ) + self._price_fetch_coordinator = price_fetch_coordinator + self._unsub_price_fetch_listener: CALLBACK_TYPE | None = None - def _seconds_until_next_15_minute(self) -> float: - """Return seconds until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" + @callback + def _build_price_data(self) -> dict[str, TibberHomeData]: + """Build derived price data from the fetched Tibber homes.""" + return { + home_id: _build_home_data(home) + for home_id, home in (self._price_fetch_coordinator.data or {}).items() + } + + @callback + def _async_handle_price_fetch_update(self) -> None: + """Update derived price data when fetched prices change.""" + self.update_interval = self._time_until_next_15_minute() + self.async_set_updated_data(self._build_price_data()) + + @callback + def _schedule_refresh(self) -> None: + """Start listening to fetched price data when entities subscribe.""" + super()._schedule_refresh() + if self._unsub_price_fetch_listener is None: + self._unsub_price_fetch_listener = ( + self._price_fetch_coordinator.async_add_listener( + self._async_handle_price_fetch_update + ) + ) + + def _unschedule_refresh(self) -> None: + """Stop listening to fetched price data when unused.""" + super()._unschedule_refresh() + if self._unsub_price_fetch_listener is not None: + self._unsub_price_fetch_listener() + self._unsub_price_fetch_listener = None + + def _time_until_next_15_minute(self) -> timedelta: + """Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" now = dt_util.utcnow() next_minute = ((now.minute // 15) + 1) * 15 if next_minute >= 60: @@ -284,50 +337,94 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]): next_run = now.replace( minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC ) - return (next_run - now).total_seconds() + return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: - """Update data via API and return per-home data for sensors.""" - tibber_connection = await self.config_entry.runtime_data.async_get_client( - self.hass + self.update_interval = self._time_until_next_15_minute() + return self._build_price_data() + + +class TibberFetchPriceCoordinator(TibberCoordinator[dict[str, tibber.TibberHome]]): + """Fetch Tibber price data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: TibberConfigEntry, + ) -> None: + """Initialize the price coordinator.""" + super().__init__( + hass, + config_entry, + name=f"{DOMAIN} price fetch", ) + self._tomorrow_price_poll_threshold_seconds = random.uniform( + 3600 * 14, 3600 * 22 + ) + + async def _async_update_data(self) -> dict[str, tibber.TibberHome]: + """Fetch latest price data via API and return per-home data.""" + tibber_connection = await self._async_get_client() active_homes = tibber_connection.get_homes(only_active=True) + + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + timedelta(days=1) + tomorrow_start = today_end + tomorrow_end = tomorrow_start + timedelta(days=1) + + def _has_prices_today(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices today.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if today_start <= start_dt < today_end: + return True + return False + + def _has_prices_tomorrow(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices tomorrow.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if tomorrow_start <= start_dt < tomorrow_end: + return True + return False + + def _needs_update(home: tibber.TibberHome) -> bool: + """Return True if the home needs to be updated.""" + if not _has_prices_today(home): + return True + if _has_prices_tomorrow(home): + return False + if now >= today_start + timedelta( + seconds=self._tomorrow_price_poll_threshold_seconds + ): + return True + return False + + self.update_interval = timedelta(seconds=random.uniform(60, 60 * 10)) + try: await asyncio.gather( - tibber_connection.fetch_consumption_data_active_homes(), - tibber_connection.fetch_production_data_active_homes(), + *( + home.update_info_and_price_info() + for home in active_homes + if _needs_update(home) + ) ) + except tibber.exceptions.RateLimitExceededError as err: + raise UpdateFailed( + f"Rate limit exceeded, retry after {err.retry_after} seconds", + retry_after=err.retry_after, + ) from err + except tibber.exceptions.HttpExceptionError as err: + raise UpdateFailed(f"Error communicating with API ({err})") from err - now = dt_util.now() - homes_to_update = [ - home - for home in active_homes - if ( - (last_data_timestamp := home.last_data_timestamp) is None - or (last_data_timestamp - now).total_seconds() < 11 * 3600 - ) - ] - - if homes_to_update: - await asyncio.gather( - *(home.update_info_and_price_info() for home in homes_to_update) - ) - except tibber.RetryableHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - except tibber.FatalHttpExceptionError as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - - result = {home.home_id: _build_home_data(home) for home in active_homes} - - self.update_interval = timedelta(seconds=self._seconds_until_next_15_minute()) - return result + return {home.home_id: home for home in active_homes} -class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): +class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]): """Fetch and cache Tibber Data API device capabilities.""" - config_entry: TibberConfigEntry - def __init__( self, hass: HomeAssistant, @@ -336,12 +433,10 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): """Initialize the coordinator.""" super().__init__( hass, - _LOGGER, + entry, name=f"{DOMAIN} Data API", update_interval=timedelta(minutes=1), - config_entry=entry, ) - self._runtime_data = entry.runtime_data self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {} def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None: @@ -359,15 +454,6 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): return device_sensors.get(sensor_id) return None - async def _async_get_client(self) -> tibber.Tibber: - """Get the Tibber client with error handling.""" - try: - return await self._runtime_data.async_get_client(self.hass) - except ConfigEntryAuthFailed: - raise - except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err: - raise UpdateFailed(f"Unable to create Tibber client: {err}") from err - async def _async_setup(self) -> None: """Initial load of Tibber Data API devices.""" client = await self._async_get_client() diff --git a/homeassistant/components/tibber/diagnostics.py b/homeassistant/components/tibber/diagnostics.py index bde48b75972..9b981cf005d 100644 --- a/homeassistant/components/tibber/diagnostics.py +++ b/homeassistant/components/tibber/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Tibber.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index ceda353e743..3eea41a0e5a 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.0"] + "requirements": ["pyTibber==0.37.6"] } diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 7dc5c2c259b..adcdea407ac 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -1,7 +1,5 @@ """Support for Tibber notifications.""" -from __future__ import annotations - import tibber from homeassistant.components.notify import ( diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 008e3abef28..781626cd17d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -1,7 +1,5 @@ """Support for Tibber sensors.""" -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta import logging @@ -609,8 +607,8 @@ async def _async_setup_graphql_sensors( entity_registry = er.async_get(hass) - coordinator: TibberDataCoordinator | None = None - price_coordinator: TibberPriceCoordinator | None = None + coordinator = entry.runtime_data.data_coordinator + price_coordinator = entry.runtime_data.price_coordinator entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: @@ -626,12 +624,9 @@ async def _async_setup_graphql_sensors( _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err - if home.has_active_subscription: - if price_coordinator is None: - price_coordinator = TibberPriceCoordinator(hass, entry) + if price_coordinator is not None and home.has_active_subscription: entities.append(TibberSensorElPrice(price_coordinator, home)) - if coordinator is None: - coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + if coordinator is not None and home.has_active_subscription: entities.extend( TibberDataSensor(home, coordinator, entity_description) for entity_description in SENSORS @@ -753,7 +748,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, tibber_home=tibber_home) - self._attr_available = False + self._price_data_available = False self._attr_native_unit_of_measurement = tibber_home.price_unit self._attr_extra_state_attributes = { "app_nickname": None, @@ -772,17 +767,28 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator self._model = "Price Sensor" self._device_name = self._home_name + self._update_attributes() + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return super().available and self._price_data_available @callback def _handle_coordinator_update(self) -> None: + self._update_attributes() + super()._handle_coordinator_update() + + @callback + def _update_attributes(self) -> None: """Handle updated data from the coordinator.""" data = self.coordinator.data if not data or ( (home_data := data.get(self._tibber_home.home_id)) is None or (current_price := home_data.get("current_price")) is None ): - self._attr_available = False - self.async_write_ha_state() + self._price_data_available = False + self._attr_native_value = None return self._attr_native_unit_of_measurement = home_data.get( @@ -803,8 +809,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[ "estimated_annual_consumption" ] - self._attr_available = True - self.async_write_ha_state() + self._price_data_available = True class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): @@ -979,7 +984,7 @@ class TibberRtEntityCreator: self._async_add_entities(new_entities) -class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-class-module +class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=home-assistant-enforce-class-module """Handle Tibber realtime data.""" def __init__( diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 099739e4478..7528d670c04 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -1,11 +1,11 @@ """Services for Tibber integration.""" -from __future__ import annotations - import datetime as dt from datetime import datetime from typing import TYPE_CHECKING, Any, Final +import aiohttp +import tibber import voluptuous as vol from homeassistant.core import ( @@ -15,7 +15,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -52,7 +52,52 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: tibber_prices: dict[str, Any] = {} + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + dt.timedelta(days=1) + tomorrow_end = today_start + dt.timedelta(days=2) + + def _has_valid_prices(home: tibber.TibberHome) -> bool: + """Return True if the home has valid prices.""" + for price_start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(price_start))) + + if now.hour >= 13: + if today_end <= start_dt < tomorrow_end: + return True + elif today_start <= start_dt < today_end: + return True + return False + for tibber_home in tibber_connection.get_homes(only_active=True): + if not _has_valid_prices(tibber_home): + try: + await tibber_home.update_info_and_price_info() + except TimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_timeout", + ) from err + except tibber.InvalidLoginError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_invalid_login", + ) from err + except ( + tibber.RetryableHttpExceptionError, + tibber.FatalHttpExceptionError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err.status)}, + ) from err + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err)}, + ) from err home_nickname = tibber_home.name price_data = [ diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index d07f295785e..c175f2fe962 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -235,6 +235,15 @@ "data_api_reauth_required": { "message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features." }, + "get_prices_communication_failed": { + "message": "Could not fetch energy prices from Tibber ({detail})" + }, + "get_prices_invalid_login": { + "message": "Could not authenticate with Tibber while fetching prices" + }, + "get_prices_timeout": { + "message": "Timeout fetching energy prices from Tibber" + }, "invalid_date": { "message": "Invalid datetime provided {date}" }, diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py index a3961cbb569..a3f9d3f96ea 100644 --- a/homeassistant/components/tikteck/light.py +++ b/homeassistant/components/tikteck/light.py @@ -1,7 +1,5 @@ """Support for Tikteck lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 4b7dc9ca3b5..49203b9569b 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -1,7 +1,5 @@ """The Tile component.""" -from __future__ import annotations - from pytile import async_login from pytile.errors import InvalidAuthError, TileError diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py index 6abc80732a6..753b6634751 100644 --- a/homeassistant/components/tile/binary_sensor.py +++ b/homeassistant/components/tile/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tile binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index 2ff7c0ca9ed..6266b5fe2a4 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Tile integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index c81c791cd5d..89e69060d47 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -1,7 +1,5 @@ """Support for Tile device trackers.""" -from __future__ import annotations - import logging from homeassistant.components.device_tracker import TrackerEntity diff --git a/homeassistant/components/tile/diagnostics.py b/homeassistant/components/tile/diagnostics.py index 9db33b737c0..f2c8a0c7e4d 100644 --- a/homeassistant/components/tile/diagnostics.py +++ b/homeassistant/components/tile/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Tile.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tilt_ble/__init__.py b/homeassistant/components/tilt_ble/__init__.py index 09bda4212f3..578cee58096 100644 --- a/homeassistant/components/tilt_ble/__init__.py +++ b/homeassistant/components/tilt_ble/__init__.py @@ -1,7 +1,5 @@ """The tilt_ble integration.""" -from __future__ import annotations - import logging from tilt_ble import TiltBluetoothDeviceData diff --git a/homeassistant/components/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py index b4a3235c60f..9a762b82460 100644 --- a/homeassistant/components/tilt_ble/config_flow.py +++ b/homeassistant/components/tilt_ble/config_flow.py @@ -1,12 +1,11 @@ """Config flow for tilt_ble.""" -from __future__ import annotations - from typing import Any from tilt_ble import TiltBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -72,6 +71,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index d5f9bd34232..61f1f476929 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -1,7 +1,5 @@ """Support for Tilt Hydrometers.""" -from __future__ import annotations - from tilt_ble import DeviceClass, DeviceKey, SensorUpdate, Units from homeassistant.components.bluetooth.passive_update_processor import ( @@ -94,7 +92,9 @@ async def async_setup_entry( TiltBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class TiltBluetoothSensorEntity( diff --git a/homeassistant/components/tilt_pi/sensor.py b/homeassistant/components/tilt_pi/sensor.py index 4ce40e70bdb..42cb3bfce22 100644 --- a/homeassistant/components/tilt_pi/sensor.py +++ b/homeassistant/components/tilt_pi/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -23,7 +23,6 @@ from .entity import TiltEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 -ATTR_TEMPERATURE = "temperature" ATTR_GRAVITY = "gravity" diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 1e3c37b55b3..c04f9ff6f8e 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -1,7 +1,5 @@ """Component to allow setting time as platforms.""" -from __future__ import annotations - from datetime import time, timedelta import logging from typing import final diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 151f5c6b39f..42c45f7ac30 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -1,7 +1,5 @@ """The time_date component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 364bf26d1aa..6d92c9e8686 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Time & Date integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -10,6 +8,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_DISPLAY_OPTIONS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.schema_config_entry_flow import ( @@ -24,7 +23,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES +from .const import DOMAIN, OPTION_TYPES from .sensor import TimeDateSensor _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py index 53656bae181..1d88f892a6e 100644 --- a/homeassistant/components/time_date/const.py +++ b/homeassistant/components/time_date/const.py @@ -1,12 +1,9 @@ """Constants for the Time & Date integration.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform -CONF_DISPLAY_OPTIONS = "display_options" DOMAIN: Final = "time_date" PLATFORMS = [Platform.SENSOR] TIME_STR_FORMAT = "%H:%M" diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index f05244e7680..9b9d74144d9 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,7 +1,5 @@ """Support for showing the date and the time.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 85745aea8e4..2c90ca86739 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -1,7 +1,5 @@ """Support for Timers.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -41,6 +39,7 @@ ATTR_REMAINING = "remaining" ATTR_FINISHES_AT = "finishes_at" ATTR_RESTORE = "restore" ATTR_FINISHED_AT = "finished_at" +ATTR_LAST_TRANSITION = "last_transition" CONF_DURATION = "duration" CONF_RESTORE = "restore" @@ -202,6 +201,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): def __init__(self, config: ConfigType) -> None: """Initialize a timer.""" self._config: dict = config + self._last_transition: str | None = None self._state: str = STATUS_IDLE self._configured_duration = cv.time_period_str(config[CONF_DURATION]) self._running_duration: timedelta = self._configured_duration @@ -249,6 +249,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): attrs: dict[str, Any] = { ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, + ATTR_LAST_TRANSITION: self._last_transition, } if self._end is not None: attrs[ATTR_FINISHES_AT] = self._end.isoformat() @@ -274,6 +275,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): # Begin restoring state self._state = state.state + self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: @@ -321,8 +323,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._end = start + self._remaining - self.async_write_ha_state() - self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id}) + self._fire_event_and_write_state(event) self._listener = async_track_point_in_utc_time( self.hass, self._async_finished, self._end @@ -333,7 +334,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): """Change duration of a running timer.""" if self._listener is None or self._end is None: raise HomeAssistantError( - f"Timer {self.entity_id} is not running, only active timers can be changed" + f"Timer {self.entity_id} is not running," + " only active timers can be changed" ) # Check against new remaining time before checking boundaries new_remaining = (self._end + duration) - dt_util.utcnow().replace(microsecond=0) @@ -343,12 +345,15 @@ class Timer(collection.CollectionEntity, RestoreEntity): ) if self._remaining and (self._remaining + duration) < timedelta(): raise HomeAssistantError( - f"Not possible to change timer {self.entity_id} to negative time remaining" + f"Not possible to change timer" + f" {self.entity_id} to negative time remaining" ) self._listener() self._end += duration self._remaining = new_remaining + # We don't use _fire_event_and_write_state here because we don't want to + # update last_transition self.async_write_ha_state() self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( @@ -366,8 +371,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._state = STATUS_PAUSED self._end = None - self.async_write_ha_state() - self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id}) + self._fire_event_and_write_state(EVENT_TIMER_PAUSED) @callback def async_cancel(self) -> None: @@ -382,10 +386,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} - ) + self._fire_event_and_write_state(EVENT_TIMER_CANCELLED) @callback def async_finish(self) -> None: @@ -403,10 +404,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) @callback @@ -421,10 +420,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) async def async_update_config(self, config: ConfigType) -> None: @@ -435,3 +432,14 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._running_duration = self._configured_duration self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE) self.async_write_ha_state() + + def _fire_event_and_write_state( + self, event: str, *, extra_attrs: dict[str, Any] | None = None + ) -> None: + """Fire the event and write state.""" + self._last_transition = event.partition(".")[2] + self.async_write_ha_state() + event_data = {ATTR_ENTITY_ID: self.entity_id} + if extra_attrs: + event_data.update(extra_attrs) + self.hass.bus.async_fire(event, event_data) diff --git a/homeassistant/components/timer/conditions.yaml b/homeassistant/components/timer/conditions.yaml index a94cf600933..6930f7263e1 100644 --- a/homeassistant/components/timer/conditions.yaml +++ b/homeassistant/components/timer/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_active: *condition_common is_paused: *condition_common diff --git a/homeassistant/components/timer/icons.json b/homeassistant/components/timer/icons.json index fcc398870aa..769fdfd10f8 100644 --- a/homeassistant/components/timer/icons.json +++ b/homeassistant/components/timer/icons.json @@ -12,22 +12,42 @@ }, "services": { "cancel": { - "service": "mdi:cancel" + "service": "mdi:timer-cancel" }, "change": { - "service": "mdi:pencil" + "service": "mdi:timer-edit" }, "finish": { - "service": "mdi:check" + "service": "mdi:timer-check" }, "pause": { - "service": "mdi:pause" + "service": "mdi:timer-pause" }, "reload": { "service": "mdi:reload" }, "start": { - "service": "mdi:play" + "service": "mdi:timer-play" + } + }, + "triggers": { + "cancelled": { + "trigger": "mdi:timer-cancel" + }, + "finished": { + "trigger": "mdi:timer-check" + }, + "paused": { + "trigger": "mdi:timer-pause" + }, + "restarted": { + "trigger": "mdi:timer-refresh" + }, + "started": { + "trigger": "mdi:timer-play" + }, + "time_remaining": { + "trigger": "mdi:timer-alert-outline" } } } diff --git a/homeassistant/components/timer/reproduce_state.py b/homeassistant/components/timer/reproduce_state.py index 3bdee08016c..95cec586c3d 100644 --- a/homeassistant/components/timer/reproduce_state.py +++ b/homeassistant/components/timer/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Timer state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 4a3e6c2a3b3..ddc6e9db44f 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,6 +1,9 @@ { "common": { - "condition_behavior_name": "Condition passes if" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_active": { @@ -8,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is active" @@ -17,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is idle" @@ -26,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is paused" @@ -53,6 +65,16 @@ "finishes_at": { "name": "Finishes at" }, + "last_transition": { + "name": "Last transition", + "state": { + "cancelled": "Cancelled", + "finished": "Finished", + "paused": "Paused", + "restarted": "Restarted", + "started": "Started" + } + }, "remaining": { "name": "Remaining" }, @@ -62,14 +84,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - } - }, "services": { "cancel": { "description": "Resets a timer's duration to the last known initial value without firing the timer finished event.", @@ -108,5 +122,76 @@ "name": "Start timer" } }, - "title": "Timer" + "title": "Timer", + "triggers": { + "cancelled": { + "description": "Triggers when one or more timers are cancelled.", + "fields": { + "behavior": { + "name": "[%key:component::timer::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::trigger_for_name%]" + } + }, + "name": "Timer cancelled" + }, + "finished": { + "description": "Triggers when one or more timers finish.", + "fields": { + "behavior": { + "name": "[%key:component::timer::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::trigger_for_name%]" + } + }, + "name": "Timer finished" + }, + "paused": { + "description": "Triggers when one or more timers are paused.", + "fields": { + "behavior": { + "name": "[%key:component::timer::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::trigger_for_name%]" + } + }, + "name": "Timer paused" + }, + "restarted": { + "description": "Triggers when one or more timers are restarted.", + "fields": { + "behavior": { + "name": "[%key:component::timer::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::trigger_for_name%]" + } + }, + "name": "Timer restarted" + }, + "started": { + "description": "Triggers when one or more timers are started.", + "fields": { + "behavior": { + "name": "[%key:component::timer::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::trigger_for_name%]" + } + }, + "name": "Timer started" + }, + "time_remaining": { + "description": "Triggers when one or more timers reach a specific remaining time.", + "fields": { + "remaining": { + "name": "Time remaining" + } + }, + "name": "Timer time remaining" + } + } } diff --git a/homeassistant/components/timer/trigger.py b/homeassistant/components/timer/trigger.py new file mode 100644 index 00000000000..776c9118573 --- /dev/null +++ b/homeassistant/components/timer/trigger.py @@ -0,0 +1,181 @@ +"""Provides triggers for timers.""" + +from datetime import datetime, timedelta +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.target import ( + TargetStateChangedData, + async_track_target_selector_state_change_event, +) +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + Trigger, + TriggerActionRunner, + TriggerConfig, + make_entity_target_state_trigger, +) +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE + +CONF_REMAINING = "remaining" + +TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_REMAINING): cv.positive_time_period_dict, + }, + } +) + + +class TimeRemainingTrigger(Trigger): + """Trigger when a timer has a specific amount of time remaining.""" + + _domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()} + _schema = TIME_REMAINING_TRIGGER_SCHEMA + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, cls._schema(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the time remaining trigger.""" + super().__init__(hass, config) + assert config.target is not None + self._target = config.target + options = config.options or {} + self._remaining: timedelta = options[CONF_REMAINING] + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities to timer domain.""" + return filter_by_domain_specs(self._hass, self._domain_specs, entities) + + @override + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + scheduled: dict[str, CALLBACK_TYPE] = {} + + @callback + def schedule_for_state( + entity_id: str, + to_state: State | None, + context: Context | None, + ) -> None: + """Schedule a fire for an active timer state, if applicable.""" + if to_state is None: + return + if to_state.state != STATUS_ACTIVE: + return + + finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT) + if finishes_at_str is None: + return + + finishes_at = dt_util.parse_datetime(finishes_at_str) + if finishes_at is None: + return + + fire_at = finishes_at - self._remaining + if fire_at <= dt_util.utcnow(): + return + + @callback + def fire_trigger(now: datetime) -> None: + """Fire the trigger.""" + scheduled.pop(entity_id, None) + run_action( + { + ATTR_ENTITY_ID: entity_id, + "to_state": to_state, + "remaining": self._remaining, + }, + f"time remaining of {entity_id}", + context, + ) + + scheduled[entity_id] = async_track_point_in_utc_time( + self._hass, fire_trigger, fire_at + ) + + @callback + def state_change_listener( + target_state_change_data: TargetStateChangedData, + ) -> None: + """Listen for state changes and schedule trigger.""" + event = target_state_change_data.state_change_event + entity_id: str = event.data["entity_id"] + to_state = event.data["new_state"] + + # Cancel any previously scheduled callback for this entity + if entity_id in scheduled: + scheduled.pop(entity_id)() + + schedule_for_state(entity_id, to_state, event.context) + + @callback + def on_entities_update(added: set[str], removed: set[str]) -> None: + """Handle changes to the tracked entity set.""" + for entity_id in removed: + if entity_id in scheduled: + scheduled.pop(entity_id)() + for entity_id in added: + state = self._hass.states.get(entity_id) + schedule_for_state(entity_id, state, state.context if state else None) + + unsub = async_track_target_selector_state_change_event( + self._hass, + self._target, + state_change_listener, + self.entity_filter, + on_entities_update, + ) + + @callback + def async_remove() -> None: + """Remove state listeners.""" + unsub() + for cancel in scheduled.values(): + cancel() + scheduled.clear() + + return async_remove + + +TRIGGERS: dict[str, type[Trigger]] = { + "cancelled": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "cancelled" + ), + "finished": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "finished" + ), + "paused": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "paused" + ), + "restarted": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "restarted" + ), + "started": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started" + ), + "time_remaining": TimeRemainingTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for timers.""" + return TRIGGERS diff --git a/homeassistant/components/timer/triggers.yaml b/homeassistant/components/timer/triggers.yaml new file mode 100644 index 00000000000..e40d8ddd75c --- /dev/null +++ b/homeassistant/components/timer/triggers.yaml @@ -0,0 +1,32 @@ +.trigger_common: &trigger_common + target: + entity: + domain: timer + fields: + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + +cancelled: *trigger_common +finished: *trigger_common +paused: *trigger_common +restarted: *trigger_common +started: *trigger_common + +time_remaining: + target: + entity: + domain: timer + fields: + remaining: + required: true + selector: + duration: diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 0d9f6ff8fb2..5a64fd0837f 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -1,6 +1,4 @@ -"""Support for TMB (Transports Metropolitans de Barcelona) Barcelona public transport.""" - -from __future__ import annotations +"""Support for TMB Barcelona public transport.""" from datetime import timedelta import logging diff --git a/homeassistant/components/tod/__init__.py b/homeassistant/components/tod/__init__.py index 3740c6b685f..d705ccc5d5d 100644 --- a/homeassistant/components/tod/__init__.py +++ b/homeassistant/components/tod/__init__.py @@ -1,7 +1,5 @@ """The Times of the Day integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 1ab34861a6e..fe371b126e5 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -1,7 +1,5 @@ """Support for representing current time of the day as binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, time, timedelta import logging diff --git a/homeassistant/components/tod/config_flow.py b/homeassistant/components/tod/config_flow.py index df9596f3a20..e40b6ca119f 100644 --- a/homeassistant/components/tod/config_flow.py +++ b/homeassistant/components/tod/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Times of the Day integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index f5110f41e59..59eb210eecb 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,7 +1,5 @@ """The todo integration.""" -from __future__ import annotations - from collections.abc import Callable, Iterable import copy import dataclasses diff --git a/homeassistant/components/todo/condition.py b/homeassistant/components/todo/condition.py new file mode 100644 index 00000000000..e3aebd4cd4a --- /dev/null +++ b/homeassistant/components/todo/condition.py @@ -0,0 +1,20 @@ +"""Provides conditions for to-do lists.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import ( + Condition, + make_entity_numerical_condition, + make_entity_state_condition, +) + +from .const import DOMAIN + +CONDITIONS: dict[str, type[Condition]] = { + "all_completed": make_entity_state_condition(DOMAIN, "0"), + "incomplete": make_entity_numerical_condition(DOMAIN), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the to-do list conditions.""" + return CONDITIONS diff --git a/homeassistant/components/todo/conditions.yaml b/homeassistant/components/todo/conditions.yaml new file mode 100644 index 00000000000..f4ecf7b7354 --- /dev/null +++ b/homeassistant/components/todo/conditions.yaml @@ -0,0 +1,40 @@ +.condition_common: &condition_common + target: &condition_todo_target + entity: + domain: todo + fields: + behavior: &condition_behavior + required: true + default: any + selector: + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: + +.incomplete_threshold_entity: &incomplete_threshold_entity + - domain: input_number + - domain: number + - domain: sensor + +.incomplete_threshold_number: &incomplete_threshold_number + min: 0 + mode: box + +all_completed: *condition_common + +incomplete: + target: *condition_todo_target + fields: + behavior: *condition_behavior + for: *condition_for + threshold: + required: true + selector: + numeric_threshold: + entity: *incomplete_threshold_entity + mode: is + number: *incomplete_threshold_number diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 3b0aa37fa7b..97d103b4a67 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -1,7 +1,5 @@ """Constants for the To-do integration.""" -from __future__ import annotations - from enum import IntFlag, StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/todo/icons.json b/homeassistant/components/todo/icons.json index 3addb8400c7..588b0e4b217 100644 --- a/homeassistant/components/todo/icons.json +++ b/homeassistant/components/todo/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "all_completed": { + "condition": "mdi:clipboard-check" + }, + "incomplete": { + "condition": "mdi:clipboard-alert" + } + }, "entity_component": { "_": { "default": "mdi:clipboard-list" diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index cc86b7a095f..78c0aca1c8e 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -1,7 +1,5 @@ """Intents for the todo integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.core import HomeAssistant @@ -88,10 +86,12 @@ class ListAddItemIntentHandler(ListBaseIntentHandler): async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None: """Execute action specific to this intent handler.""" + # Format item summary with first letter capitalized and rest as-is + summary = item[:1].upper() + item[1:] if item else item # Add to list await target_list.async_create_todo_item( - TodoItem(summary=item.capitalize(), status=TodoItemStatus.NEEDS_ACTION) + TodoItem(summary=summary, status=TodoItemStatus.NEEDS_ACTION) ) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4bf0565f135..eb6fe5b9b62 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,4 +1,38 @@ { + "common": { + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "condition_threshold_name": "Threshold type" + }, + "conditions": { + "all_completed": { + "description": "Tests if all to-do items are completed in one or more to-do lists.", + "fields": { + "behavior": { + "name": "[%key:component::todo::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::todo::common::condition_for_name%]" + } + }, + "name": "All to-do items completed" + }, + "incomplete": { + "description": "Tests the number of incomplete to-do items in one or more to-do lists.", + "fields": { + "behavior": { + "name": "[%key:component::todo::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::todo::common::condition_for_name%]" + }, + "threshold": { + "name": "[%key:component::todo::common::condition_threshold_name%]" + } + }, + "name": "Incomplete to-do items" + } + }, "entity_component": { "_": { "name": "[%key:component::todo::title%]" diff --git a/homeassistant/components/todo/trigger.py b/homeassistant/components/todo/trigger.py index 8387850f6e5..b4299f71ed5 100644 --- a/homeassistant/components/todo/trigger.py +++ b/homeassistant/components/todo/trigger.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET +from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -25,6 +25,7 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus ITEM_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, + vol.Required(CONF_OPTIONS, default={}): {}, } ) @@ -77,7 +78,7 @@ class ItemChangeListener(TargetEntityChangeTracker): @override @callback def _handle_entities_update(self, tracked_entities: set[str]) -> None: - """Restart the listeners when the list of entities of the tracked targets is updated.""" + """Restart listeners when tracked target entities change.""" if self._pending_listener_task: self._pending_listener_task.cancel() self._pending_listener_task = self._hass.async_create_task( diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 2e30856d0df..56793e52398 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -5,12 +5,10 @@ import logging from todoist_api_python.api_async import TodoistAPIAsync -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import TodoistCoordinator +from .coordinator import TodoistConfigEntry, TodoistCoordinator _LOGGER = logging.getLogger(__name__) @@ -20,7 +18,7 @@ SCAN_INTERVAL = datetime.timedelta(minutes=1) PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TodoistConfigEntry) -> bool: """Set up todoist from a config entry.""" token = entry.data[CONF_TOKEN] @@ -28,17 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = TodoistCoordinator(hass, _LOGGER, entry, SCAN_INTERVAL, api, token) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TodoistConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 509ce593699..9e562e5dc36 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,7 +1,5 @@ """Support for Todoist task management (https://todoist.com).""" -from __future__ import annotations - from datetime import date, datetime, timedelta import logging from typing import Any @@ -16,7 +14,6 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEvent, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError @@ -60,7 +57,7 @@ from .const import ( START, SUMMARY, ) -from .coordinator import TodoistCoordinator, flatten_async_pages +from .coordinator import TodoistConfigEntry, TodoistCoordinator, flatten_async_pages from .types import CalData, CustomProject, ProjectData, TodoistEvent from .util import parse_due_date @@ -116,11 +113,11 @@ SCAN_INTERVAL = timedelta(minutes=1) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TodoistConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist calendar platform config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data projects = await coordinator.async_get_projects() labels = await coordinator.async_get_labels() @@ -354,6 +351,7 @@ def async_register_services( # noqa: C901 _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA ) diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index 41e7602836e..58bf01b954b 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import AsyncGenerator from datetime import timedelta import logging -from typing import TypeVar from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label, Project, Section, Task @@ -15,10 +14,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import MAX_PAGE_SIZE -T = TypeVar("T") +type TodoistConfigEntry = ConfigEntry[TodoistCoordinator] -async def flatten_async_pages( +async def flatten_async_pages[T]( pages: AsyncGenerator[list[T]], ) -> list[T]: """Flatten paginated results from an async generator.""" diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index ec2c38c35ff..2d55d0ecea2 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -12,23 +12,21 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import TodoistCoordinator +from .coordinator import TodoistConfigEntry, TodoistCoordinator from .util import parse_due_date async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TodoistConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist todo platform config entry.""" - coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data projects = await coordinator.async_get_projects() async_add_entities( TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name) diff --git a/homeassistant/components/todoist/types.py b/homeassistant/components/todoist/types.py index da716131695..0a6c49a711c 100644 --- a/homeassistant/components/todoist/types.py +++ b/homeassistant/components/todoist/types.py @@ -1,7 +1,5 @@ """Types for the Todoist component.""" -from __future__ import annotations - from datetime import datetime from typing import TypedDict diff --git a/homeassistant/components/todoist/util.py b/homeassistant/components/todoist/util.py index 430db133ba8..bfec7f96cdb 100644 --- a/homeassistant/components/todoist/util.py +++ b/homeassistant/components/todoist/util.py @@ -1,7 +1,5 @@ """Utility functions for the Todoist integration.""" -from __future__ import annotations - from datetime import date, datetime from todoist_api_python.models import Due diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index 280a23ba538..bea0797b6cb 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -1,7 +1,5 @@ """The ToGrill integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py index 46b2853779e..d8c4850d7a8 100644 --- a/homeassistant/components/togrill/config_flow.py +++ b/homeassistant/components/togrill/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the ToGrill integration.""" -from __future__ import annotations - from typing import Any from bleak.exc import BleakError @@ -10,6 +8,7 @@ from togrill_bluetooth.client import Client from togrill_bluetooth.packets import PacketA0Notify import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -115,6 +114,7 @@ class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_infos[address] ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, True): address = discovery_info.address diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 6f2419ef821..5743f1b1108 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the ToGrill Bluetooth integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import timedelta diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py index 4e25332e73e..39cceb36753 100644 --- a/homeassistant/components/togrill/entity.py +++ b/homeassistant/components/togrill/entity.py @@ -1,7 +1,5 @@ """Provides the base entities.""" -from __future__ import annotations - from bleak.exc import BleakError from togrill_bluetooth.client import Client from togrill_bluetooth.exceptions import BaseError diff --git a/homeassistant/components/togrill/event.py b/homeassistant/components/togrill/event.py index a598ec70a3c..d01d67e544c 100644 --- a/homeassistant/components/togrill/event.py +++ b/homeassistant/components/togrill/event.py @@ -1,7 +1,5 @@ """Support for event entities.""" -from __future__ import annotations - from togrill_bluetooth.packets import Packet, PacketA5Notify from homeassistant.components.event import EventEntity diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py index fa6f0b69ae8..833763af1c1 100644 --- a/homeassistant/components/togrill/number.py +++ b/homeassistant/components/togrill/number.py @@ -1,7 +1,5 @@ """Support for number entities.""" -from __future__ import annotations - from collections.abc import Callable, Generator, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/togrill/select.py b/homeassistant/components/togrill/select.py index 39644313cf2..eceed13ea82 100644 --- a/homeassistant/components/togrill/select.py +++ b/homeassistant/components/togrill/select.py @@ -1,11 +1,9 @@ """Support for select entities.""" -from __future__ import annotations - from collections.abc import Callable, Generator, Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, TypeVar +from typing import Any from togrill_bluetooth.packets import ( GrillType, @@ -39,17 +37,14 @@ class ToGrillSelectEntityDescription(SelectEntityDescription): probe_number: int | None = None -_ENUM = TypeVar("_ENUM", bound=Enum) - - -def _get_enum_from_name(type_: type[_ENUM], value: str) -> _ENUM | None: +def _get_enum_from_name[T: Enum](type_: type[T], value: str) -> T | None: """Return enum value or None.""" if value == OPTION_NONE: return None return type_[value.upper()] -def _get_enum_from_value(type_: type[_ENUM], value: int | None) -> _ENUM | None: +def _get_enum_from_value[T: Enum](type_: type[T], value: int | None) -> T | None: """Return enum value or None.""" if value is None: return None @@ -59,7 +54,7 @@ def _get_enum_from_value(type_: type[_ENUM], value: int | None) -> _ENUM | None: return None -def _get_enum_options(type_: type[_ENUM]) -> list[str]: +def _get_enum_options[T: Enum](type_: type[T]) -> list[str]: """Return a list of enum options.""" values = [OPTION_NONE] values.extend(option.name.lower() for option in type_) diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py index affe03f6d6b..03723f36712 100644 --- a/homeassistant/components/togrill/sensor.py +++ b/homeassistant/components/togrill/sensor.py @@ -1,7 +1,5 @@ """Support for sensor entities.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index bbd17cc8b13..bc6af470994 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -1,7 +1,5 @@ """Component to control TOLO Sauna/Steam Bath.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index ed7ab0c3b76..d18b5cef129 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -1,7 +1,5 @@ """TOLO Sauna climate controls (main sauna control).""" -from __future__ import annotations - from typing import Any from tololib import ( diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index 7b97fb20343..8add97e5c9a 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for TOLO integration.""" -from __future__ import annotations - import logging from types import MappingProxyType from typing import Any diff --git a/homeassistant/components/tolo/coordinator.py b/homeassistant/components/tolo/coordinator.py index 372c67a4260..a23c391ef74 100644 --- a/homeassistant/components/tolo/coordinator.py +++ b/homeassistant/components/tolo/coordinator.py @@ -1,7 +1,5 @@ """Component to control TOLO Sauna/Steam Bath.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import NamedTuple diff --git a/homeassistant/components/tolo/entity.py b/homeassistant/components/tolo/entity.py index c6aef0fb824..26c24973b99 100644 --- a/homeassistant/components/tolo/entity.py +++ b/homeassistant/components/tolo/entity.py @@ -1,7 +1,5 @@ """Component to control TOLO Sauna/Steam Bath.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 41ca94055ba..fc39b953c4a 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -1,7 +1,5 @@ """TOLO Sauna fan controls.""" -from __future__ import annotations - from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index 25e1e913544..442c762f64e 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -1,7 +1,5 @@ """TOLO Sauna light controls.""" -from __future__ import annotations - from typing import Any from homeassistant.components.light import ColorMode, LightEntity diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index db06b82d002..8a54ec4d8f6 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -1,7 +1,5 @@ """TOLO Sauna number controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index f487fba9664..a608ed6dc1b 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -1,7 +1,5 @@ """TOLO Sauna Select controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index dc2e421ff55..75e097962da 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -1,7 +1,5 @@ """TOLO Sauna (non-binary, general) sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py index 686f78b04e9..5341f996b67 100644 --- a/homeassistant/components/tolo/switch.py +++ b/homeassistant/components/tolo/switch.py @@ -1,7 +1,5 @@ """TOLO Sauna switch controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index 2cef5eea0cf..318aa4d2f52 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -1,7 +1,5 @@ """Support for Tomato routers.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 7d6b9ed3f73..9d1e20201d4 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -1,6 +1,5 @@ """The Tomorrow.io integration.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from pytomorrowio import TomorrowioV4 @@ -29,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will not use the class's lat and long so we can pass in garbage # lats and longs api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) - coordinator = TomorrowioDataUpdateCoordinator(hass, entry, api) + coordinator = TomorrowioDataUpdateCoordinator(hass, api) hass.data[DOMAIN][api_key] = coordinator await coordinator.async_setup_entry(entry) @@ -49,6 +48,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> coordinator: TomorrowioDataUpdateCoordinator = hass.data[DOMAIN][api_key] # If this is true, we can remove the coordinator if await coordinator.async_unload_entry(config_entry): + await coordinator.async_shutdown() hass.data[DOMAIN].pop(api_key) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 71674a646cb..a624842685c 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tomorrow.io integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py index e727be38b16..caec13dc3ab 100644 --- a/homeassistant/components/tomorrowio/const.py +++ b/homeassistant/components/tomorrowio/const.py @@ -1,7 +1,5 @@ """Constants for the Tomorrow.io integration.""" -from __future__ import annotations - import logging from pytomorrowio.const import DAILY, HOURLY, NOWCAST, WeatherCode diff --git a/homeassistant/components/tomorrowio/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py index 2a6b3675792..01425121ba4 100644 --- a/homeassistant/components/tomorrowio/coordinator.py +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -1,7 +1,5 @@ """The Tomorrow.io integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta from math import ceil @@ -24,6 +22,7 @@ from homeassistant.const import ( CONF_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -116,11 +115,9 @@ def async_set_update_interval( class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an object to hold Tomorrow.io data.""" - config_entry: ConfigEntry + config_entry: None - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: TomorrowioV4 - ) -> None: + def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: """Initialize.""" self._api = api self.data = {CURRENT: {}, FORECASTS: {}} @@ -130,7 +127,7 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, - config_entry=config_entry, + config_entry=None, name=f"{DOMAIN}_{self._api.api_key_masked}", ) @@ -158,7 +155,15 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): "Loaded %s entries, initiating first refresh", len(self.entry_id_to_location_dict), ) - await self.async_config_entry_first_refresh() + await self._async_refresh( + log_failures=False, + raise_on_auth_failed=True, + raise_on_entry_error=True, + ) + if not self.last_update_success: + ex = ConfigEntryNotReady() + ex.__cause__ = self.last_exception + raise ex self._coordinator_ready.set() else: # If we have an event, we need to wait for it to be set before we proceed @@ -184,7 +189,7 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._listeners: self._schedule_refresh() - async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: + async def async_unload_entry(self, entry: ConfigEntry) -> bool: """Unload a config entry from coordinator. Returns whether coordinator can be removed as well because there are no diff --git a/homeassistant/components/tomorrowio/entity.py b/homeassistant/components/tomorrowio/entity.py index f00677b1561..baf3e28a486 100644 --- a/homeassistant/components/tomorrowio/entity.py +++ b/homeassistant/components/tomorrowio/entity.py @@ -1,7 +1,5 @@ """The Tomorrow.io integration.""" -from __future__ import annotations - from typing import Any from pytomorrowio.const import CURRENT diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index f288f011061..0de3c07a136 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -1,7 +1,5 @@ """Sensor component that handles additional Tomorrowio data for your location.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass @@ -331,6 +329,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entities = [ TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 36b85515c3c..244a2bb7f81 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -1,7 +1,5 @@ """Weather component that handles meteorological data for your location.""" -from __future__ import annotations - from datetime import datetime from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode @@ -69,6 +67,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entity_registry = er.async_get(hass) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 919a146ec93..10d1e5a0bcb 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.typing import ConfigType from .const import CONF_AGREEMENT_ID, CONF_MIGRATE, DEFAULT_SCAN_INTERVAL, DOMAIN -from .coordinator import ToonDataUpdateCoordinator +from .coordinator import ToonConfigEntry, ToonDataUpdateCoordinator from .oauth2 import register_oauth2_implementations PLATFORMS = [ @@ -94,7 +94,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ToonConfigEntry) -> bool: """Set up Toon from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -111,8 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator # Register device for the Meter Adapter, since it will have no entities. device_registry = dr.async_get(hass) @@ -145,17 +144,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ToonConfigEntry) -> bool: """Unload Toon config entry.""" # Remove webhooks registration - await hass.data[DOMAIN][entry.entry_id].unregister_webhook() + await entry.runtime_data.unregister_webhook() # Unload entities for this entry/device. - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # Cleanup - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index eff8aed0a20..601ee104ee9 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Toon binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.binary_sensor import ( @@ -9,12 +7,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import ToonDataUpdateCoordinator +from .coordinator import ToonConfigEntry, ToonDataUpdateCoordinator from .entity import ( ToonBoilerDeviceEntity, ToonBoilerModuleDeviceEntity, @@ -26,11 +23,11 @@ from .entity import ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ToonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensor based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ description.cls(coordinator, description) diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 5538a0abd91..b5dded48c20 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -1,7 +1,5 @@ """Support for Toon thermostat.""" -from __future__ import annotations - from typing import Any from toonapi import ( @@ -21,24 +19,23 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ToonDataUpdateCoordinator from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN +from .coordinator import ToonConfigEntry, ToonDataUpdateCoordinator from .entity import ToonDisplayDeviceEntity from .helpers import toon_exception_handler async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ToonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([ToonThermostatDevice(coordinator)]) diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py index ab5ff6d87e3..a000480e312 100644 --- a/homeassistant/components/toon/config_flow.py +++ b/homeassistant/components/toon/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Toon component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 894b4c91334..de20329a04b 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -1,7 +1,5 @@ """Provides the Toon DataUpdateCoordinator.""" -from __future__ import annotations - import logging import secrets @@ -24,14 +22,16 @@ from .const import CONF_CLOUDHOOK_URL, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) +type ToonConfigEntry = ConfigEntry[ToonDataUpdateCoordinator] + class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): """Class to manage fetching Toon data from single endpoint.""" - config_entry: ConfigEntry + config_entry: ToonConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session + self, hass: HomeAssistant, entry: ToonConfigEntry, session: OAuth2Session ) -> None: """Initialize global Toon data updater.""" self.session = session diff --git a/homeassistant/components/toon/entity.py b/homeassistant/components/toon/entity.py index 0c08c10bfaf..05e9c97b880 100644 --- a/homeassistant/components/toon/entity.py +++ b/homeassistant/components/toon/entity.py @@ -1,7 +1,5 @@ """DataUpdate Coordinator, and base Entity and Device models for Toon.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index d65a6d76676..97e4606fb04 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -1,16 +1,14 @@ """Helpers for Toon.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine -import logging from typing import Any, Concatenate from toonapi import ToonConnectionError, ToonError -from .entity import ToonEntity +from homeassistant.exceptions import HomeAssistantError -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .entity import ToonEntity def toon_exception_handler[_ToonEntityT: ToonEntity, **_P]( @@ -19,20 +17,24 @@ def toon_exception_handler[_ToonEntityT: ToonEntity, **_P]( """Decorate Toon calls to handle Toon exceptions. A decorator that wraps the passed in function, catches Toon errors, - and handles the availability of the device in the data coordinator. + and raises a translated ``HomeAssistantError``. """ async def handler(self: _ToonEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() - except ToonConnectionError as error: - _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False self.coordinator.async_update_listeners() - + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error except ToonError as error: - _LOGGER.error("Invalid response from API: %s", error) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_response", + ) from error return handler diff --git a/homeassistant/components/toon/icons.json b/homeassistant/components/toon/icons.json deleted file mode 100644 index 217f1240893..00000000000 --- a/homeassistant/components/toon/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "update": { - "service": "mdi:update" - } - } -} diff --git a/homeassistant/components/toon/oauth2.py b/homeassistant/components/toon/oauth2.py index 2535cc5de7d..c926a767155 100644 --- a/homeassistant/components/toon/oauth2.py +++ b/homeassistant/components/toon/oauth2.py @@ -1,7 +1,5 @@ """OAuth2 implementations for Toon.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index e5b155b409b..1c2db7e55c0 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -1,7 +1,5 @@ """Support for Toon sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( @@ -10,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfEnergy, @@ -22,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN -from .coordinator import ToonDataUpdateCoordinator +from .coordinator import ToonConfigEntry, ToonDataUpdateCoordinator from .entity import ( ToonBoilerDeviceEntity, ToonDisplayDeviceEntity, @@ -37,11 +34,11 @@ from .entity import ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ToonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Toon sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ description.cls(coordinator, description) for description in SENSOR_ENTITIES diff --git a/homeassistant/components/toon/services.yaml b/homeassistant/components/toon/services.yaml deleted file mode 100644 index 1b75dd4957a..00000000000 --- a/homeassistant/components/toon/services.yaml +++ /dev/null @@ -1,7 +0,0 @@ -update: - fields: - display: - advanced: true - example: eneco-001-123456 - selector: - text: diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 5cb42d9f333..3aeca0734ae 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -34,20 +34,14 @@ } }, "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Toon device." + }, + "invalid_response": { + "message": "Received an invalid response from the Toon device." + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } - }, - "services": { - "update": { - "description": "Updates all entities with fresh data from Toon.", - "fields": { - "display": { - "description": "Toon display to update.", - "name": "Display" - } - }, - "name": "Update" - } } } diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index d59a542d4d8..c941a2a45f0 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -1,7 +1,5 @@ """Support for Toon switches.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any @@ -13,23 +11,21 @@ from toonapi import ( ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ToonDataUpdateCoordinator +from .coordinator import ToonConfigEntry, ToonDataUpdateCoordinator from .entity import ToonDisplayDeviceEntity, ToonEntity, ToonRequiredKeysMixin from .helpers import toon_exception_handler async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ToonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon switches based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [description.cls(coordinator, description) for description in SWITCH_ENTITIES] diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 01dbf0237ab..6062c7a2595 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -1,7 +1,5 @@ """Support for the Torque OBD application.""" -from __future__ import annotations - import re from aiohttp import web diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index e31e6085832..5b75461be83 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -1,7 +1,5 @@ """Interfaces with TotalConnect alarm control panels.""" -from __future__ import annotations - from total_connect_client import ArmingHelper from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid from total_connect_client.location import TotalConnectLocation diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 33e82dcaf53..c4d29d22de9 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Total Connect component.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index fc310bf850c..08cf331d4f5 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for TotalConnect.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/touchline/__init__.py b/homeassistant/components/touchline/__init__.py index 3d9fffca9dd..53f8be3e878 100644 --- a/homeassistant/components/touchline/__init__.py +++ b/homeassistant/components/touchline/__init__.py @@ -1,7 +1,5 @@ """The touchline component.""" -from __future__ import annotations - import logging from pytouchline_extended import PyTouchline diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index df0afa2ef0d..71aae8b9d12 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -1,7 +1,5 @@ """Platform for Roth Touchline floor heating controller.""" -from __future__ import annotations - from typing import Any, NamedTuple from pytouchline_extended import PyTouchline diff --git a/homeassistant/components/touchline/config_flow.py b/homeassistant/components/touchline/config_flow.py index 64c249923f2..ab9490a9a94 100644 --- a/homeassistant/components/touchline/config_flow.py +++ b/homeassistant/components/touchline/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Roth Touchline integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/touchline/data.py b/homeassistant/components/touchline/data.py index 2d6fa324abd..906380e51fe 100644 --- a/homeassistant/components/touchline/data.py +++ b/homeassistant/components/touchline/data.py @@ -1,7 +1,5 @@ """Custom types for Touchline.""" -from __future__ import annotations - from dataclasses import dataclass from pytouchline_extended import PyTouchline diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py index 804aaa46c72..2f613a41ba0 100644 --- a/homeassistant/components/touchline_sl/__init__.py +++ b/homeassistant/components/touchline_sl/__init__.py @@ -1,7 +1,5 @@ """The Roth Touchline SL integration.""" -from __future__ import annotations - import asyncio from pytouchlinesl import TouchlineSL diff --git a/homeassistant/components/touchline_sl/config_flow.py b/homeassistant/components/touchline_sl/config_flow.py index 91d959b5a0a..894ac98e5f6 100644 --- a/homeassistant/components/touchline_sl/config_flow.py +++ b/homeassistant/components/touchline_sl/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Roth Touchline SL integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/touchline_sl/coordinator.py b/homeassistant/components/touchline_sl/coordinator.py index dce616a81b3..93dafc27da8 100644 --- a/homeassistant/components/touchline_sl/coordinator.py +++ b/homeassistant/components/touchline_sl/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching Touchline SL data.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/touchline_sl/sensor.py b/homeassistant/components/touchline_sl/sensor.py index 7d520ff51ce..cfe4d05de0a 100644 --- a/homeassistant/components/touchline_sl/sensor.py +++ b/homeassistant/components/touchline_sl/sensor.py @@ -1,7 +1,5 @@ """Roth Touchline SL sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 5b3456cc2ac..fa807237709 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,6 +1,5 @@ """Component to embed TP-Link smart home devices.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Iterable diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 38935595fe2..d5dd070bd3e 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -1,7 +1,5 @@ """Support for TPLink binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final, cast diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 145adb79185..0f3562d2562 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -1,7 +1,5 @@ """Support for TPLink button entities.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 7b59678da8e..a4b50f63308 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -196,7 +196,8 @@ class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera): _LOGGER.debug( "Empty camera image returned for %s", self._device.host ) - # image could be empty if a stream is running so check for explicit auth error + # image could be empty if a stream is + # running so check for explicit auth error await self._async_check_stream_auth(video_url) else: _LOGGER.debug( diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index c8e7dee8d73..4c98ddd9e34 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -1,7 +1,5 @@ """Support for TP-Link thermostats.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 0914c4191cf..fdef80f2e86 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for TP-Link.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any, Self, cast @@ -131,7 +129,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): if not updates: return None updates = {**entry.data, **updates} - # If the connection parameters have changed the credentials_hash will be invalid. + # If the connection parameters have changed the + # credentials_hash will be invalid. if new_connection_params: updates.pop(CONF_CREDENTIALS_HASH, None) _LOGGER.debug( @@ -146,7 +145,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): def _update_config_if_entry_in_setup_error( self, entry: ConfigEntry, host: str, device: Device | None ) -> ConfigFlowResult | None: - """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config.""" + """Update device config if discovery finds a device in error state.""" if entry.state not in ( ConfigEntryState.SETUP_ERROR, ConfigEntryState.SETUP_RETRY, @@ -216,7 +215,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device, credentials ) except AuthenticationError: - pass # Authentication exceptions should continue to the rest of the step + pass else: self._discovered_device = device return await self.async_step_discovery_confirm() @@ -553,7 +552,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices = await async_discover_devices(self.hass) devices_name = { formatted_mac: ( - f"{device.alias or mac_alias(device.mac)} {device.model} ({device.host}) {formatted_mac}" + f"{device.alias or mac_alias(device.mac)}" + f" {device.model} ({device.host})" + f" {formatted_mac}" ) for formatted_mac, device in self._discovered_devices.items() if formatted_mac not in configured_devices diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 2df7101791a..d9aad68ecd8 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -1,7 +1,5 @@ """Const for TP-Link.""" -from __future__ import annotations - from typing import Final from kasa.smart.modules.clean import AreaUnit diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 1a7b40457f0..1c85d2feca6 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -1,7 +1,5 @@ """Component to embed TP-Link smart home devices.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py index 86d4f66cdc0..ba37dd13ab6 100644 --- a/homeassistant/components/tplink/deprecate.py +++ b/homeassistant/components/tplink/deprecate.py @@ -1,7 +1,5 @@ """Helper class for deprecating entities.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index 46a5f0cb1bd..a6c5d2ac9e6 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for TPLink.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 7c1e9e72b85..93f4dac6534 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -1,7 +1,5 @@ """Common code for tplink.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from dataclasses import dataclass, replace @@ -435,7 +433,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): parent=parent, ) for feat in device.features.values() - if feat.type == feature_type + if feat.type is feature_type and feat.id not in EXCLUDED_FEATURES and ( feat.category is not Feature.Category.Primary @@ -477,7 +475,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) -> list[_E]: """Create entities for device and its children. - This is a helper that calls *_entities_for_device* for the device and its children. + Calls *_entities_for_device* for the device and its children. """ entities: list[_E] = [] # Add parent entities before children so via_device id works. @@ -622,7 +620,7 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): ) -> list[_E]: """Create entities for device and its children. - This is a helper that calls *_entities_for_device* for the device and its children. + Calls *_entities_for_device* for the device and its children. """ entities: list[_E] = [] diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index dd4fc7b01e8..9934bf52b38 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -1,7 +1,5 @@ """Support for TPLink lights.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass import logging diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 4fc9995a2a9..34ad5197c81 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -1,7 +1,5 @@ """Support for TPLink number entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Final, cast diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 72042f571e6..0e456cd9d32 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -1,7 +1,5 @@ """Support for TPLink select entities.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Final, cast diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 967853da629..f8ead80a889 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,7 +1,5 @@ """Support for TPLink sensor entities.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from operator import methodcaller diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index a0a173107d9..a084c85d80e 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -1,7 +1,5 @@ """Support for TPLink siren entity.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import math diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 3cb20d63cd7..4b4c84b4910 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,7 +1,5 @@ """Support for TPLink switch entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any, cast diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index e948e778be4..2cd8bf5aac5 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -1,7 +1,5 @@ """Support for TPLink vacuum.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 3435b1cfea3..6f92a8757d0 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -1,7 +1,5 @@ """The TP-Link Omada integration.""" -from __future__ import annotations - from tplink_omada_client import OmadaSite from tplink_omada_client.devices import OmadaListDevice from tplink_omada_client.exceptions import ( diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index a8260f555ef..266c2da2e82 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -1,7 +1,5 @@ """Support for TPLink Omada binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index debd7832a3d..ab20ed8bee3 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -1,7 +1,5 @@ """Config flow for TP-Link Omada integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging import re @@ -59,7 +57,8 @@ async def create_omada_client( and re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", host_parts.hostname) is not None ): - # TP-Link API uses cookies for login session, so an unsafe cookie jar is required for IP addresses + # TP-Link API uses cookies for login session, + # so an unsafe cookie jar is required for IPs websession = async_create_clientsession( hass, cookie_jar=CookieJar(unsafe=True), verify_ssl=verify_ssl ) diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 6956f975908..fbb4b671296 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,7 +1,5 @@ """Controller for sharing Omada API coordinators between platforms.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING @@ -71,7 +69,8 @@ class OmadaSiteController: Args: device_filter: Function that returns True if a device should be processed. - entity_callback: Given a discovered Omada device, creates entities for that device. + entity_callback: Given a discovered Omada device, + creates entities for that device. """ # Track which devices have been processed already processed_devices: set[str] = set() diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 8191a47c6c7..7fa0817861e 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,7 +1,5 @@ """Generic Omada API coordinator.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -160,7 +158,7 @@ class FirmwareUpdateStatus(NamedTuple): class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): - """Coordinator for getting details about available firmware updates for Omada devices.""" + """Coordinator for Omada device firmware update details.""" def __init__( self, diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py index 610be048174..0b4e568a0fc 100644 --- a/homeassistant/components/tplink_omada/device_tracker.py +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -43,6 +43,7 @@ class OmadaClientScannerEntity( ): """Entity for a client connected to the Omada network.""" + _attr_has_entity_name = True _client_details: OmadaWirelessClient | None = None def __init__( diff --git a/homeassistant/components/tplink_omada/diagnostics.py b/homeassistant/components/tplink_omada/diagnostics.py new file mode 100644 index 00000000000..fcb747eb0e4 --- /dev/null +++ b/homeassistant/components/tplink_omada/diagnostics.py @@ -0,0 +1,127 @@ +"""Diagnostics support for TP-Link Omada.""" + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from . import OmadaConfigEntry + +ENTRY_TO_REDACT = { + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +} + +RUNTIME_TO_REDACT = { + "addr", + "echoServer", + "gateway", + "gateway2", + "hostName", + "ip", + "priDns", + "priDns2", + "sndDns", + "sndDns2", + "ssid", + "sn", + "omadacId", +} + + +def _build_identifier_replacements(mac_values: set[str]) -> dict[str, str]: + """Build deterministic replacement values for network identifiers.""" + replacements: dict[str, str] = {} + + for index, raw_mac in enumerate(sorted(mac_values)): + pseudonym = format_mac(str(index).zfill(12)) + variants = {raw_mac, raw_mac.upper(), raw_mac.lower()} + + normalized = format_mac(raw_mac) + variants.update({normalized, normalized.upper(), normalized.lower()}) + + for variant in variants: + replacements[variant] = pseudonym + + return replacements + + +def _replace_identifiers(data: Any, to_replace: Mapping[str, str]) -> Any: + """Replace network identifiers in nested diagnostics payloads.""" + if isinstance(data, Mapping): + return { + key: _replace_identifiers(value, to_replace) for key, value in data.items() + } + + if isinstance(data, list): + return [_replace_identifiers(item, to_replace) for item in data] + + if isinstance(data, str): + return to_replace.get(data, data) + + return data + + +def _redact_runtime_record( + raw_data: Mapping[str, Any], replacements: Mapping[str, str] +) -> dict[str, Any]: + """Apply identifier replacement and key redaction to runtime data.""" + return async_redact_data( + _replace_identifiers(raw_data, replacements), + RUNTIME_TO_REDACT, + ) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OmadaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + controller = entry.runtime_data + + devices = controller.devices_coordinator.data + clients = controller.clients_coordinator.data + + gateway_data: dict[str, Any] | None = None + if ( + gateway_coordinator := controller.gateway_coordinator + ) and gateway_coordinator.data: + gateway = next(iter(gateway_coordinator.data.values())) + gateway_data = gateway.raw_data + + mac_values = set(devices) | set(clients) + for client in clients.values(): + if ap_mac := client.raw_data.get("apMac"): + mac_values.add(ap_mac) + if gateway_data and (gateway_mac := gateway_data.get("mac")): + mac_values.add(gateway_mac) + + replacements = _build_identifier_replacements(mac_values) + + return { + "entry": async_redact_data(entry.as_dict(), ENTRY_TO_REDACT), + "runtime": { + "devices": { + replacements[mac]: _redact_runtime_record( + device.raw_data, + replacements, + ) + for mac, device in devices.items() + }, + "clients": { + replacements[mac]: _redact_runtime_record( + client.raw_data, + replacements, + ) + for mac, client in clients.items() + }, + "gateway": ( + _redact_runtime_record(gateway_data, replacements) + if gateway_data is not None + else None + ), + }, + } diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index ace158c44ea..8259d41f47a 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py index b5fd81dadfb..99a95565669 100644 --- a/homeassistant/components/tplink_omada/sensor.py +++ b/homeassistant/components/tplink_omada/sensor.py @@ -1,7 +1,5 @@ """Support for TPLink Omada binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -86,7 +84,7 @@ async def async_setup_entry( @dataclass(frozen=True, kw_only=True) class OmadaDeviceSensorEntityDescription(SensorEntityDescription): - """Entity description for a status derived from an Omada device in the device list.""" + """Entity description for status from an Omada device.""" exists_func: Callable[[OmadaListDevice], bool] = lambda _: True update_func: Callable[[OmadaListDevice], StateType] diff --git a/homeassistant/components/tplink_omada/services.py b/homeassistant/components/tplink_omada/services.py index d6d10f45cec..a7ff10413a5 100644 --- a/homeassistant/components/tplink_omada/services.py +++ b/homeassistant/components/tplink_omada/services.py @@ -6,6 +6,7 @@ from tplink_omada_client.exceptions import OmadaClientException import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, selector @@ -15,7 +16,6 @@ from .controller import OmadaSiteController SERVICE_RECONNECT_CLIENT = "reconnect_client" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_MAC = "mac" @@ -27,7 +27,8 @@ def _get_controller(call: ServiceCall) -> OmadaSiteController: if not entry: raise ServiceValidationError("Specified TP-Link Omada controller not found") else: - # Assume first loaded entry if none specified (for backward compatibility/99% use case) + # Assume first loaded entry if none specified + # (for backward compatibility/99% use case) entries = call.hass.config_entries.async_entries(DOMAIN) if len(entries) == 0: raise ServiceValidationError("No active TP-Link Omada controllers found") diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 26149d779ea..c380756c640 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -1,11 +1,9 @@ """Support for TPLink Omada device toggle options.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import partial -from typing import Any, Generic, TypeVar +from typing import Any from tplink_omada_client import ( GatewayPortSettings, @@ -40,10 +38,6 @@ from .controller import OmadaGatewayCoordinator, OmadaSwitchPortCoordinator from .coordinator import OmadaCoordinator from .entity import OmadaDeviceEntity -TPort = TypeVar("TPort") -TDevice = TypeVar("TDevice", bound="OmadaDevice") -TCoordinator = TypeVar("TCoordinator", bound="OmadaCoordinator[Any]") - PARALLEL_UPDATES = 0 @@ -82,7 +76,8 @@ async def async_setup_entry( ) async_add_entities(entities) - # Register switch port entities for switches that are connected, such that we can determine the port information + # Register switch port entities for switches that are + # connected, so we can determine the port information await controller.async_register_device_entities( device_filter=lambda d: ( d.type == "switch" and d.status_category == DeviceStatusCategory.CONNECTED @@ -134,10 +129,12 @@ def _get_switch_port_base_name(port: OmadaSwitchPortDetails) -> str: @dataclass(frozen=True, kw_only=True) -class OmadaDevicePortSwitchEntityDescription( - SwitchEntityDescription, Generic[TCoordinator, TDevice, TPort] -): - """Entity description for a toggle switch derived from a network port on an Omada device.""" +class OmadaDevicePortSwitchEntityDescription[ + TCoordinator: OmadaCoordinator[Any], + TDevice: OmadaDevice, + TPort, +](SwitchEntityDescription): + """Entity description for a port toggle on an Omada device.""" exists_func: Callable[[TDevice, TPort], bool] = lambda _, p: True coordinator_update_func: Callable[[TCoordinator, TDevice, TPort], TPort | None] @@ -151,7 +148,7 @@ class OmadaSwitchPortSwitchEntityDescription( OmadaSwitchPortCoordinator, OmadaSwitch, OmadaSwitchPortDetails ] ): - """Entity description for a toggle switch for a feature of a Port on an Omada Switch.""" + """Entity description for a switch port feature toggle.""" coordinator_update_func: Callable[ [OmadaSwitchPortCoordinator, OmadaSwitch, OmadaSwitchPortDetails], @@ -165,7 +162,7 @@ class OmadaGatewayPortConfigSwitchEntityDescription( OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortConfig ] ): - """Entity description for a toggle switch for a configuration of a Port on an Omada Gateway.""" + """Entity description for a gateway port config toggle.""" coordinator_update_func: Callable[ [OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortConfig], @@ -183,7 +180,7 @@ class OmadaGatewayPortStatusSwitchEntityDescription( OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortStatus ] ): - """Entity description for a toggle switch for a status of a Port on an Omada Gateway.""" + """Entity description for a gateway port status toggle.""" coordinator_update_func: Callable[ [OmadaGatewayCoordinator, OmadaGateway, OmadaGatewayPortStatus], @@ -264,12 +261,15 @@ GATEWAY_PORT_CONFIG_SWITCHES: list[OmadaGatewayPortConfigSwitchEntityDescription ] -class OmadaDevicePortSwitchEntity( +class OmadaDevicePortSwitchEntity[ + TCoordinator: OmadaCoordinator[Any], + TDevice: OmadaDevice, + TPort, +]( OmadaDeviceEntity[TCoordinator], SwitchEntity, - Generic[TCoordinator, TDevice, TPort], ): - """Generic toggle switch entity for a Netork Port of an Omada Device.""" + """Generic toggle switch entity for a Network Port of an Omada Device.""" entity_description: OmadaDevicePortSwitchEntityDescription[ TCoordinator, TDevice, TPort diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 0c3112d4a9c..03bab75918e 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,7 +1,5 @@ """Support for TPLink Omada device firmware updates.""" -from __future__ import annotations - from typing import Any from tplink_omada_client.devices import OmadaListDevice @@ -88,7 +86,8 @@ class OmadaDeviceUpdate( raise HomeAssistantError("Firmware update request rejected") from ex except OmadaClientException as ex: raise HomeAssistantError( - "Unable to send Firmware update request. Check the controller is online." + "Unable to send Firmware update request." + " Check the controller is online." ) from ex finally: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index f3138a113c4..ae26bee2094 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -1,6 +1,5 @@ """Support for Traccar device tracking.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta import logging diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py index 5ff488ba4fa..c7db6e73157 100644 --- a/homeassistant/components/traccar_server/__init__.py +++ b/homeassistant/components/traccar_server/__init__.py @@ -1,7 +1,5 @@ """The Traccar Server integration.""" -from __future__ import annotations - from datetime import timedelta from aiohttp import CookieJar diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py index 133b3832ff8..e7aba24a369 100644 --- a/homeassistant/components/traccar_server/binary_sensor.py +++ b/homeassistant/components/traccar_server/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Traccar server binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Literal diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index a7d91582339..d199b1acab8 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Traccar Server integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 601b8fb4cd2..d0c9a3703d7 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Traccar Server.""" -from __future__ import annotations - import asyncio from datetime import datetime from logging import DEBUG as LOG_LEVEL_DEBUG @@ -83,6 +81,9 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat async def _async_update_data(self) -> TraccarServerCoordinatorData: """Fetch data from Traccar Server.""" LOGGER.debug("Updating device data") + get_custom_attrs = ( + self._return_custom_attributes_if_not_filtered_by_accuracy_configuration + ) data: TraccarServerCoordinatorData = {} try: ( @@ -120,12 +121,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat ) continue - if ( - attr - := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( - device, position - ) - ) is None: + attr = get_custom_attrs(device, position) + if attr is None: self.logger.debug( "Skipping position update %s for %s due to accuracy filter", position["id"], @@ -149,18 +146,17 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat """Handle subscription data.""" self.logger.debug("Received subscription data: %s", data) self._should_log_subscription_error = True + get_custom_attrs = ( + self._return_custom_attributes_if_not_filtered_by_accuracy_configuration + ) update_devices = set() for device in data.get("devices") or []: if (device_id := device["id"]) not in self.data: self.logger.debug("Device %s not found in data", device_id) continue - if ( - attr - := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( - device, self.data[device_id]["position"] - ) - ) is None: + attr = get_custom_attrs(device, self.data[device_id]["position"]) + if attr is None: continue self.data[device_id]["device"] = device @@ -176,12 +172,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat ) continue - if ( - attr - := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( - self.data[device_id]["device"], position - ) - ) is None: + attr = get_custom_attrs(self.data[device_id]["device"], position) + if attr is None: self.logger.debug( "Skipping position update %s for %s due to accuracy filter", position["id"], @@ -257,7 +249,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat device: DeviceModel, position: PositionModel, ) -> dict[str, Any] | None: - """Return a dictionary of custom attributes if not filtered by accuracy configuration.""" + """Return custom attributes if not filtered by accuracy.""" attr = {} skip_accuracy_filter = False diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index f35e5d5559b..e26d59c1bb1 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -1,7 +1,5 @@ """Support for Traccar server device tracking.""" -from __future__ import annotations - from typing import Any from homeassistant.components.device_tracker import TrackerEntity diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py index cd93997828b..7ccab0883e7 100644 --- a/homeassistant/components/traccar_server/diagnostics.py +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Traccar Server.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data diff --git a/homeassistant/components/traccar_server/entity.py b/homeassistant/components/traccar_server/entity.py index e773bf66562..a3b2c716685 100644 --- a/homeassistant/components/traccar_server/entity.py +++ b/homeassistant/components/traccar_server/entity.py @@ -1,7 +1,5 @@ """Base entity for Traccar Server.""" -from __future__ import annotations - from typing import Any from pytraccar import DeviceModel, GeofenceModel, PositionModel diff --git a/homeassistant/components/traccar_server/helpers.py b/homeassistant/components/traccar_server/helpers.py index 9a22f2784bc..f9ff7d31d4f 100644 --- a/homeassistant/components/traccar_server/helpers.py +++ b/homeassistant/components/traccar_server/helpers.py @@ -1,7 +1,5 @@ """Helper functions for the Traccar Server integration.""" -from __future__ import annotations - from pytraccar import DeviceModel, GeofenceModel, PositionModel diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index 84132c9c17f..e1bb157f665 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -1,7 +1,5 @@ """Support for Traccar server sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Literal diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index bb0f3e5251a..d42dcda2832 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -1,7 +1,5 @@ """Support for script and automation tracing and debugging.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/trace/const.py b/homeassistant/components/trace/const.py index fedbdb71d3a..4498c7a0db6 100644 --- a/homeassistant/components/trace/const.py +++ b/homeassistant/components/trace/const.py @@ -1,7 +1,5 @@ """Shared constants for script and automation tracing and debugging.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index 3c503efdd28..524604b6c8b 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -1,7 +1,5 @@ """Containers for a script or automation trace.""" -from __future__ import annotations - import abc from collections import deque import datetime as dt diff --git a/homeassistant/components/trace/util.py b/homeassistant/components/trace/util.py index 73e65dd3998..baff0466c66 100644 --- a/homeassistant/components/trace/util.py +++ b/homeassistant/components/trace/util.py @@ -1,7 +1,5 @@ """Support for script and automation tracing and debugging.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index e5c20e757ea..896021bc6a4 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -1,7 +1,5 @@ """The tractive integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import logging @@ -102,13 +100,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> tractive = TractiveClient(hass, client, creds["user_id"], entry) + trackables = [] try: - trackable_objects = await client.trackable_objects() - trackables = await asyncio.gather( - *(_generate_trackables(client, item) for item in trackable_objects) - ) + for obj in await client.trackable_objects(): + # To avoid hitting Tractive API rate limits, we add a small + # delay between requests to fetch trackable details. + await asyncio.sleep(2) + trackables.append(await _generate_trackables(client, obj)) except aiotractive.exceptions.TractiveError as error: + await client.close() raise ConfigEntryNotReady from error + except ConfigEntryNotReady: + await client.close() + raise # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. @@ -156,7 +160,8 @@ async def _generate_trackables( if "details" not in trackable_data: _LOGGER.warning( - "Tracker %s has no details and will be skipped. This happens for shared trackers", + "Tracker %s has no details and will be" + " skipped. This happens for shared trackers", trackable_data["device_id"], ) return None @@ -164,16 +169,16 @@ async def _generate_trackables( tracker = client.tracker(trackable_data["device_id"]) trackable_pet = client.trackable_object(trackable_data["_id"]) - tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( - tracker.details(), - tracker.hw_info(), - tracker.pos_report(), - trackable_pet.health_overview(), - ) + # Sequential fetching to prevent HTTP 429 Rate Limits + tracker_details = await tracker.details() + hw_info = await tracker.hw_info() + pos_report = await tracker.pos_report() + health_overview = await trackable_pet.health_overview() if not tracker_details.get("_id"): raise ConfigEntryNotReady( - f"Tractive API returns incomplete data for tracker {trackable_data['device_id']}", + "Tractive API returns incomplete data" + f" for tracker {trackable_data['device_id']}", ) return Trackables( @@ -246,6 +251,7 @@ class TractiveClient: ): self._last_hw_time = event["hardware"]["time"] self._send_hardware_update(event) + self._send_switch_update(event) if ( "position" in event and self._last_pos_time != event["position"]["time"] @@ -302,7 +308,10 @@ class TractiveClient: for switch, key in SWITCH_KEY_MAP.items(): if switch_data := event.get(key): payload[switch] = switch_data["active"] - payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING" + if hardware := event.get("hardware", {}): + payload[ATTR_POWER_SAVING] = ( + hardware.get("power_saving_zone_id") is not None + ) self._dispatch_tracker_event( TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 9ded1f699c3..c2cd2fce84a 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Tractive binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -59,7 +57,6 @@ class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription): SENSOR_TYPES = [ TractiveBinarySensorEntityDescription( key=ATTR_BATTERY_CHARGING, - translation_key="tracker_battery_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, supported=lambda details: details.get("charging_state") is not None, diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index db590695320..e94ead0af88 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -1,7 +1,5 @@ """Config flow for tractive integration.""" -from __future__ import annotations - from collections.abc import Mapping from http import HTTPStatus import logging diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 09a4e3faf1f..e5aff1046f5 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -1,7 +1,5 @@ """Support for Tractive device trackers.""" -from __future__ import annotations - from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity @@ -10,11 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry -from .const import ( - SERVER_UNAVAILABLE, - TRACKER_HARDWARE_STATUS_UPDATED, - TRACKER_POSITION_UPDATED, -) +from .const import SERVER_UNAVAILABLE, TRACKER_POSITION_UPDATED from .entity import TractiveEntity @@ -36,6 +30,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" _attr_translation_key = "tracker" + _attr_name = None def __init__(self, client: TractiveClient, item: Trackables) -> None: """Initialize tracker entity.""" @@ -43,10 +38,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): client, item.trackable, item.tracker_details, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + f"{TRACKER_POSITION_UPDATED}-{item.tracker_details['_id']}", ) - self._battery_level: int | None = item.hw_info.get("battery_level") self._attr_latitude = item.pos_report["latlong"][0] self._attr_longitude = item.pos_report["latlong"][1] self._attr_location_accuracy: float = item.pos_report["pos_uncertainty"] @@ -60,17 +54,6 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): return SourceType.BLUETOOTH return SourceType.GPS - @property - def battery_level(self) -> int | None: - """Return the battery level of the device.""" - return self._battery_level - - @callback - def _handle_hardware_status_update(self, event: dict[str, Any]) -> None: - self._battery_level = event["battery_level"] - self._attr_available = True - self.async_write_ha_state() - @callback def _handle_position_update(self, event: dict[str, Any]) -> None: self._attr_latitude = event["latitude"] @@ -80,20 +63,12 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._attr_available = True self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call + # pylint: disable-next=home-assistant-missing-super-call async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" if not self._client.subscribed: self._client.subscribe() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - self._dispatcher_signal, - self._handle_hardware_status_update, - ) - ) - self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/tractive/diagnostics.py b/homeassistant/components/tractive/diagnostics.py index a0fc0628f08..e1a61261064 100644 --- a/homeassistant/components/tractive/diagnostics.py +++ b/homeassistant/components/tractive/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Tractive.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index d6050c865b6..082a16c787b 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -1,11 +1,9 @@ """A entity class for Tractive integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -24,16 +22,27 @@ class TractiveEntity(Entity): trackable: dict[str, Any], tracker_details: dict[str, Any], dispatcher_signal: str, + hardware_entity: bool = True, ) -> None: """Initialize tracker entity.""" - self._attr_device_info = DeviceInfo( - configuration_url="https://my.tractive.com/", - identifiers={(DOMAIN, tracker_details["_id"])}, - name=trackable["details"]["name"], - manufacturer="Tractive GmbH", - sw_version=tracker_details["fw_version"], - model=tracker_details["model_number"], - ) + if hardware_entity: + self._attr_device_info = DeviceInfo( + configuration_url="https://my.tractive.com/", + identifiers={(DOMAIN, tracker_details["_id"])}, + translation_key="tracker", + translation_placeholders={"id": tracker_details["_id"]}, + manufacturer="Tractive GmbH", + sw_version=tracker_details["fw_version"], + model_id=tracker_details["model_number"], + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, trackable["_id"])}, + name=trackable["details"]["name"], + via_device=(DOMAIN, tracker_details["_id"]), + entry_type=DeviceEntryType.SERVICE, + ) + self._user_id = client.user_id self._tracker_id = tracker_details["_id"] self._client = client diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index a66edb985ac..200bda0d885 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==1.0.1"] + "requirements": ["aiotractive==1.0.3"] } diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 09d7ee5f9c0..82044de40be 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -1,7 +1,5 @@ """Support for Tractive sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -65,7 +63,11 @@ class TractiveSensor(TractiveEntity, SensorEntity): else: dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}" super().__init__( - client, item.trackable, item.tracker_details, dispatcher_signal + client, + item.trackable, + item.tracker_details, + dispatcher_signal, + description.hardware_sensor, ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" @@ -85,7 +87,6 @@ class TractiveSensor(TractiveEntity, SensorEntity): SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_BATTERY_LEVEL, - translation_key="tracker_battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 43a36d5832a..b0ff950e7e4 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -20,18 +20,15 @@ } } }, + "device": { + "tracker": { + "name": "Tracker {id}" + } + }, "entity": { "binary_sensor": { - "tracker_battery_charging": { - "name": "Tracker battery charging" - }, "tracker_power_saving": { - "name": "Tracker power saving" - } - }, - "device_tracker": { - "tracker": { - "name": "Tracker" + "name": "Power saving" } }, "sensor": { @@ -50,11 +47,8 @@ "rest_time": { "name": "Rest time" }, - "tracker_battery_level": { - "name": "Tracker battery" - }, "tracker_state": { - "name": "Tracker state", + "name": "Status", "state": { "inaccurate_position": "Inaccurate position", "not_reporting": "Not reporting", @@ -69,11 +63,19 @@ "name": "Live tracking" }, "tracker_buzzer": { - "name": "Tracker buzzer" + "name": "Buzzer" }, "tracker_led": { - "name": "Tracker LED" + "name": "LED" } } + }, + "exceptions": { + "failed_to_turn_off": { + "message": "An error occurred while trying to turn off {entity}" + }, + "failed_to_turn_on": { + "message": "An error occurred while trying to turn on {entity}" + } } } diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 0f05a20c0ec..3d8db9e794d 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -1,7 +1,5 @@ """Support for Tractive switches.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any, Literal @@ -11,6 +9,7 @@ from aiotractive.exceptions import TractiveError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry @@ -19,6 +18,7 @@ from .const import ( ATTR_LED, ATTR_LIVE_TRACKING, ATTR_POWER_SAVING, + DOMAIN, TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -100,13 +100,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" - if self.entity_description.key not in event: - return + if ATTR_POWER_SAVING in event: + self._attr_available = not event[ATTR_POWER_SAVING] - # We received an event, so the service is online and the switch entities should - # be available. - self._attr_available = not event[ATTR_POWER_SAVING] - self._attr_is_on = event[self.entity_description.key] + if self.entity_description.key in event: + self._attr_is_on = event[self.entity_description.key] self.async_write_ha_state() @@ -115,8 +113,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): try: result = await self._method(True) except TractiveError as error: - _LOGGER.error(error) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_turn_on", + translation_placeholders={"entity": self.entity_id}, + ) from error # Write state back to avoid switch flips with a slow response if result["pending"]: self._attr_is_on = True @@ -127,8 +128,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): try: result = await self._method(False) except TractiveError as error: - _LOGGER.error(error) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_turn_off", + translation_placeholders={"entity": self.entity_id}, + ) from error # Write state back to avoid switch flips with a slow response if result["pending"]: self._attr_is_on = False diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index c3e8938b244..018a0f3d375 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -1,9 +1,6 @@ """Support for IKEA Tradfri.""" -from __future__ import annotations - from datetime import datetime, timedelta -from typing import Any from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory @@ -21,18 +18,12 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CONF_GATEWAY_ID, - CONF_IDENTITY, - CONF_KEY, - COORDINATOR, - COORDINATOR_LIST, - DOMAIN, - FACTORY, - KEY_API, - LOGGER, +from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN, LOGGER +from .coordinator import ( + TradfriConfigEntry, + TradfriData, + TradfriDeviceDataUpdateCoordinator, ) -from .coordinator import TradfriDeviceDataUpdateCoordinator PLATFORMS = [ Platform.COVER, @@ -47,18 +38,14 @@ TIMEOUT_API = 30 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TradfriConfigEntry, ) -> bool: """Create a gateway.""" - tradfri_data: dict[str, Any] = {} - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data - factory = await APIFactory.init( entry.data[CONF_HOST], psk_id=entry.data[CONF_IDENTITY], psk=entry.data[CONF_KEY], ) - tradfri_data[FACTORY] = factory # Used for async_unload_entry async def on_hass_stop(event: Event) -> None: """Close connection when hass stops.""" @@ -98,11 +85,7 @@ async def async_setup_entry( remove_stale_devices(hass, entry, devices) # Setup the device coordinators - coordinator_data = { - CONF_GATEWAY_ID: gateway, - KEY_API: api, - COORDINATOR_LIST: [], - } + tradfri_data = TradfriData(factory=factory, gateway=gateway, api=api) for device in devices: coordinator = TradfriDeviceDataUpdateCoordinator( @@ -113,9 +96,9 @@ async def async_setup_entry( entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_GW, coordinator.set_hub_available) ) - coordinator_data[COORDINATOR_LIST].append(coordinator) + tradfri_data.coordinator_list.append(coordinator) - tradfri_data[COORDINATOR] = coordinator_data + entry.runtime_data = tradfri_data async def async_keep_alive(now: datetime) -> None: if hass.is_stopping: @@ -139,13 +122,11 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TradfriConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - tradfri_data = hass.data[DOMAIN].pop(entry.entry_id) - factory = tradfri_data[FACTORY] - await factory.shutdown() + await entry.runtime_data.factory.shutdown() return unload_ok @@ -184,7 +165,8 @@ def remove_stale_devices( continue if device_id is None or device_id not in all_device_ids: - # If device_id is None an invalid device entry was found for this config entry. + # If device_id is None an invalid device entry + # was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. device_registry.async_update_device( @@ -256,7 +238,8 @@ def migrate_config_entry_and_identifiers( # Loop through list of config_entry_ids for device config_entry_ids = device.config_entries for config_entry_id in config_entry_ids: - # Check that the config entry in list is not the device's primary config entry + # Check that the config entry in list is not + # the device's primary config entry if config_entry_id == device.primary_config_entry: continue diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index f4adb1cc09e..ef0fa289c37 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tradfri.""" -from __future__ import annotations - import asyncio from typing import Any, cast from uuid import uuid4 diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index e42bb6f5f4d..9a9da766baf 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -7,8 +7,4 @@ LOGGER = logging.getLogger(__package__) CONF_GATEWAY_ID = "gateway_id" CONF_IDENTITY = "identity" CONF_KEY = "key" -COORDINATOR = "coordinator" -COORDINATOR_LIST = "coordinator_list" DOMAIN = "tradfri" -FACTORY = "tradfri_factory" -KEY_API = "tradfri_api" diff --git a/homeassistant/components/tradfri/coordinator.py b/homeassistant/components/tradfri/coordinator.py index 4c5c186626e..65a9d1f5963 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -1,11 +1,12 @@ """Tradfri DataUpdateCoordinator.""" -from __future__ import annotations - from collections.abc import Callable +from dataclasses import dataclass, field from datetime import timedelta from typing import Any +from pytradfri import Gateway +from pytradfri.api.aiocoap_api import APIFactory from pytradfri.command import Command from pytradfri.device import Device from pytradfri.error import RequestError @@ -18,16 +19,30 @@ from .const import LOGGER SCAN_INTERVAL = 60 # Interval for updating the coordinator +type TradfriConfigEntry = ConfigEntry[TradfriData] + + +@dataclass +class TradfriData: + """Runtime data for a Tradfri config entry.""" + + factory: APIFactory + gateway: Gateway + api: Callable[[Command | list[Command]], Any] + coordinator_list: list[TradfriDeviceDataUpdateCoordinator] = field( + default_factory=list + ) + class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Coordinator to manage data for a specific Tradfri device.""" - config_entry: ConfigEntry + config_entry: TradfriConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, api: Callable[[Command | list[Command]], Any], device: Device, ) -> None: diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index b1fb9b153ad..33b2e1c866a 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -1,39 +1,35 @@ """Support for IKEA Tradfri covers.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any, cast from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriCover( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_blind_control ) diff --git a/homeassistant/components/tradfri/diagnostics.py b/homeassistant/components/tradfri/diagnostics.py index 4d89fd0081f..733657d749c 100644 --- a/homeassistant/components/tradfri/diagnostics.py +++ b/homeassistant/components/tradfri/diagnostics.py @@ -1,22 +1,19 @@ """Diagnostics support for IKEA Tradfri.""" -from __future__ import annotations - from typing import Any, cast -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN +from .const import CONF_GATEWAY_ID, DOMAIN +from .coordinator import TradfriConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TradfriConfigEntry ) -> dict[str, Any]: """Return diagnostics the Tradfri platform.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - coordinator_data = entry_data[COORDINATOR] + tradfri_data = entry.runtime_data device_registry = dr.async_get(hass) device = cast( @@ -28,7 +25,7 @@ async def async_get_config_entry_diagnostics( device_data: list = [ coordinator.device.device_info.model_number - for coordinator in coordinator_data[COORDINATOR_LIST] + for coordinator in tradfri_data.coordinator_list ] return { diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index 41c20b19de5..a41faae5518 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -1,7 +1,5 @@ """Base class for IKEA TRADFRI.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable, Coroutine from functools import wraps diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index e8fb7c050ed..825da12786d 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -1,19 +1,16 @@ """Represent an air purifier.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any, cast from pytradfri.command import Command from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity ATTR_AUTO = "Auto" @@ -32,21 +29,20 @@ def _from_fan_speed(fan_speed: int) -> int: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriAirPurifierFan( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_air_purifier_control ) diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 1aab244888a..da024622fc8 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,7 +1,5 @@ """Support for IKEA Tradfri lights.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any, cast @@ -17,33 +15,31 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriLight( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_light_control ) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index b4a7c335481..a5f00db3d74 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,7 +1,5 @@ """Support for IKEA Tradfri sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -15,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -26,15 +23,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_GATEWAY_ID, - COORDINATOR, - COORDINATOR_LIST, - DOMAIN, - KEY_API, - LOGGER, -) -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID, DOMAIN, LOGGER +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity @@ -127,17 +117,17 @@ def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data + api = tradfri_data.api entities: list[TradfriSensor] = [] - for device_coordinator in coordinator_data[COORDINATOR_LIST]: + for device_coordinator in tradfri_data.coordinator_list: if ( not device_coordinator.device.has_light_control and not device_coordinator.device.has_socket_control diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index a2a1a5b4623..51e9e2970a9 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,39 +1,35 @@ """Support for IKEA Tradfri switches.""" -from __future__ import annotations - from collections.abc import Callable from typing import Any, cast from pytradfri.command import Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriSwitch( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_socket_control ) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index fc5588f40ac..95b9d5a545c 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -1,7 +1,5 @@ """The trafikverket_camera component.""" -from __future__ import annotations - import logging from pytrafikverket import TrafikverketCamera diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index 92112b41466..d471ee6b529 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Trafikverket Camera integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index b4eddb0890f..32d29a36f7b 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -1,7 +1,5 @@ """Camera for the Trafikverket Camera integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index 29f3db7beac..9c7c61423c9 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Trafikverket Camera integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -203,7 +201,11 @@ class TVCameraConfigFlow(ConfigFlow, domain=DOMAIN): camera_choices = [ SelectOptionDict( value=f"{camera_info.camera_id}", - label=f"{camera_info.camera_id} - {camera_info.camera_name} - {camera_info.location}", + label=( + f"{camera_info.camera_id}" + f" - {camera_info.camera_name}" + f" - {camera_info.location}" + ), ) for camera_info in self.cameras ] diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index 649eb102575..92bb1d11e78 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Trafikverket Camera integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from io import BytesIO diff --git a/homeassistant/components/trafikverket_camera/entity.py b/homeassistant/components/trafikverket_camera/entity.py index c564c2673d3..52570279e82 100644 --- a/homeassistant/components/trafikverket_camera/entity.py +++ b/homeassistant/components/trafikverket_camera/entity.py @@ -1,7 +1,5 @@ """Base entity for Trafikverket Camera.""" -from __future__ import annotations - from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index 726fcb6f901..bcbc8734afd 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Trafikverket Camera integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/trafikverket_ferry/__init__.py b/homeassistant/components/trafikverket_ferry/__init__.py index ac9b1bd95ae..efe9df88370 100644 --- a/homeassistant/components/trafikverket_ferry/__init__.py +++ b/homeassistant/components/trafikverket_ferry/__init__.py @@ -1,7 +1,5 @@ """The trafikverket_ferry component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index dfa64ed2953..57840651a9c 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Trafikverket Ferry integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 59b6bb4aaa3..c5b12db2511 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Trafikverket Ferry integration.""" -from __future__ import annotations - from datetime import date, datetime, time, timedelta import logging from typing import TYPE_CHECKING, Any @@ -36,7 +34,7 @@ def next_weekday(fromdate: date, weekday: int) -> date: def next_departuredate(departure: list[str]) -> date: """Calculate the next departuredate from an array input of short days.""" - today_date = date.today() + today_date = dt_util.now().date() today_weekday = date.weekday(today_date) if WEEKDAYS[today_weekday] in departure: return today_date diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index b908bc5f550..00c14440cd8 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -1,7 +1,5 @@ """Ferry information for departures, provided by Trafikverket.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/trafikverket_ferry/util.py b/homeassistant/components/trafikverket_ferry/util.py index ca7e3af3902..520f47a662d 100644 --- a/homeassistant/components/trafikverket_ferry/util.py +++ b/homeassistant/components/trafikverket_ferry/util.py @@ -1,7 +1,5 @@ """Utils for trafikverket_ferry.""" -from __future__ import annotations - from datetime import time @@ -10,6 +8,7 @@ def create_unique_id( ) -> str: """Create unique id.""" return ( - f"{ferry_from.casefold().replace(' ', '')}-{ferry_to.casefold().replace(' ', '')}" + f"{ferry_from.casefold().replace(' ', '')}" + f"-{ferry_to.casefold().replace(' ', '')}" f"-{ferry_time!s}-{weekdays!s}" ) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 7cdb0c02f5b..785b2076043 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -1,7 +1,5 @@ """The trafikverket_train component.""" -from __future__ import annotations - import logging from pytrafikverket import ( diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 2328a7126fd..44b7fb45284 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Trafikverket Train integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index 28c9ab6fe8e..b5522ed7fab 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Trafikverket Train integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, time, timedelta import logging diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 150b5ee7abb..02cd962d56b 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -1,7 +1,5 @@ """Train information for departures and delays, provided by Trafikverket.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/trafikverket_train/util.py b/homeassistant/components/trafikverket_train/util.py index 9a8dd9ea237..98c32d2ca7e 100644 --- a/homeassistant/components/trafikverket_train/util.py +++ b/homeassistant/components/trafikverket_train/util.py @@ -1,10 +1,9 @@ """Utils for trafikverket_train.""" -from __future__ import annotations - from datetime import date, timedelta from homeassistant.const import WEEKDAYS +from homeassistant.util import dt as dt_util def next_weekday(fromdate: date, weekday: int) -> date: @@ -17,7 +16,7 @@ def next_weekday(fromdate: date, weekday: int) -> date: def next_departuredate(departure: list[str]) -> date: """Calculate the next departuredate from an array input of short days.""" - today_date = date.today() + today_date = dt_util.now().date() today_weekday = date.weekday(today_date) if WEEKDAYS[today_weekday] in departure: return today_date diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py index 8fe67b5c66a..8d42f9faf28 100644 --- a/homeassistant/components/trafikverket_weatherstation/__init__.py +++ b/homeassistant/components/trafikverket_weatherstation/__init__.py @@ -1,7 +1,5 @@ """The trafikverket_weatherstation component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index ee9fe264692..04f0ad08078 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Trafikverket Weather integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 33f09c0ffe2..440f9b6d819 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Trafikverket Weather integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/trafikverket_weatherstation/diagnostics.py b/homeassistant/components/trafikverket_weatherstation/diagnostics.py index e70d60493f6..c89b7c3346e 100644 --- a/homeassistant/components/trafikverket_weatherstation/diagnostics.py +++ b/homeassistant/components/trafikverket_weatherstation/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Trafikverket Weatherstation.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index bbc6764e3ef..f18eebf83d9 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1,7 +1,5 @@ """Weather information for air and road temperature (by Trafikverket).""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/trane/__init__.py b/homeassistant/components/trane/__init__.py index 1574c6cf03a..2e39945b39e 100644 --- a/homeassistant/components/trane/__init__.py +++ b/homeassistant/components/trane/__init__.py @@ -1,7 +1,5 @@ """Integration for Trane Local thermostats.""" -from __future__ import annotations - from steamloop import ( AuthenticationError, SteamloopConnectionError, diff --git a/homeassistant/components/trane/climate.py b/homeassistant/components/trane/climate.py index b076236a44c..7597cea2036 100644 --- a/homeassistant/components/trane/climate.py +++ b/homeassistant/components/trane/climate.py @@ -1,7 +1,5 @@ """Climate platform for the Trane Local integration.""" -from __future__ import annotations - from typing import Any from steamloop import FanMode, HoldType, ThermostatConnection, ZoneMode diff --git a/homeassistant/components/trane/config_flow.py b/homeassistant/components/trane/config_flow.py index 72477c375b5..dbd6f8f5489 100644 --- a/homeassistant/components/trane/config_flow.py +++ b/homeassistant/components/trane/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Trane Local integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/trane/entity.py b/homeassistant/components/trane/entity.py index a6c27f33b9b..f151d7a4fa5 100644 --- a/homeassistant/components/trane/entity.py +++ b/homeassistant/components/trane/entity.py @@ -1,7 +1,5 @@ """Base entity for the Trane Local integration.""" -from __future__ import annotations - from typing import Any from steamloop import ThermostatConnection, Zone diff --git a/homeassistant/components/trane/switch.py b/homeassistant/components/trane/switch.py index a31b12cbd3d..2e51a7877fd 100644 --- a/homeassistant/components/trane/switch.py +++ b/homeassistant/components/trane/switch.py @@ -1,7 +1,5 @@ """Switch platform for the Trane Local integration.""" -from __future__ import annotations - from typing import Any from steamloop import HoldType, ThermostatConnection diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index bbcec432240..8861c6a3390 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,7 +1,5 @@ """Support for the Transmission BitTorrent client API.""" -from __future__ import annotations - from functools import partial import logging import re @@ -25,7 +23,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -34,8 +36,9 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_PATH, DEFAULT_SSL, DOMAIN +from .const import DEFAULT_PATH, DEFAULT_SSL, DOMAIN, MIN_REQUIRED_TRANSMISSION_VERSION from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator +from .helpers import create_version from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -80,7 +83,8 @@ async def async_setup_entry( if CONF_NAME not in config_entry.data: return None match = re.search( - f"{config_entry.data[CONF_HOST]}-{config_entry.data[CONF_NAME]} (?P.+)", + f"{config_entry.data[CONF_HOST]}" + f"-{config_entry.data[CONF_NAME]} (?P.+)", entity_entry.unique_id, ) @@ -97,6 +101,17 @@ async def async_setup_entry( except (TransmissionConnectError, TransmissionError) as err: raise ConfigEntryNotReady from err + version = create_version(api.server_version) + if version.valid and version < MIN_REQUIRED_TRANSMISSION_VERSION: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="version_error", + translation_placeholders={ + "transmission_version": api.server_version, + "min_version": MIN_REQUIRED_TRANSMISSION_VERSION, + }, + ) + protocol: Final = "https" if config_entry.data[CONF_SSL] else "http" device_registry = dr.async_get(hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index 9294319aeb8..e0d6bd2803b 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Transmission Bittorrent Client.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -40,8 +38,10 @@ from .const import ( DEFAULT_PORT, DEFAULT_SSL, DOMAIN, + MIN_REQUIRED_TRANSMISSION_VERSION, SUPPORTED_ORDER_MODES, ) +from .helpers import create_version DATA_SCHEMA = vol.Schema( { @@ -80,13 +80,17 @@ class TransmissionFlowHandler(ConfigFlow, domain=DOMAIN): {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) try: - await get_api(self.hass, user_input) + api = await get_api(self.hass, user_input) except TransmissionAuthError: errors[CONF_USERNAME] = "invalid_auth" errors[CONF_PASSWORD] = "invalid_auth" except TransmissionConnectError, TransmissionError: errors["base"] = "cannot_connect" + else: + version = create_version(api.server_version) + if version.valid and version < MIN_REQUIRED_TRANSMISSION_VERSION: + errors["base"] = "transmission_version" if not errors: return self.async_create_entry( @@ -115,14 +119,20 @@ class TransmissionFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: user_input = {**reauth_entry.data, **user_input} try: - await get_api(self.hass, user_input) + api = await get_api(self.hass, user_input) except TransmissionAuthError: errors[CONF_PASSWORD] = "invalid_auth" except TransmissionConnectError, TransmissionError: errors["base"] = "cannot_connect" else: - return self.async_update_reload_and_abort(reauth_entry, data=user_input) + version = create_version(api.server_version) + if version.valid and version < MIN_REQUIRED_TRANSMISSION_VERSION: + errors["base"] = "transmission_version" + else: + return self.async_update_reload_and_abort( + reauth_entry, data=user_input + ) return self.async_show_form( description_placeholders={ diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index cde621249c3..c1be49acd15 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,13 +1,14 @@ """Constants for the Transmission Bittorrent Client component.""" -from __future__ import annotations - from collections.abc import Callable +from awesomeversion import AwesomeVersion from transmission_rpc import Torrent DOMAIN = "transmission" +MIN_REQUIRED_TRANSMISSION_VERSION = AwesomeVersion("4.0.0") + ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" ORDER_BEST_RATIO_FIRST = "best_ratio_first" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index c6af4eded27..78fb5bf8c28 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for transmission integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/transmission/event.py b/homeassistant/components/transmission/event.py index 79cf21a5ffd..41f901169ef 100644 --- a/homeassistant/components/transmission/event.py +++ b/homeassistant/components/transmission/event.py @@ -1,7 +1,5 @@ """Define events for the Transmission integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/transmission/helpers.py b/homeassistant/components/transmission/helpers.py index 4a3ddc28b27..0fa111a6d52 100644 --- a/homeassistant/components/transmission/helpers.py +++ b/homeassistant/components/transmission/helpers.py @@ -2,6 +2,7 @@ from typing import Any +from awesomeversion import AwesomeVersion from transmission_rpc.torrent import Torrent @@ -43,3 +44,8 @@ def format_torrents( value[torrent.name] = format_torrent(torrent) return value + + +def create_version(version: str) -> AwesomeVersion: + """Convert versions, transmission has x.x.x (build).""" + return AwesomeVersion(version.split(" ", 1)[0]) diff --git a/homeassistant/components/transmission/quality_scale.yaml b/homeassistant/components/transmission/quality_scale.yaml index fdf66ba7ce7..7d5c53f0ead 100644 --- a/homeassistant/components/transmission/quality_scale.yaml +++ b/homeassistant/components/transmission/quality_scale.yaml @@ -54,7 +54,7 @@ rules: comment: | Speed sensors change so frequently that disabling by default may be appropriate. entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 2d678b4275d..ca1663edb3a 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -1,7 +1,5 @@ """Support for monitoring the Transmission BitTorrent client API.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 509191c5349..34d229b9e61 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -6,7 +6,8 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "transmission_version": "Minimum required version is 4.0.0. Please upgrade Transmission and then retry." }, "step": { "reauth_confirm": { @@ -122,6 +123,9 @@ "exceptions": { "could_not_add_torrent": { "message": "Could not add torrent: unsupported type or no permission." + }, + "version_error": { + "message": "You are running {transmission_version} of Transmission. Minimum required version is {min_version}. Please upgrade Transmission and then restart Home Assistant." } }, "options": { diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 1f247a0c699..6a712256229 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -1,7 +1,5 @@ """Support for Transport NSW (AU) to query next leave event.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index 9644016b90a..a3a92b0f26c 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,7 +1,5 @@ """Component providing HA sensor support for Travis CI framework.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 79e8f57aea8..a7c4d7be089 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -1,7 +1,5 @@ """A sensor that monitors trends in other components.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index c0b24a4fbde..0edf14a251b 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -1,7 +1,5 @@ """A sensor that monitors trends in other components.""" -from __future__ import annotations - from collections import deque from collections.abc import Mapping import logging diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index d8c2f1ba1a9..898dc56034c 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -1,12 +1,11 @@ """Config flow for Trend integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime from homeassistant.helpers import selector @@ -29,6 +28,8 @@ from .const import ( DOMAIN, ) +ALLOWED_DOMAINS = [COUNTER_DOMAIN, SENSOR_DOMAIN] + async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get base options schema.""" @@ -92,7 +93,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False), + selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS, multiple=False), ), } ) diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index a6d0f8a0427..39ed17a3fba 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -1,6 +1,7 @@ { "domain": "trend", "name": "Trend", + "after_dependencies": ["sensor", "counter"], "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trend", diff --git a/homeassistant/components/triggercmd/__init__.py b/homeassistant/components/triggercmd/__init__.py index 3c1a2c855d0..68aa31642c3 100644 --- a/homeassistant/components/triggercmd/__init__.py +++ b/homeassistant/components/triggercmd/__init__.py @@ -1,17 +1,13 @@ """The TRIGGERcmd component.""" -from __future__ import annotations - from triggercmd import client, ha from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import httpx_client -from .const import CONF_TOKEN - PLATFORMS = [ Platform.SWITCH, ] diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py index e796e836abf..b8e4a3b2db7 100644 --- a/homeassistant/components/triggercmd/config_flow.py +++ b/homeassistant/components/triggercmd/config_flow.py @@ -1,7 +1,5 @@ """Config flow for TRIGGERcmd integration.""" -from __future__ import annotations - import logging from typing import Any @@ -10,11 +8,12 @@ from triggercmd import TRIGGERcmdConnectionError, client import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import httpx_client -from .const import CONF_TOKEN, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/triggercmd/const.py b/homeassistant/components/triggercmd/const.py index 0fc15b2b806..113f035879d 100644 --- a/homeassistant/components/triggercmd/const.py +++ b/homeassistant/components/triggercmd/const.py @@ -1,4 +1,3 @@ """Constants for the TRIGGERcmd integration.""" DOMAIN = "triggercmd" -CONF_TOKEN = "token" diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py index ae7b0d4beec..b4a27497b40 100644 --- a/homeassistant/components/triggercmd/switch.py +++ b/homeassistant/components/triggercmd/switch.py @@ -1,7 +1,5 @@ """Platform for switch integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/trmnl/__init__.py b/homeassistant/components/trmnl/__init__.py index 497a398e301..c8c15abea35 100644 --- a/homeassistant/components/trmnl/__init__.py +++ b/homeassistant/components/trmnl/__init__.py @@ -1,7 +1,5 @@ """The TRMNL integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/trmnl/config_flow.py b/homeassistant/components/trmnl/config_flow.py index 828bc1a3ad4..8759253b375 100644 --- a/homeassistant/components/trmnl/config_flow.py +++ b/homeassistant/components/trmnl/config_flow.py @@ -1,7 +1,5 @@ """Config flow for TRMNL.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/trmnl/coordinator.py b/homeassistant/components/trmnl/coordinator.py index f66582150c1..eb2ba430e4f 100644 --- a/homeassistant/components/trmnl/coordinator.py +++ b/homeassistant/components/trmnl/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching TRMNL data.""" -from __future__ import annotations - from datetime import timedelta from trmnl import TRMNLClient diff --git a/homeassistant/components/trmnl/diagnostics.py b/homeassistant/components/trmnl/diagnostics.py index 53f215185af..88c9c3fbec6 100644 --- a/homeassistant/components/trmnl/diagnostics.py +++ b/homeassistant/components/trmnl/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for TRMNL.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/trmnl/entity.py b/homeassistant/components/trmnl/entity.py index 744028366d6..cefc65ed51a 100644 --- a/homeassistant/components/trmnl/entity.py +++ b/homeassistant/components/trmnl/entity.py @@ -1,7 +1,5 @@ """Base class for TRMNL entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/trmnl/sensor.py b/homeassistant/components/trmnl/sensor.py index ba73b3fbad1..7316f482a60 100644 --- a/homeassistant/components/trmnl/sensor.py +++ b/homeassistant/components/trmnl/sensor.py @@ -1,7 +1,5 @@ """Support for TRMNL sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/trmnl/switch.py b/homeassistant/components/trmnl/switch.py index 78438826985..84080146f30 100644 --- a/homeassistant/components/trmnl/switch.py +++ b/homeassistant/components/trmnl/switch.py @@ -1,7 +1,5 @@ """Support for TRMNL switch entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/trmnl/time.py b/homeassistant/components/trmnl/time.py index 52dc7de5f02..36b339ee6e2 100644 --- a/homeassistant/components/trmnl/time.py +++ b/homeassistant/components/trmnl/time.py @@ -1,7 +1,5 @@ """Support for TRMNL time entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index fb9dfcac13c..7b957b3c816 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1,7 +1,5 @@ """Provide functionality for TTS.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, MutableMapping from dataclasses import dataclass, field @@ -142,7 +140,7 @@ class TTSCache: """If an error occurred while loading, contains the error.""" _consumers: list[asyncio.Queue[bytes | None]] | None = None - """A queue for each current consumer to notify of new data while the generator is loading.""" + """Queue for consumers to receive data while loading.""" def __init__( self, diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 830e0053cee..83c1b78a8e2 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -1,7 +1,5 @@ """Text-to-speech constants.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index 3b5f29bba44..127ae4eb829 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -108,13 +108,17 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH _ = self.default_language except AttributeError as err: raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + "TTS entities must either set the" + " '_attr_default_language' attribute or override" + " the 'default_language' property" ) from err try: _ = self.supported_languages except AttributeError as err: raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + "TTS entities must either set the" + " '_attr_supported_languages' attribute or" + " override the 'supported_languages' property" ) from err state = await self.async_get_last_state() if ( diff --git a/homeassistant/components/tts/helper.py b/homeassistant/components/tts/helper.py index 614d848ea6a..19da80ffe42 100644 --- a/homeassistant/components/tts/helper.py +++ b/homeassistant/components/tts/helper.py @@ -1,7 +1,5 @@ """Provide helper functions for the TTS.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index edae942a1d4..a9d79e2a16a 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -1,7 +1,5 @@ """Provide the legacy TTS service provider interface.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Coroutine, Mapping from functools import partial diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index df336c5d76d..bc0f2715e0d 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -1,7 +1,5 @@ """Text-to-speech media source.""" -from __future__ import annotations - import json from typing import TypedDict diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index c4c1bb1ae15..70c1a8e4567 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -1,7 +1,5 @@ """Support notifications through TTS service.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 03b176eaab3..96913368a4b 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -21,7 +21,6 @@ say: selector: text: options: - advanced: true example: platform specific selector: object: @@ -50,7 +49,6 @@ speak: selector: text: options: - advanced: true example: platform specific selector: object: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 0555f8a145a..48a7e5212f0 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,22 +1,12 @@ """Support for Tuya Smart devices.""" -from __future__ import annotations - import logging -from typing import Any, NamedTuple -from tuya_sharing import ( - CustomerDevice, - Manager, - SharingDeviceListener, - SharingTokenListener, -) +from tuya_sharing import Manager -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ENDPOINT, @@ -27,59 +17,31 @@ from .const import ( LOGGER, PLATFORMS, TUYA_CLIENT_ID, - TUYA_DISCOVERY_NEW, - TUYA_HA_SIGNAL_UPDATE_ENTITY, ) +from .coordinator import DeviceListener, TuyaConfigEntry +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) # Suppress logs from the library, it logs unneeded on error logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL) -type TuyaConfigEntry = ConfigEntry[HomeAssistantTuyaData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Tuya Services.""" + await async_setup_services(hass) -class HomeAssistantTuyaData(NamedTuple): - """Tuya data stored in the Home Assistant data object.""" - - manager: Manager - listener: SharingDeviceListener - - -def _create_manager(entry: TuyaConfigEntry, token_listener: TokenListener) -> Manager: - """Create a Tuya Manager instance.""" - return Manager( - TUYA_CLIENT_ID, - entry.data[CONF_USER_CODE], - entry.data[CONF_TERMINAL_ID], - entry.data[CONF_ENDPOINT], - entry.data[CONF_TOKEN_INFO], - token_listener, - ) + return True async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" - token_listener = TokenListener(hass, entry) + listener = DeviceListener(hass, entry) + await hass.async_add_executor_job(listener.initialize) - # Move to executor as it makes blocking call to import_module - # with args ('.system', 'urllib3.contrib.resolver') - manager = await hass.async_add_executor_job(_create_manager, entry, token_listener) - - listener = DeviceListener(hass, manager) - manager.add_device_listener(listener) - - # Get all devices from Tuya - try: - await hass.async_add_executor_job(manager.update_device_cache) - except Exception as exc: - # While in general, we should avoid catching broad exceptions, - # we have no other way of detecting this case. - if "sign invalid" in str(exc): - msg = "Authentication failed. Please re-authenticate" - raise ConfigEntryAuthFailed(msg) from exc - raise - - # Connection is successful, store the manager & listener - entry.runtime_data = HomeAssistantTuyaData(manager=manager, listener=listener) + # Connection is successful, store the listener in runtime_data + entry.runtime_data = listener + manager = listener.manager # Cleanup device registry await cleanup_device_registry(hass, manager, entry) @@ -95,20 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool device.function, device.status_range, ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, device.id)}, - manufacturer="Tuya", - name=device.name, - # Note: the model is overridden via entity.device_info property - # when the entity is created. If no entities are generated, it will - # stay as unsupported - model=f"{device.product_name} (unsupported)", - model_id=device.product_id, - ) + # Register quirk, and add device to the device registry + listener.async_register_device(device_registry, device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # If the device does not register any entities, the device does not need to subscribe + # If the device does not register any entities, + # the device does not need to subscribe # So the subscription is here await hass.async_add_executor_job(manager.refresh_mq) return True @@ -133,10 +87,11 @@ async def cleanup_device_registry( async def async_unload_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Unloading the Tuya platforms.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - tuya = entry.runtime_data - if tuya.manager.mq is not None: - tuya.manager.mq.stop() - tuya.manager.remove_device_listener(tuya.listener) + listener = entry.runtime_data + manager = listener.manager + if manager.mq is not None: + manager.mq.stop() + manager.remove_device_listener(listener) return unload_ok @@ -153,103 +108,3 @@ async def async_remove_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> Non entry.data[CONF_TOKEN_INFO], ) await hass.async_add_executor_job(manager.unload) - - -class DeviceListener(SharingDeviceListener): - """Device Update Listener.""" - - def __init__( - self, - hass: HomeAssistant, - manager: Manager, - ) -> None: - """Init DeviceListener.""" - self.hass = hass - self.manager = manager - - def update_device( - self, - device: CustomerDevice, - updated_status_properties: list[str] | None = None, - dp_timestamps: dict[str, int] | None = None, - ) -> None: - """Update device status with optional DP timestamps.""" - LOGGER.debug( - "Received update for device %s (online: %s): %s" - " (updated properties: %s, dp_timestamps: %s)", - device.id, - device.online, - device.status, - updated_status_properties, - dp_timestamps, - ) - dispatcher_send( - self.hass, - f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}", - updated_status_properties, - dp_timestamps, - ) - - def add_device(self, device: CustomerDevice) -> None: - """Add device added listener.""" - # Ensure the device isn't present stale - self.hass.add_job(self.async_remove_device, device.id) - - LOGGER.debug( - "Add device %s (online: %s): %s (function: %s, status range: %s)", - device.id, - device.online, - device.status, - device.function, - device.status_range, - ) - - dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) - - def remove_device(self, device_id: str) -> None: - """Add device removed listener.""" - self.hass.add_job(self.async_remove_device, device_id) - - @callback - def async_remove_device(self, device_id: str) -> None: - """Remove device from Home Assistant.""" - LOGGER.debug("Remove device: %s", device_id) - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, device_id)} - ) - if device_entry is not None: - device_registry.async_remove_device(device_entry.id) - - -class TokenListener(SharingTokenListener): - """Token listener for upstream token updates.""" - - def __init__( - self, - hass: HomeAssistant, - entry: TuyaConfigEntry, - ) -> None: - """Init TokenListener.""" - self.hass = hass - self.entry = entry - - def update_token(self, token_info: dict[str, Any]) -> None: - """Update token info in config entry.""" - data = { - **self.entry.data, - CONF_TOKEN_INFO: { - "t": token_info["t"], - "uid": token_info["uid"], - "expire_time": token_info["expire_time"], - "access_token": token_info["access_token"], - "refresh_token": token_info["refresh_token"], - }, - } - - @callback - def async_update_entry() -> None: - """Update config entry.""" - self.hass.config_entries.async_update_entry(self.entry, data=data) - - self.hass.add_job(async_update_entry) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 72b6121a0f2..73505fee4fb 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -1,9 +1,7 @@ """Support for Tuya Alarm.""" -from __future__ import annotations - from tuya_device_handlers.definition.alarm_control_panel import ( - TuyaAlarmControlPanelDefinition, + AlarmControlPanelDefinition, get_default_definition, ) from tuya_device_handlers.helpers.homeassistant import ( @@ -22,8 +20,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity ALARM: dict[DeviceCategory, AlarmControlPanelEntityDescription] = { @@ -43,7 +41,9 @@ _TUYA_TO_HA_STATE_MAPPINGS = { TuyaAlarmControlPanelState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY, TuyaAlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT, TuyaAlarmControlPanelState.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION, - TuyaAlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + TuyaAlarmControlPanelState.ARMED_CUSTOM_BYPASS: ( + AlarmControlPanelState.ARMED_CUSTOM_BYPASS + ), TuyaAlarmControlPanelState.PENDING: AlarmControlPanelState.PENDING, TuyaAlarmControlPanelState.ARMING: AlarmControlPanelState.ARMING, TuyaAlarmControlPanelState.DISARMING: AlarmControlPanelState.DISARMING, @@ -91,7 +91,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): device: CustomerDevice, device_manager: Manager, description: AlarmControlPanelEntityDescription, - definition: TuyaAlarmControlPanelDefinition, + definition: AlarmControlPanelDefinition, ) -> None: """Init Tuya Alarm.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a2ffe563613..52520b844ee 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -1,11 +1,9 @@ """Support for Tuya binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from tuya_device_handlers.definition.binary_sensor import ( - TuyaBinarySensorDefinition, + BinarySensorDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -20,8 +18,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity @@ -90,6 +88,14 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, .. bitmap_key="tankfull", translation_key="tankfull", ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_FULL", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="FULL", + translation_key="tankfull", + ), TuyaBinarySensorEntityDescription( key="defrost", dpcode=DPCode.FAULT, @@ -98,6 +104,14 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, .. bitmap_key="defrost", translation_key="defrost", ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_COIL", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="COIL", + translation_key="coil_freeze", + ), TuyaBinarySensorEntityDescription( key="wet", dpcode=DPCode.FAULT, @@ -106,6 +120,54 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, .. bitmap_key="wet", translation_key="wet", ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_Cleaning", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="Cleaning", + translation_key="filter_cleaning", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_E1", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="E1", + translation_key="temp_error", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_CL", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="CL", + translation_key="low_temp", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_CH", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="CH", + translation_key="high_temp", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_LO", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="LO", + translation_key="low_humidity", + ), + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_MOTOR", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="MOTOR", + translation_key="motor_fault", + ), ), DeviceCategory.CWWSQ: ( TuyaBinarySensorEntityDescription( @@ -205,14 +267,7 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, .. ), TAMPER_BINARY_SENSOR, ), - DeviceCategory.LDCG: ( - TuyaBinarySensorEntityDescription( - key=DPCode.TEMPER_ALARM, - device_class=BinarySensorDeviceClass.TAMPER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TAMPER_BINARY_SENSOR, - ), + DeviceCategory.LDCG: (TAMPER_BINARY_SENSOR,), DeviceCategory.MC: ( TuyaBinarySensorEntityDescription( key=DPCode.STATUS, @@ -434,7 +489,7 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): device: CustomerDevice, device_manager: Manager, description: TuyaBinarySensorEntityDescription, - definition: TuyaBinarySensorDefinition, + definition: BinarySensorDefinition, ) -> None: """Init Tuya binary sensor.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 95ae72a94e5..021d4a76510 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,21 +1,23 @@ """Support for Tuya buttons.""" -from __future__ import annotations - from tuya_device_handlers.definition.button import ( - TuyaButtonDefinition, + ButtonDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { @@ -65,6 +67,13 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + DeviceCategory.SP: ( + ButtonEntityDescription( + key=DPCode.DEVICE_RESTART, + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + ), + ), } @@ -106,7 +115,7 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): device: CustomerDevice, device_manager: Manager, description: ButtonEntityDescription, - definition: TuyaButtonDefinition, + definition: ButtonDefinition, ) -> None: """Init Tuya button.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 36b69885b2e..47f332d027c 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,9 +1,7 @@ """Support for Tuya cameras.""" -from __future__ import annotations - from tuya_device_handlers.definition.camera import ( - TuyaCameraDefinition, + CameraDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -18,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity CAMERAS: dict[DeviceCategory, CameraEntityDescription] = { @@ -70,7 +68,7 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity): device: CustomerDevice, device_manager: Manager, description: CameraEntityDescription, - definition: TuyaCameraDefinition, + definition: CameraDefinition, ) -> None: """Init Tuya Camera.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 06db6ea9f4b..b8186779389 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,12 +1,10 @@ """Support for Tuya Climate.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast from tuya_device_handlers.definition.climate import ( - TuyaClimateDefinition, + ClimateDefinition, get_default_definition, ) from tuya_device_handlers.helpers.homeassistant import ( @@ -31,10 +29,12 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity +from .util import get_temperature_unit _TUYA_TO_HA_HVACMODE_MAPPINGS = { TuyaClimateHVACMode.OFF: HVACMode.OFF, @@ -139,12 +139,15 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _attr_name = None _attr_target_temperature_step = 1.0 + _current_temp_unit: UnitOfTemperature | None = None + _set_temp_unit: UnitOfTemperature | None = None + def __init__( self, device: CustomerDevice, device_manager: Manager, description: TuyaClimateEntityDescription, - definition: TuyaClimateDefinition, + definition: ClimateDefinition, ) -> None: """Determine which values to use.""" super().__init__(device, device_manager, description) @@ -159,6 +162,15 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._target_humidity_wrapper = definition.target_humidity_wrapper self._attr_temperature_unit = definition.temperature_unit + if self._current_temperature: + self._current_temp_unit = get_temperature_unit( + device, self._current_temperature.native_unit + ) + if self._set_temperature: + self._set_temp_unit = get_temperature_unit( + device, self._set_temperature.native_unit + ) + # Get integer type data for the dpcode to set temperature, use # it to define min, max & step temperatures if definition.set_temperature_wrapper: @@ -190,9 +202,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # Determine preset modes (ignore if empty options) if definition.preset_wrapper and definition.preset_wrapper.options: - self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = definition.preset_wrapper.options self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + if description.switch_only_hvac_mode not in self._attr_hvac_modes: + self._attr_hvac_modes.append(description.switch_only_hvac_mode) # Determine dpcode to use for setting the humidity if definition.target_humidity_wrapper: @@ -263,14 +276,26 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._async_send_wrapper_updates( - self._set_temperature, kwargs[ATTR_TEMPERATURE] - ) + value = kwargs[ATTR_TEMPERATURE] + if self._set_temp_unit and self._set_temp_unit != self.temperature_unit: + value = TemperatureConverter.convert( + value, self.temperature_unit, self._set_temp_unit + ) + await self._async_send_wrapper_updates(self._set_temperature, value) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._read_wrapper(self._current_temperature) + value = self._read_wrapper(self._current_temperature) + if ( + value is not None + and self._current_temp_unit + and self._current_temp_unit != self.temperature_unit + ): + return TemperatureConverter.convert( + value, self._current_temp_unit, self.temperature_unit + ) + return value @property def current_humidity(self) -> int | None: @@ -280,7 +305,16 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the temperature currently set to be reached.""" - return self._read_wrapper(self._set_temperature) + value = self._read_wrapper(self._set_temperature) + if ( + value is not None + and self._set_temp_unit + and self._set_temp_unit != self.temperature_unit + ): + return TemperatureConverter.convert( + value, self._set_temp_unit, self.temperature_unit + ) + return value @property def target_humidity(self) -> int | None: diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 30d04eb61e2..81479c1edb1 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Tuya.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 722900566b9..c4c68f7800a 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,7 +1,5 @@ """Constants for the Tuya integration.""" -from __future__ import annotations - from dataclasses import dataclass, field from enum import StrEnum import logging @@ -35,7 +33,6 @@ CONF_ENDPOINT = "endpoint" CONF_TERMINAL_ID = "terminal_id" CONF_TOKEN_INFO = "token_info" CONF_USER_CODE = "user_code" -CONF_USERNAME = "username" TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" TUYA_SCHEMA = "haauthorize" @@ -660,6 +657,7 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COMPRESSOR_STRENGTH = "compressor_strength" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" CONTROL_2 = "control_2" @@ -698,6 +696,7 @@ class DPCode(StrEnum): DEHUMIDITY_SET_VALUE = "dehumidify_set_value" DELAY_CLEAN_TIME = "delay_clean_time" DELAY_SET = "delay_set" + DEVICE_RESTART = "device_restart" DEW_POINT_TEMP = "dew_point_temp" DISINFECTION = "disinfection" DO_NOT_DISTURB = "do_not_disturb" @@ -753,6 +752,10 @@ class DPCode(StrEnum): HUMIDITY_VALUE = "humidity_value" # Humidity INSTALLATION_HEIGHT = "installation_height" INVERTER_OUTPUT_POWER = "inverter_output_power" + IPC_AUTO_SIREN = "ipc_auto_siren" + IPC_BRIGHT = "ipc_bright" + IPC_CONTRAST = "ipc_contrast" + IPC_SHARP = "ipc_sharp" IPC_WORK_MODE = "ipc_work_mode" LED_TYPE_1 = "led_type_1" LED_TYPE_2 = "led_type_2" @@ -778,6 +781,7 @@ class DPCode(StrEnum): MINI_SET = "mini_set" MODE = "mode" # Working mode / Mode MOODLIGHTING = "moodlighting" # Mood light + MOTION_AREA_SWITCH = "motion_area_switch" # Activity area MOTION_RECORD = "motion_record" MOTION_SENSITIVITY = "motion_sensitivity" MOTION_SWITCH = "motion_switch" # Motion switch @@ -907,8 +911,10 @@ class DPCode(StrEnum): TARGET_DIS_CLOSEST = "target_dis_closest" # Closest target distance TDS_IN = "tds_in" # Total dissolved solids TEMP = "temp" # Temperature setting + TEMP_AROUND = "temp_around" # Current around (outside) temperature TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" + TEMP_COILER = "temp_coiler" # Current coil temperature TEMP_CONTROLLER = "temp_controller" TEMP_CORRECTION = "temp_correction" TEMP_CURRENT = "temp_current" # Current temperature in °C @@ -929,12 +935,15 @@ class DPCode(StrEnum): "temp_current_external_f" # Current external temperature in Fahrenheit ) TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_EFFLUENT = "temp_effluent" # Current flow temperature TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F + TEMP_SETTING_QUICK_C = "temp_setting_quick_c" TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching TEMP_VALUE = "temp_value" # Color temperature TEMP_VALUE_V2 = "temp_value_v2" + TEMP_VENTING = "temp_venting" # Current heat plate temperature TEMPER_ALARM = "temper_alarm" # Tamper alarm TIME_TOTAL = "time_total" TIME_USE = "time_use" # Total seconds of irrigation @@ -949,6 +958,7 @@ class DPCode(StrEnum): UP_DOWN = "up_down" UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" + USE_TIME_ONE = "use_time_one" UV = "uv" # UV sterilization UV_INDEX = "uv_index" UV_RUNTIME = "uv_runtime" # UV runtime @@ -981,7 +991,9 @@ class DPCode(StrEnum): WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode WORK_POWER = "work_power" + WORK_STATE = "work_state" WORK_STATE_E = "work_state_e" + WORK_TYPE = "work_type" @dataclass diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py new file mode 100644 index 00000000000..7579b803286 --- /dev/null +++ b/homeassistant/components/tuya/coordinator.py @@ -0,0 +1,199 @@ +"""Support for Tuya Smart devices.""" + +from pathlib import Path +from typing import Any + +from tuya_device_handlers import TUYA_QUIRKS_REGISTRY +from tuya_device_handlers.devices import register_tuya_quirks +from tuya_sharing import ( + CustomerDevice, + Manager, + SharingDeviceListener, + SharingTokenListener, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send + +from .const import ( + CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, + DOMAIN, + LOGGER, + TUYA_CLIENT_ID, + TUYA_DISCOVERY_NEW, + TUYA_HA_SIGNAL_UPDATE_ENTITY, +) +from .util import get_device_info + +type TuyaConfigEntry = ConfigEntry[DeviceListener] + + +class DeviceListener(SharingDeviceListener): + """Device Update Listener.""" + + manager: Manager + + def __init__( + self, + hass: HomeAssistant, + entry: TuyaConfigEntry, + ) -> None: + """Init DeviceListener.""" + self.hass = hass + self._entry = entry + + def initialize(self) -> None: + """Initialize device listener. + + Needs to be called in executor as these make blocking calls: + - `register_tuya_quirks` + - `Manager` initialization + - `manager.update_device_cache` + """ + entry = self._entry + hass = self.hass + + # Makes blocking call to load files from disk + register_tuya_quirks(str(Path(hass.config.config_dir, "tuya_quirks"))) + + token_listener = _TokenListener(hass, entry) + + # Makes blocking call to import_module + # with args ('.system', 'urllib3.contrib.resolver') + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + token_listener, + ) + + manager.add_device_listener(self) + + # Get all devices from Tuya, makes blocking web calls + try: + manager.update_device_cache() + except Exception as exc: + # While in general, we should avoid catching broad exceptions, + # we have no other way of detecting this case. + if "sign invalid" in str(exc): + msg = "Authentication failed. Please re-authenticate" + raise ConfigEntryAuthFailed(msg) from exc + raise + + self.manager = manager + + def update_device( + self, + device: CustomerDevice, + updated_status_properties: list[str] | None = None, + dp_timestamps: dict[str, int] | None = None, + ) -> None: + """Handle device update event.""" + LOGGER.debug( + "Received update for device %s (online: %s): %s" + " (updated properties: %s, dp_timestamps: %s)", + device.id, + device.online, + device.status, + updated_status_properties, + dp_timestamps, + ) + dispatcher_send( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}", + updated_status_properties, + dp_timestamps, + ) + + def add_device(self, device: CustomerDevice) -> None: + """Handle device added event.""" + LOGGER.debug( + "Add device %s (online: %s): %s (function: %s, status range: %s)", + device.id, + device.online, + device.status, + device.function, + device.status_range, + ) + self.hass.add_job(self.async_add_device, device) + + @callback + def async_add_device(self, device: CustomerDevice) -> None: + """Add device to Home Assistant.""" + # Ensure the (stale) device isn't present in the device registry + self.async_remove_device(device.id) + + # Register quirk, and add device to the device registry + device_registry = dr.async_get(self.hass) + self.async_register_device(device_registry, device) + + # Notify platforms of new device so entities can be created + async_dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) + + @callback + def async_register_device( + self, device_registry: dr.DeviceRegistry, device: CustomerDevice + ) -> None: + """Register device with Home Assistant.""" + TUYA_QUIRKS_REGISTRY.initialise_device_quirk(device) + + device_registry.async_get_or_create( + config_entry_id=self._entry.entry_id, + **get_device_info(device, initial=True), + ) + + def remove_device(self, device_id: str) -> None: + """Handle device removal event.""" + LOGGER.debug("Remove device: %s", device_id) + self.hass.add_job(self.async_remove_device, device_id) + + @callback + def async_remove_device(self, device_id: str) -> None: + """Remove device from Home Assistant.""" + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device_entry is not None: + device_registry.async_remove_device(device_entry.id) + + +class _TokenListener(SharingTokenListener): + """Token listener for upstream token updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: TuyaConfigEntry, + ) -> None: + """Init TokenListener.""" + self.hass = hass + self.entry = entry + + def update_token(self, token_info: dict[str, Any]) -> None: + """Update token info in config entry.""" + data = { + **self.entry.data, + CONF_TOKEN_INFO: { + "t": token_info["t"], + "uid": token_info["uid"], + "expire_time": token_info["expire_time"], + "access_token": token_info["access_token"], + "refresh_token": token_info["refresh_token"], + }, + } + + @callback + def async_update_entry() -> None: + """Update config entry.""" + self.hass.config_entries.async_update_entry(self.entry, data=data) + + self.hass.add_job(async_update_entry) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index a2f8eec2a98..afdbf6cda32 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -1,12 +1,10 @@ """Support for Tuya Cover.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any from tuya_device_handlers.definition.cover import ( - TuyaCoverDefinition, + CoverDefinition, get_default_definition, ) from tuya_device_handlers.device_wrapper.cover import ( @@ -35,8 +33,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity @@ -206,7 +204,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): device: CustomerDevice, device_manager: Manager, description: TuyaCoverEntityDescription, - definition: TuyaCoverDefinition, + definition: CoverDefinition, ) -> None: """Init Tuya Cover.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 0d3dc9df860..425b6a10dca 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Tuya.""" -from __future__ import annotations - from typing import Any from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict @@ -12,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import TuyaConfigEntry from .const import DOMAIN, DPCode +from .coordinator import TuyaConfigEntry _REDACTED_DPCODES = { DPCode.ALARM_MESSAGE, diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 7ebe9aaf416..b00bbbc0d4d 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -1,17 +1,15 @@ """Tuya Home Assistant Base Device Model.""" -from __future__ import annotations - from typing import Any from tuya_device_handlers.device_wrapper import DeviceWrapper from tuya_sharing import CustomerDevice, Manager -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription -from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY +from .const import LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY +from .util import get_device_info class TuyaEntity(Entity): @@ -26,7 +24,8 @@ class TuyaEntity(Entity): device_manager: Manager, description: EntityDescription, ) -> None: - """Init TuyaHaEntity.""" + """Init TuyaEntity.""" + self._attr_device_info = get_device_info(device) self._attr_unique_id = f"tuya.{device.id}{description.key}" self.entity_description = description # TuyaEntity initialize mq can subscribe @@ -34,17 +33,6 @@ class TuyaEntity(Entity): self.device = device self.device_manager = device_manager - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.id)}, - manufacturer="Tuya", - name=self.device.name, - model=self.device.product_name, - model_id=self.device.product_id, - ) - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 9809c8a928a..771394df3a1 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -1,11 +1,9 @@ """Support for Tuya event entities.""" -from __future__ import annotations - from dataclasses import dataclass from tuya_device_handlers.definition.event import ( - TuyaEventDefinition, + EventDefinition, get_default_definition, ) from tuya_device_handlers.device_wrapper.common import DPCodeTypeInformationWrapper @@ -25,8 +23,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity @@ -158,7 +156,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity): device: CustomerDevice, device_manager: Manager, description: EventEntityDescription, - definition: TuyaEventDefinition, + definition: EventDefinition, ) -> None: """Init Tuya event entity.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 5d0db0adc67..c62e460ce76 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,13 +1,8 @@ """Support for Tuya Fan.""" -from __future__ import annotations - from typing import Any -from tuya_device_handlers.definition.fan import ( - TuyaFanDefinition, - get_default_definition, -) +from tuya_device_handlers.definition.fan import FanDefinition, get_default_definition from tuya_device_handlers.helpers.homeassistant import TuyaFanDirection from tuya_sharing import CustomerDevice, Manager @@ -22,8 +17,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity FANS: dict[DeviceCategory, FanEntityDescription] = { @@ -81,7 +76,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): device: CustomerDevice, device_manager: Manager, description: FanEntityDescription, - definition: TuyaFanDefinition, + definition: FanDefinition, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 1ab7418666d..72f7e37316e 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -1,12 +1,10 @@ """Support for Tuya (de)humidifiers.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any from tuya_device_handlers.definition.humidifier import ( - TuyaHumidifierDefinition, + HumidifierDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -21,8 +19,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity from .util import ActionDPCodeNotFoundError @@ -101,7 +99,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): device: CustomerDevice, device_manager: Manager, description: TuyaHumidifierEntityDescription, - definition: TuyaHumidifierDefinition, + definition: HumidifierDefinition, ) -> None: """Init Tuya (de)humidifier.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index ef93acf327c..8aa819ea980 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -381,5 +381,13 @@ "default": "mdi:watermark" } } + }, + "services": { + "get_feeder_meal_plan": { + "service": "mdi:database-eye" + }, + "set_feeder_meal_plan": { + "service": "mdi:database-edit" + } } } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 2f786708b44..e28b102b7b2 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,13 +1,11 @@ """Support for the Tuya lights.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any from tuya_device_handlers.definition.light import ( FallbackColorDataMode, - TuyaLightDefinition, + LightDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -28,8 +26,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity @@ -426,7 +424,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): device: CustomerDevice, device_manager: Manager, description: TuyaLightEntityDescription, - definition: TuyaLightDefinition, + definition: LightDefinition, ) -> None: """Init TuyaHaLight.""" super().__init__(device, device_manager, description) @@ -536,7 +534,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): @property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" - # If the light is currently in color mode, extract the brightness from the color data + # If the light is currently in color mode, + # extract the brightness from the color data if self.color_mode == ColorMode.HS and self._color_data_wrapper: hsv_data = self._read_wrapper(self._color_data_wrapper) return None if hsv_data is None else round(hsv_data[2]) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 15d9402e2e9..a01310dcb6f 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.16", + "tuya-device-handlers==0.0.22", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 078865f5a24..8bed6605a0a 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -1,9 +1,7 @@ """Support for Tuya number.""" -from __future__ import annotations - from tuya_device_handlers.definition.number import ( - TuyaNumberDefinition, + NumberDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -19,16 +17,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, - DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity +from .util import get_device_temp_unit_convert NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { DeviceCategory.BH: ( @@ -230,7 +228,14 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { ), ), DeviceCategory.SFKZQ: ( - # Controls the irrigation duration for the water valve + # Controls the irrigation duration for indexed water valves + NumberEntityDescription( + key=DPCode.COUNTDOWN, + translation_key="irrigation_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + # Controls the irrigation duration for indexed water valves NumberEntityDescription( key=DPCode.COUNTDOWN_1, translation_key="indexed_irrigation_duration", @@ -301,6 +306,21 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { translation_key="volume", entity_category=EntityCategory.CONFIG, ), + NumberEntityDescription( + key=DPCode.IPC_BRIGHT, + translation_key="video_brightness", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.IPC_CONTRAST, + translation_key="video_contrast", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.IPC_SHARP, + translation_key="video_sharpness", + entity_category=EntityCategory.CONFIG, + ), ), DeviceCategory.SZJQR: ( NumberEntityDescription( @@ -385,6 +405,28 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + DeviceCategory.WG2: ( + NumberEntityDescription( + key=DPCode.DELAY_SET, + # This setting is called "Arm Delay" in the official Tuya app + translation_key="arm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_DELAY_TIME, + translation_key="alarm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_TIME, + # This setting is called "Siren Duration" in the official Tuya app + translation_key="siren_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), DeviceCategory.WK: ( NumberEntityDescription( key=DPCode.TEMP_CORRECTION, @@ -487,7 +529,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): device: CustomerDevice, device_manager: Manager, description: NumberEntityDescription, - definition: TuyaNumberDefinition, + definition: NumberDefinition, ) -> None: """Initialize a Tuya number entity.""" super().__init__(device, device_manager, description) @@ -496,54 +538,62 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_native_max_value = definition.number_wrapper.max_value self._attr_native_min_value = definition.number_wrapper.min_value self._attr_native_step = definition.number_wrapper.value_step - if description.native_unit_of_measurement is None: - self._attr_native_unit_of_measurement = ( - definition.number_wrapper.native_unit - ) - self._validate_device_class_unit() + self._validate_device_class_unit(definition.number_wrapper.native_unit) - def _validate_device_class_unit(self) -> None: + def _validate_device_class_unit(self, tuya_uom: str | None) -> None: """Validate device class unit compatibility.""" # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. if ( - self.device_class is not None - and not self.device_class.startswith(DOMAIN) - and self.entity_description.native_unit_of_measurement is None + (device_class := self.device_class) is None # we do not need to check mappings if the API UOM is allowed - and self.native_unit_of_measurement - not in NUMBER_DEVICE_CLASS_UNITS[self.device_class] + or tuya_uom in NUMBER_DEVICE_CLASS_UNITS[device_class] ): - # We cannot have a device class, if the UOM isn't set or the - # device class cannot be found in the validation mapping. - if ( - self.native_unit_of_measurement is None - or self.device_class not in DEVICE_CLASS_UNITS - ): - LOGGER.debug( - "Device class %s ignored for incompatible unit %s in number entity %s", - self.device_class, - self.native_unit_of_measurement, - self.unique_id, - ) - self._attr_device_class = None - return + self._attr_native_unit_of_measurement = tuya_uom + return - uoms = DEVICE_CLASS_UNITS[self.device_class] - uom = uoms.get(self.native_unit_of_measurement) or uoms.get( - self.native_unit_of_measurement.lower() + # If the device provides TEMP_UNIT_CONVERT and no unit is set, use it. + if ( + device_class is NumberDeviceClass.TEMPERATURE + and not tuya_uom + and (temp_unit := get_device_temp_unit_convert(self.device)) is not None + ): + self._attr_native_unit_of_measurement = temp_unit + return + + # Check mappings for compatible units of measurement for the device class + if ( + tuya_uom is not None + and (uoms := DEVICE_CLASS_UNITS.get(device_class)) + and (uom := uoms.get(tuya_uom) or uoms.get(tuya_uom.lower())) + ): + self._attr_native_unit_of_measurement = uom.unit + return + + if self.entity_description.native_unit_of_measurement is not None: + LOGGER.debug( + "Incompatible unit %s replaced by entity description unit %s " + "for device class %s in number entity %s; use a quirk " + "(https://github.com/home-assistant-libs/tuya-device-handlers)" + " to override", + tuya_uom, + self.entity_description.native_unit_of_measurement, + device_class, + self.unique_id, ) - # Unknown unit of measurement, device class should not be used. - if uom is None: - self._attr_device_class = None - return + return - # Found unit of measurement, use the standardized Unit - # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = uom.unit + self._attr_native_unit_of_measurement = tuya_uom + self._attr_device_class = None + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in number entity %s", + device_class, + tuya_uom, + self.unique_id, + ) @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 239aabd9bcc..08eef788a0c 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -1,7 +1,5 @@ """Support for Tuya scenes.""" -from __future__ import annotations - from typing import Any from tuya_sharing import Manager, SharingScene @@ -11,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import DOMAIN +from .coordinator import TuyaConfigEntry async def async_setup_entry( diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 8192db57b1e..96a44875a5d 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -1,9 +1,7 @@ """Support for Tuya select.""" -from __future__ import annotations - from tuya_device_handlers.definition.select import ( - TuyaSelectDefinition, + SelectDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -14,13 +12,25 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = { + DeviceCategory.BH: ( + SelectEntityDescription( + key=DPCode.TEMP_SETTING_QUICK_C, + entity_category=EntityCategory.CONFIG, + translation_key="quick_heat_temperature", + ), + SelectEntityDescription( + key=DPCode.WORK_TYPE, + entity_category=EntityCategory.CONFIG, + translation_key="kettle_work_mode", + ), + ), DeviceCategory.CL: ( SelectEntityDescription( key=DPCode.CONTROL_BACK_MODE, @@ -390,7 +400,7 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity): device: CustomerDevice, device_manager: Manager, description: SelectEntityDescription, - definition: TuyaSelectDefinition, + definition: SelectDefinition, ) -> None: """Initialize a Tuya select entity.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 59307204567..a502858449e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1,14 +1,15 @@ """Support for Tuya sensors.""" -from __future__ import annotations - from dataclasses import dataclass from tuya_device_handlers.definition.sensor import ( - TuyaSensorDefinition, + SensorDefinition, get_default_definition, ) -from tuya_device_handlers.device_wrapper.common import DPCodeTypeInformationWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeEnumWrapper, + DPCodeTypeInformationWrapper, +) from tuya_device_handlers.device_wrapper.sensor import ( DeltaIntegerWrapper, ElectricityCurrentJsonWrapper, @@ -44,16 +45,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, - DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity +from .util import get_device_temp_unit_convert CURRENT_WRAPPER = (ElectricityCurrentRawWrapper, ElectricityCurrentJsonWrapper) POWER_WRAPPER = (ElectricityPowerRawWrapper, ElectricityPowerJsonWrapper) @@ -379,6 +380,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_energy", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), TuyaSensorEntityDescription( key=DPCode.FORWARD_ENERGY_TOTAL, @@ -392,6 +394,12 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.CUR_NEUTRAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), TuyaSensorEntityDescription( key=DPCode.SUPPLY_FREQUENCY, translation_key="supply_frequency", @@ -635,14 +643,12 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_POWER, translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_VOLTAGE, @@ -650,7 +656,6 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.ADD_ELE, @@ -1068,13 +1073,25 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { ), ), DeviceCategory.SFKZQ: ( - # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + # Total seconds of irrigation. Read-write value; + # the device appears to ignore the write action TuyaSensorEntityDescription( key=DPCode.TIME_USE, translation_key="total_watering_time", state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), + TuyaSensorEntityDescription( + key=DPCode.USE_TIME_ONE, + translation_key="last_watering_time", + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.WORK_STATE, + translation_key="irrigation_status", + entity_category=EntityCategory.DIAGNOSTIC, + ), *BATTERY_SENSORS, ), DeviceCategory.SGBJ: BATTERY_SENSORS, @@ -1153,14 +1170,12 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_POWER, translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_VOLTAGE, @@ -1168,7 +1183,6 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.ADD_ELE, @@ -1600,12 +1614,41 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { ), ), DeviceCategory.ZNRB: ( + TuyaSensorEntityDescription( + key=DPCode.COMPRESSOR_STRENGTH, + translation_key="compressor_strength", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_AROUND, + translation_key="outside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_COILER, + translation_key="coil_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_EFFLUENT, + translation_key="flow_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_VENTING, + translation_key="heat_exchanger_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ), DeviceCategory.ZWJCY: ( TuyaSensorEntityDescription( @@ -1680,20 +1723,23 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): device: CustomerDevice, device_manager: Manager, description: TuyaSensorEntityDescription, - definition: TuyaSensorDefinition, + definition: SensorDefinition, ) -> None: """Init Tuya sensor.""" super().__init__(device, device_manager, description) self._dpcode_wrapper = definition.sensor_wrapper - if description.native_unit_of_measurement is None: - self._attr_native_unit_of_measurement = ( - definition.sensor_wrapper.native_unit - ) if description.suggested_unit_of_measurement is None: self._attr_suggested_unit_of_measurement = ( definition.sensor_wrapper.suggested_unit ) + if ( + description.device_class is None + # For enum type DPs, we can assume it's an ENUM sensor + and isinstance(definition.sensor_wrapper, DPCodeEnumWrapper) + ): + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_options = definition.sensor_wrapper.options if ( description.state_class is None # For integer type DPs with "sum" report type, we can assume it's a total @@ -1702,51 +1748,66 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): ): self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._validate_device_class_unit() + self._validate_device_class_unit(definition.sensor_wrapper.native_unit) - def _validate_device_class_unit(self) -> None: + def _validate_device_class_unit(self, tuya_uom: str | None) -> None: """Validate device class unit compatibility.""" # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. if ( - self.device_class is not None - and not self.device_class.startswith(DOMAIN) - and self.entity_description.native_unit_of_measurement is None + device_class := self.entity_description.device_class + ) is SensorDeviceClass.ENUM: + self._attr_native_unit_of_measurement = None + return + if ( + device_class is None # we do not need to check mappings if the API UOM is allowed - and self.native_unit_of_measurement - not in SENSOR_DEVICE_CLASS_UNITS[self.device_class] + or tuya_uom in SENSOR_DEVICE_CLASS_UNITS[device_class] ): - # We cannot have a device class, if the UOM isn't set or the - # device class cannot be found in the validation mapping. - if ( - self.native_unit_of_measurement is None - or self.device_class not in DEVICE_CLASS_UNITS - ): - LOGGER.debug( - "Device class %s ignored for incompatible unit %s in sensor entity %s", - self.device_class, - self.native_unit_of_measurement, - self.unique_id, - ) - self._attr_device_class = None - self._attr_suggested_unit_of_measurement = None - return + self._attr_native_unit_of_measurement = tuya_uom + return - uoms = DEVICE_CLASS_UNITS[self.device_class] - uom = uoms.get(self.native_unit_of_measurement) or uoms.get( - self.native_unit_of_measurement.lower() - ) + # If the device provides TEMP_UNIT_CONVERT and no unit is set, use it. + if ( + device_class is SensorDeviceClass.TEMPERATURE + and not tuya_uom + and (temp_unit := get_device_temp_unit_convert(self.device)) is not None + ): + self._attr_native_unit_of_measurement = temp_unit + return - # Unknown unit of measurement, device class should not be used. - if uom is None: - self._attr_device_class = None - self._attr_suggested_unit_of_measurement = None - return - - # Found unit of measurement, use the standardized Unit - # Use the target conversion unit (if set) + # Check mappings for compatible units of measurement for the device class + if ( + tuya_uom is not None + and (uoms := DEVICE_CLASS_UNITS.get(device_class)) + and (uom := uoms.get(tuya_uom) or uoms.get(tuya_uom.lower())) + ): self._attr_native_unit_of_measurement = uom.unit + return + + if self.entity_description.native_unit_of_measurement is not None: + LOGGER.debug( + "Incompatible unit %s replaced by entity description unit %s " + "for device class %s in sensor entity %s; use a quirk " + "(https://github.com/home-assistant-libs/tuya-device-handlers)" + " to override", + tuya_uom, + self.entity_description.native_unit_of_measurement, + device_class, + self.unique_id, + ) + return + + self._attr_native_unit_of_measurement = tuya_uom + self._attr_device_class = None + self._attr_suggested_unit_of_measurement = None + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in sensor entity %s", + device_class, + tuya_uom, + self.unique_id, + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/tuya/services.py b/homeassistant/components/tuya/services.py new file mode 100644 index 00000000000..bef24571c2e --- /dev/null +++ b/homeassistant/components/tuya/services.py @@ -0,0 +1,160 @@ +"""Services for Tuya integration.""" + +from enum import StrEnum +from typing import Any + +from tuya_device_handlers.device_wrapper.service_feeder_schedule import ( + FeederSchedule, + get_feeder_schedule_wrapper, +) +from tuya_sharing import CustomerDevice, Manager +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + +FEEDING_ENTRY_SCHEMA = vol.Schema( + { + vol.Optional("days"): [vol.In(DAYS)], + vol.Required("time"): str, + vol.Required("portion"): int, + vol.Required("enabled"): bool, + } +) + + +class Service(StrEnum): + """Tuya services.""" + + GET_FEEDER_MEAL_PLAN = "get_feeder_meal_plan" + SET_FEEDER_MEAL_PLAN = "set_feeder_meal_plan" + + +def _get_tuya_device( + hass: HomeAssistant, device_id: str +) -> tuple[CustomerDevice, Manager]: + """Get a Tuya device and manager from a Home Assistant device registry ID.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + # Find the Tuya device ID from identifiers + tuya_device_id = None + for identifier_domain, identifier_value in device_entry.identifiers: + if identifier_domain == DOMAIN: + tuya_device_id = identifier_value + break + + if tuya_device_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_tuya_device", + translation_placeholders={ + "device_id": device_id, + }, + ) + + # Find the device in Tuya config entry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + manager = entry.runtime_data.manager + if tuya_device_id in manager.device_map: + return manager.device_map[tuya_device_id], manager + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + +async def async_get_feeder_meal_plan( + call: ServiceCall, +) -> dict[str, Any]: + """Handle get_feeder_meal_plan service call.""" + device, _ = _get_tuya_device(call.hass, call.data[ATTR_DEVICE_ID]) + + if not (wrapper := get_feeder_schedule_wrapper(device)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_support_meal_plan_status", + translation_placeholders={ + "device_id": device.id, + }, + ) + + meal_plan = wrapper.read_device_status(device) + if meal_plan is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_meal_plan_data", + ) + + return {"meal_plan": meal_plan} + + +async def async_set_feeder_meal_plan(call: ServiceCall) -> None: + """Handle set_feeder_meal_plan service call.""" + device, manager = _get_tuya_device(call.hass, call.data[ATTR_DEVICE_ID]) + + if not (wrapper := get_feeder_schedule_wrapper(device)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_support_meal_plan_function", + translation_placeholders={ + "device_id": device.id, + }, + ) + + meal_plan: list[FeederSchedule] = call.data["meal_plan"] + + await call.hass.async_add_executor_job( + manager.send_commands, + device.id, + wrapper.get_update_commands(device, meal_plan), + ) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up Tuya services.""" + + hass.services.async_register( + DOMAIN, + Service.GET_FEEDER_MEAL_PLAN, + async_get_feeder_meal_plan, + schema=vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + Service.SET_FEEDER_MEAL_PLAN, + async_set_feeder_meal_plan, + schema=vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required("meal_plan"): vol.All( + list, + [FEEDING_ENTRY_SCHEMA], + ), + } + ), + ) diff --git a/homeassistant/components/tuya/services.yaml b/homeassistant/components/tuya/services.yaml new file mode 100644 index 00000000000..e3aaa5faf6c --- /dev/null +++ b/homeassistant/components/tuya/services.yaml @@ -0,0 +1,51 @@ +get_feeder_meal_plan: + fields: + device_id: + required: true + selector: + device: + integration: tuya + +set_feeder_meal_plan: + fields: + device_id: + required: true + selector: + device: + integration: tuya + meal_plan: + required: true + selector: + object: + translation_key: set_feeder_meal_plan + description_field: portion + multiple: true + fields: + days: + selector: + select: + options: + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + - sunday + multiple: true + translation_key: days_of_week + + time: + selector: + time: + + portion: + selector: + number: + min: 0 + max: 100 + mode: box + unit_of_measurement: "g" + enabled: + selector: + boolean: {} diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index b10628230ff..896b644ac46 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -1,11 +1,9 @@ """Support for Tuya siren.""" -from __future__ import annotations - from typing import Any from tuya_device_handlers.definition.siren import ( - TuyaSirenDefinition, + SirenDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -20,8 +18,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { @@ -29,21 +27,25 @@ SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { SirenEntityDescription( key=DPCode.ALARM_SWITCH, entity_category=EntityCategory.CONFIG, + translation_key="siren", ), ), DeviceCategory.DGNBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, + translation_key="siren", ), ), DeviceCategory.SGBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, + name=None, ), ), DeviceCategory.SP: ( SirenEntityDescription( key=DPCode.SIREN_SWITCH, + translation_key="siren", ), ), } @@ -86,14 +88,13 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity): """Tuya Siren Entity.""" _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF - _attr_name = None def __init__( self, device: CustomerDevice, device_manager: Manager, description: SirenEntityDescription, - definition: TuyaSirenDefinition, + definition: SirenDefinition, ) -> None: """Init Tuya Siren.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 7e3bf7ba118..69a032d86bc 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -36,6 +36,9 @@ "carbon_monoxide": { "name": "Carbon monoxide" }, + "coil_freeze": { + "name": "Coil freeze/defrost" + }, "cover_off": { "name": "Cover off" }, @@ -48,12 +51,27 @@ "feeding": { "name": "Feeding" }, + "filter_cleaning": { + "name": "Filter cleaning" + }, "formaldehyde": { "name": "Formaldehyde" }, + "high_temp": { + "name": "High temperature" + }, + "low_humidity": { + "name": "Low humidity" + }, + "low_temp": { + "name": "Low temperature" + }, "methane": { "name": "Methane" }, + "motor_fault": { + "name": "Motor fault" + }, "pm25": { "name": "PM2.5" }, @@ -63,6 +81,9 @@ "tankfull": { "name": "Tank full" }, + "temp_error": { + "name": "Temperature error" + }, "tilt": { "name": "Tilt" }, @@ -214,6 +235,9 @@ "inverter_output_power_limit": { "name": "Inverter output power limit" }, + "irrigation_duration": { + "name": "Irrigation duration" + }, "maximum_brightness": { "name": "Maximum brightness" }, @@ -256,6 +280,15 @@ "time": { "name": "Time" }, + "video_brightness": { + "name": "Video brightness" + }, + "video_contrast": { + "name": "Video contrast" + }, + "video_sharpness": { + "name": "Video sharpness" + }, "voice_times": { "name": "Voice times" }, @@ -445,6 +478,15 @@ "1": "Continuous working mode" } }, + "kettle_work_mode": { + "name": "Work mode", + "state": { + "boiling_quick": "Quick boil", + "setting_quick": "Quick heat", + "temp_boiling": "Boil and keep warm", + "temp_setting": "Heat and keep warm" + } + }, "led_type": { "name": "Light source type", "state": { @@ -482,6 +524,16 @@ "smart": "Smart" } }, + "quick_heat_temperature": { + "name": "Quick heat temperature", + "state": { + "80": "80 °C", + "85": "85 °C", + "90": "90 °C", + "95": "95 °C", + "100": "100 °C" + } + }, "record_mode": { "name": "Record mode", "state": { @@ -643,6 +695,12 @@ "cleaning_time": { "name": "Cleaning time" }, + "coil_temperature": { + "name": "Coil temperature" + }, + "compressor_strength": { + "name": "Compressor strength" + }, "concentration_carbon_dioxide": { "name": "Concentration of carbon dioxide" }, @@ -679,12 +737,18 @@ "filter_utilization": { "name": "Filter utilization" }, + "flow_temperature": { + "name": "Flow temperature" + }, "formaldehyde": { "name": "[%key:component::tuya::entity::binary_sensor::formaldehyde::name%]" }, "gas": { "name": "Gas" }, + "heat_exchanger_temperature": { + "name": "Heat exchanger temperature" + }, "heat_index_temperature": { "name": "Heat index" }, @@ -706,12 +770,23 @@ "inverter_output_power": { "name": "Inverter output power" }, + "irrigation_status": { + "name": "Status", + "state": { + "auto": "[%key:common::state::auto%]", + "idle": "[%key:common::state::idle%]", + "manual": "[%key:common::state::manual%]" + } + }, "last_amount": { "name": "Last amount" }, "last_operation_duration": { "name": "Last operation duration" }, + "last_watering_time": { + "name": "Last watering time" + }, "lifetime_battery_charge_energy": { "name": "Lifetime battery charge energy" }, @@ -756,6 +831,9 @@ "work": "Working" } }, + "outside_temperature": { + "name": "Outside temperature" + }, "oxydo_reduction_potential": { "name": "Oxydo reduction potential" }, @@ -917,6 +995,11 @@ "name": "[%key:component::sensor::entity_component::wind_direction::name%]" } }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } + }, "switch": { "anion": { "name": "Anion" @@ -927,6 +1010,9 @@ "auto_clean": { "name": "Auto clean" }, + "auto_siren": { + "name": "Auto-trigger siren" + }, "battery_lock": { "name": "Battery lock" }, @@ -981,6 +1067,9 @@ "motion_alarm": { "name": "Motion alarm" }, + "motion_detection_zone": { + "name": "Use motion detection zone" + }, "motion_recording": { "name": "Motion recording" }, @@ -1099,6 +1188,80 @@ "exceptions": { "action_dpcode_not_found": { "message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})." + }, + "device_not_found": { + "message": "Feeder with ID {device_id} could not be found." + }, + "device_not_support_meal_plan_function": { + "message": "Feeder with ID {device_id} does not support meal plan functionality." + }, + "device_not_support_meal_plan_status": { + "message": "Feeder with ID {device_id} does not support meal plan status." + }, + "device_not_tuya_device": { + "message": "Device with ID {device_id} is not a Tuya feeder." + }, + "invalid_meal_plan_data": { + "message": "Unable to parse meal plan data." + } + }, + "selector": { + "days_of_week": { + "options": { + "friday": "[%key:common::time::friday%]", + "monday": "[%key:common::time::monday%]", + "saturday": "[%key:common::time::saturday%]", + "sunday": "[%key:common::time::sunday%]", + "thursday": "[%key:common::time::thursday%]", + "tuesday": "[%key:common::time::tuesday%]", + "wednesday": "[%key:common::time::wednesday%]" + } + }, + "set_feeder_meal_plan": { + "fields": { + "days": { + "description": "Days of the week for the meal plan.", + "name": "Days" + }, + "enabled": { + "description": "Whether the meal plan is enabled.", + "name": "Enabled" + }, + "portion": { + "description": "Amount in grams", + "name": "Portion" + }, + "time": { + "description": "Time of the meal.", + "name": "Time" + } + } + } + }, + "services": { + "get_feeder_meal_plan": { + "description": "Retrieves a meal plan from a Tuya feeder.", + "fields": { + "device_id": { + "description": "The Tuya feeder.", + "name": "[%key:common::config_flow::data::device%]" + } + }, + "name": "Get feeder meal plan data" + }, + "set_feeder_meal_plan": { + "description": "Sets a meal plan on a Tuya feeder.", + "fields": { + "device_id": { + "description": "[%key:component::tuya::services::get_feeder_meal_plan::fields::device_id::description%]", + "name": "[%key:common::config_flow::data::device%]" + }, + "meal_plan": { + "description": "The meal plan data to set.", + "name": "Meal plan" + } + }, + "name": "Set feeder meal plan data" } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index c603c760b53..1c8053ac92c 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,11 +1,9 @@ """Support for Tuya switches.""" -from __future__ import annotations - from typing import Any from tuya_device_handlers.definition.switch import ( - TuyaSwitchDefinition, + SwitchDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -20,8 +18,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity # All descriptions can be found here. Mostly the Boolean data types in the @@ -719,6 +717,16 @@ SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = { translation_key="motion_alarm", entity_category=EntityCategory.CONFIG, ), + SwitchEntityDescription( + key=DPCode.MOTION_AREA_SWITCH, + translation_key="motion_detection_zone", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.IPC_AUTO_SIREN, + translation_key="auto_siren", + entity_category=EntityCategory.CONFIG, + ), ), DeviceCategory.SZ: ( SwitchEntityDescription( @@ -893,6 +901,12 @@ SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = { ), ), DeviceCategory.ZNRB: ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -945,7 +959,7 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity): device: CustomerDevice, device_manager: Manager, description: SwitchEntityDescription, - definition: TuyaSwitchDefinition, + definition: SwitchDefinition, ) -> None: """Init TuyaHaSwitch.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index bf00f0c9d06..bca752d8114 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -1,32 +1,39 @@ """Utility methods for the Tuya integration.""" -from __future__ import annotations - +from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_sharing import CustomerDevice +from homeassistant.const import UnitOfTemperature from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo -from .const import DOMAIN, DPCode +from .const import CELSIUS_ALIASES, DOMAIN, FAHRENHEIT_ALIASES, DPCode + +_TEMP_UNIT_CONVERT_MAPPING = { + "c": UnitOfTemperature.CELSIUS, + "f": UnitOfTemperature.FAHRENHEIT, +} -def get_dpcode( - device: CustomerDevice, dpcodes: str | tuple[str, ...] | None -) -> str | None: - """Get the first matching DPCode from the device or return None.""" - if dpcodes is None: - return None +def get_temperature_unit( + device: CustomerDevice, dpcode_uom: str | None +) -> UnitOfTemperature | None: + """Convert the DPCode unit of measurement to a temperature unit.""" + if not dpcode_uom: + return get_device_temp_unit_convert(device) - if not isinstance(dpcodes, tuple): - dpcodes = (dpcodes,) + dpcode_uom = dpcode_uom.lower() + if dpcode_uom in CELSIUS_ALIASES: + return UnitOfTemperature.CELSIUS + if dpcode_uom in FAHRENHEIT_ALIASES: + return UnitOfTemperature.FAHRENHEIT + return None - for dpcode in dpcodes: - if ( - dpcode in device.function - or dpcode in device.status - or dpcode in device.status_range - ): - return dpcode +def get_device_temp_unit_convert(device: CustomerDevice) -> UnitOfTemperature | None: + """Return the temperature unit from TEMP_UNIT_CONVERT, or None if unrecognised.""" + if temp_unit_convert := device.status.get(DPCode.TEMP_UNIT_CONVERT): + return _TEMP_UNIT_CONVERT_MAPPING.get(temp_unit_convert) return None @@ -54,3 +61,32 @@ class ActionDPCodeNotFoundError(ServiceValidationError): "available": str(sorted(device.function.keys())), }, ) + + +def get_device_info(device: CustomerDevice, *, initial: bool = False) -> DeviceInfo: + """Get device info.""" + manufacturer = "Tuya" + model: str | None = device.product_name + model_id: str | None = device.product_id + + if initial: + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported + model = f"{device.product_name} (unsupported)" + + if ( + quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device) + ) and quirk.manufacturer: + # If the manufacturer is not set, we cannot trust the model/model_id + manufacturer = quirk.manufacturer + model = quirk.model + model_id = quirk.model_id + + return DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer=manufacturer, + name=device.name, + model=model, + model_id=model_id, + ) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index ef2eba4a5fa..a88bb08bf83 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -1,11 +1,9 @@ """Support for Tuya Vacuums.""" -from __future__ import annotations - from typing import Any from tuya_device_handlers.definition.vacuum import ( - TuyaVacuumDefinition, + VacuumDefinition, get_default_definition, ) from tuya_device_handlers.helpers.homeassistant import ( @@ -24,8 +22,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity _TUYA_TO_HA_ACTIVITY_MAPPINGS = { @@ -81,7 +79,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): device: CustomerDevice, device_manager: Manager, description: StateVacuumEntityDescription, - definition: TuyaVacuumDefinition, + definition: VacuumDefinition, ) -> None: """Init Tuya vacuum.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 00d62ad7824..dc9f5b2852d 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -1,9 +1,7 @@ """Support for Tuya valves.""" -from __future__ import annotations - from tuya_device_handlers.definition.valve import ( - TuyaValveDefinition, + ValveDefinition, get_default_definition, ) from tuya_sharing import CustomerDevice, Manager @@ -18,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { @@ -121,7 +119,7 @@ class TuyaValveEntity(TuyaEntity, ValveEntity): device: CustomerDevice, device_manager: Manager, description: ValveEntityDescription, - definition: TuyaValveDefinition, + definition: ValveDefinition, ) -> None: """Init TuyaValveEntity.""" super().__init__(device, device_manager, description) diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 1359e707601..dfa239458fd 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -1,18 +1,14 @@ """Support for Twente Milieu.""" -from __future__ import annotations +from typing import Any -import voluptuous as vol - -from homeassistant.const import CONF_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from .const import DOMAIN, SENSOR_UNIQUE_ID_MIGRATION from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator -SERVICE_UPDATE = "update" -SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) - PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] @@ -20,6 +16,21 @@ async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry ) -> bool: """Set up Twente Milieu from a config entry.""" + old_prefix = f"{DOMAIN}_{entry.unique_id}_" + + @callback + def _migrate_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, Any] | None: + if not entity_entry.unique_id.startswith(old_prefix): + return None + old_key = entity_entry.unique_id.removeprefix(old_prefix) + if (new_key := SENSOR_UNIQUE_ID_MIGRATION.get(old_key)) is None: + return None + return {"new_unique_id": f"{entry.unique_id}_{new_key}"} + + await er.async_migrate_entries(hass, entry.entry_id, _migrate_unique_id) + coordinator = TwenteMilieuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 19e3f4f3337..ebf65c2e6fc 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -1,7 +1,5 @@ """Support for Twente Milieu Calendar.""" -from __future__ import annotations - from datetime import datetime, timedelta from homeassistant.components.calendar import CalendarEntity, CalendarEvent @@ -27,7 +25,6 @@ async def async_setup_entry( class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): """Defines a Twente Milieu calendar.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "calendar" diff --git a/homeassistant/components/twentemilieu/config_flow.py b/homeassistant/components/twentemilieu/config_flow.py index e87dde3a699..9c899245233 100644 --- a/homeassistant/components/twentemilieu/config_flow.py +++ b/homeassistant/components/twentemilieu/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Twente Milieu integration.""" -from __future__ import annotations - from typing import Any from twentemilieu import ( diff --git a/homeassistant/components/twentemilieu/const.py b/homeassistant/components/twentemilieu/const.py index e5415e09b81..f75dd53df16 100644 --- a/homeassistant/components/twentemilieu/const.py +++ b/homeassistant/components/twentemilieu/const.py @@ -22,3 +22,11 @@ WASTE_TYPE_TO_DESCRIPTION = { WasteType.PAPER: "Paper waste pickup", WasteType.TREE: "Christmas tree pickup", } + +SENSOR_UNIQUE_ID_MIGRATION = { + "tree": "tree", + "Non-recyclable": "non_recyclable", + "Organic": "organic", + "Paper": "paper", + "Plastic": "packages", +} diff --git a/homeassistant/components/twentemilieu/coordinator.py b/homeassistant/components/twentemilieu/coordinator.py index d2cf5a887ef..233260be663 100644 --- a/homeassistant/components/twentemilieu/coordinator.py +++ b/homeassistant/components/twentemilieu/coordinator.py @@ -1,15 +1,18 @@ """Data update coordinator for Twente Milieu.""" -from __future__ import annotations - from datetime import date -from twentemilieu import TwenteMilieu, WasteType +from twentemilieu import ( + TwenteMilieu, + TwenteMilieuConnectionError, + TwenteMilieuError, + WasteType, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_HOUSE_LETTER, @@ -46,4 +49,15 @@ class TwenteMilieuDataUpdateCoordinator( async def _async_update_data(self) -> dict[WasteType, list[date]]: """Fetch Twente Milieu data.""" - return await self.twentemilieu.update() + try: + return await self.twentemilieu.update() + except TwenteMilieuConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except TwenteMilieuError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py index cb3b411c530..639f63e4fd1 100644 --- a/homeassistant/components/twentemilieu/diagnostics.py +++ b/homeassistant/components/twentemilieu/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for TwenteMilieu.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py index 660dd16288c..d61e55abe0e 100644 --- a/homeassistant/components/twentemilieu/entity.py +++ b/homeassistant/components/twentemilieu/entity.py @@ -1,7 +1,5 @@ """Base entity for the Twente Milieu integration.""" -from __future__ import annotations - from homeassistant.const import CONF_ID from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index b1cb98dbca6..9b25aae6e82 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twentemilieu"], - "quality_scale": "silver", - "requirements": ["twentemilieu==2.2.1"] + "quality_scale": "platinum", + "requirements": ["twentemilieu==3.0.0"] } diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml index 42ff152cb4d..4cdcf2d6e48 100644 --- a/homeassistant/components/twentemilieu/quality_scale.yaml +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -55,10 +55,7 @@ rules: This integration does not have an options flow. # Gold - entity-translations: - status: todo - comment: | - The calendar entity name isn't translated yet. + entity-translations: done entity-device-class: done devices: done entity-category: done @@ -73,12 +70,14 @@ rules: comment: | This integration has a fixed single device which represents the service. diagnostics: done - exception-translations: - status: todo - comment: | - The coordinator raises, and currently, doesn't provide a translation for it. + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: | + The unique ID provided by the service is tied to the address. + Changing the address would result in a different unique ID and + different waste collection properties. dynamic-devices: status: exempt comment: | diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 81751d10a81..5886805c2e9 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -1,7 +1,5 @@ """Support for Twente Milieu sensors.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import date @@ -12,11 +10,9 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .coordinator import TwenteMilieuConfigEntry from .entity import TwenteMilieuEntity @@ -36,25 +32,25 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( - key="Non-recyclable", + key="non_recyclable", translation_key="non_recyclable_waste_pickup", waste_type=WasteType.NON_RECYCLABLE, device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( - key="Organic", + key="organic", translation_key="organic_waste_pickup", waste_type=WasteType.ORGANIC, device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( - key="Paper", + key="paper", translation_key="paper_waste_pickup", waste_type=WasteType.PAPER, device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( - key="Plastic", + key="packages", translation_key="packages_waste_pickup", waste_type=WasteType.PACKAGES, device_class=SensorDeviceClass.DATE, @@ -86,7 +82,7 @@ class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity): """Initialize the Twente Milieu entity.""" super().__init__(entry) self.entity_description = description - self._attr_unique_id = f"{DOMAIN}_{entry.data[CONF_ID]}_{description.key}" + self._attr_unique_id = f"{entry.unique_id}_{description.key}" @property def native_value(self) -> date | None: diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json index 06d4be585de..db54581525e 100644 --- a/homeassistant/components/twentemilieu/strings.json +++ b/homeassistant/components/twentemilieu/strings.json @@ -41,5 +41,13 @@ "name": "Paper waste pickup" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Twente Milieu service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Twente Milieu service." + } } } diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index bcea6d6fb82..7f2d45f9002 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -1,7 +1,5 @@ """Twilio Call platform for notify component.""" -from __future__ import annotations - import logging from typing import Any import urllib @@ -68,5 +66,6 @@ class TwilioCallNotificationService(BaseNotificationService): self.client.calls.create( to=target, url=twimlet_url, from_=self.from_number ) + # pylint: disable-next=home-assistant-action-swallowed-exception except TwilioRestException as exc: _LOGGER.error(exc) diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index 24527fdaf53..0f3646390c5 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -1,7 +1,5 @@ """Twilio SMS platform for notify component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index e3b53bba6c9..313b64c899f 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -20,7 +20,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Set up entries from config flow.""" - # We setup the client here so if at some point we add any other entity for this device, + # We setup the client here so if at some point we add + # any other entity for this device, # we will be able to properly share the connection. host = entry.data[CONF_HOST] diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 39d86067ead..58e061a9cbf 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Twinkly integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py index 2c2fc2a41d4..f19eec13424 100644 --- a/homeassistant/components/twinkly/coordinator.py +++ b/homeassistant/components/twinkly/coordinator.py @@ -88,7 +88,7 @@ class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]): brightness = ( int(brightness["value"]) if brightness["mode"] == "enabled" else 100 ) - brightness = int(round(brightness * 2.55)) if is_on else 0 + brightness = round(brightness * 2.55) if is_on else 0 if self.device_name != device_info[DEV_NAME]: self._async_update_device_info(device_info[DEV_NAME]) return TwinklyData( diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index 2bf46a208e8..27c1b74b1e7 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Twinkly.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index c270421d8cd..08c56e5ed3c 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -1,7 +1,5 @@ """The Twinkly light component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index a5283b3f91d..dbe6c71f529 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -1,7 +1,5 @@ """The Twinkly select component.""" -from __future__ import annotations - import logging from ttls.client import TWINKLY_MODES diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index db1adab784d..88aa4dde066 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1,7 +1,5 @@ """The Twitch component.""" -from __future__ import annotations - from typing import cast from aiohttp.client_exceptions import ClientError, ClientResponseError diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index ed196897c11..9a1001e1232 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Twitch.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, cast diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 5d677c1a1bc..431e8bb5e18 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -1,7 +1,5 @@ """Support for the Twitch stream status.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index 7799cfbb85e..9345384a59c 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -1,7 +1,5 @@ """Twitter platform for notify component.""" -from __future__ import annotations - from datetime import datetime, timedelta from functools import partial from http import HTTPStatus diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 7c50b69683f..0fbc4507f44 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -1,7 +1,5 @@ """Support for OpenWRT (ubus) routers.""" -from __future__ import annotations - import logging import re diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index 594d46c74ab..2ae96d8127b 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -1,7 +1,5 @@ """Support for UK public transport data provided by transportapi.com.""" -from __future__ import annotations - from datetime import datetime, timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index c5cdd3bfb3e..6c577f1e4d0 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -1,7 +1,5 @@ """The ukraine_alarm component.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -15,31 +13,32 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS -from .coordinator import UkraineAlarmDataUpdateCoordinator +from .coordinator import UkraineAlarmConfigEntry, UkraineAlarmDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: UkraineAlarmConfigEntry +) -> bool: """Set up Ukraine Alarm as config entry.""" websession = async_get_clientsession(hass) coordinator = UkraineAlarmDataUpdateCoordinator(hass, entry, websession) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: UkraineAlarmConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -56,7 +55,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> regions_data = await Client(websession).get_regions() except (aiohttp.ClientError, TimeoutError) as err: _LOGGER.warning( - "Could not migrate config entry %s: failed to fetch current regions: %s", + "Could not migrate config entry %s:" + " failed to fetch current regions: %s", config_entry.entry_id, err, ) diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index 9009031ea14..6da67d982c7 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -1,13 +1,10 @@ """binary sensors for Ukraine Alarm integration.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -25,7 +22,7 @@ from .const import ( DOMAIN, MANUFACTURER, ) -from .coordinator import UkraineAlarmDataUpdateCoordinator +from .coordinator import UkraineAlarmConfigEntry, UkraineAlarmDataUpdateCoordinator BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( @@ -63,12 +60,12 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UkraineAlarmConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ukraine Alarm binary sensor entities based on a config entry.""" name = config_entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( UkraineAlarmSensor( diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index c65b1a3713f..66ec416dcbf 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Ukraine Alarm.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any @@ -95,7 +93,8 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): source = self.states if user_input is not None: - # Only offer to browse subchildren if picked region wasn't the previously picked one + # Only offer to browse subchildren if picked + # region wasn't the previously picked one if ( not self.selected_region or user_input[CONF_REGION] != self.selected_region["regionId"] diff --git a/homeassistant/components/ukraine_alarm/const.py b/homeassistant/components/ukraine_alarm/const.py index 6634bacf698..a6006b11155 100644 --- a/homeassistant/components/ukraine_alarm/const.py +++ b/homeassistant/components/ukraine_alarm/const.py @@ -1,7 +1,5 @@ """Consts for the Ukraine Alarm.""" -from __future__ import annotations - from homeassistant.const import Platform DOMAIN = "ukraine_alarm" diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index b4e1decb1a1..1a8f6d23283 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -1,7 +1,5 @@ """The ukraine_alarm component.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -21,16 +19,18 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=10) +type UkraineAlarmConfigEntry = ConfigEntry[UkraineAlarmDataUpdateCoordinator] + class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Ukraine Alarm API.""" - config_entry: ConfigEntry + config_entry: UkraineAlarmConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UkraineAlarmConfigEntry, session: ClientSession, ) -> None: """Initialize.""" diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 15b0fbafead..042da2b61c1 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -51,6 +51,19 @@ async def async_setup_entry( hub = config_entry.runtime_data = UnifiHub(hass, config_entry, api) await hub.initialize() + # Pre-populate device registry with UniFi devices before forwarding to + # platforms. Without this, device_tracker entities may be registered as + # disabled-by-default if their platform is set up before another platform + # creates the device entry, since their default enabled state depends on + # the matching device existing in the registry. Other fields are populated + # when entities with DeviceInfo are added by their respective platforms. + device_registry = dr.async_get(hass) + for device in hub.api.devices.values(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.async_update_device_registry() hub.entity_loader.load_entities() diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 470f0091fff..98de4527950 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -3,8 +3,6 @@ Support for restarting UniFi devices. """ -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import secrets @@ -31,9 +29,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry +from .const import DOMAIN from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -46,6 +46,8 @@ from .entity import ( if TYPE_CHECKING: from .hub import UnifiHub +PARALLEL_UPDATES = 1 + @callback def async_port_power_cycle_available_fn(hub: UnifiHub, obj_id: str) -> bool: @@ -97,21 +99,21 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( available_fn=async_device_available_fn, control_fn=async_restart_device_control_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda _: "Restart", object_fn=lambda api, obj_id: api.devices[obj_id], unique_id_fn=lambda hub, obj_id: f"device_restart-{obj_id}", ), UnifiButtonEntityDescription[Ports, Port]( key="PoE power cycle", + translation_key="port_power_cycle", entity_category=EntityCategory.CONFIG, device_class=ButtonDeviceClass.RESTART, api_handler_fn=lambda api: api.ports, available_fn=async_port_power_cycle_available_fn, control_fn=async_power_cycle_port_control_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda port: f"{port.name} Power Cycle", object_fn=lambda api, obj_id: api.ports[obj_id], supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe), + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"power_cycle-{obj_id}", ), UnifiButtonEntityDescription[Wlans, Wlan]( @@ -124,7 +126,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( available_fn=async_wlan_available_fn, control_fn=async_regenerate_password_control_fn, device_info_fn=async_wlan_device_info_fn, - name_fn=lambda wlan: "Regenerate Password", object_fn=lambda api, obj_id: api.wlans[obj_id], unique_id_fn=lambda hub, obj_id: f"regenerate_password-{obj_id}", ), @@ -151,7 +152,13 @@ class UnifiButtonEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( async def async_press(self) -> None: """Press the button.""" - await self.entity_description.control_fn(self.api, self._obj_id) + try: + await self.entity_description.control_fn(self.api, self._obj_id) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index c8c6a54f9fe..3672ca8b95e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,19 +1,16 @@ """Config flow for UniFi Network integration. Provides user initiated configuration flow. -Discovery of UniFi Network instances hosted on UDM and UDM Pro devices -through SSDP. Reauthentication when issue with credentials are reported. +Discovery of UniFi Network instances through unifi_discovery. +Reauthentication when issue with credentials are reported. Configuration of options through options flow. """ -from __future__ import annotations - from collections.abc import Mapping import operator import socket from types import MappingProxyType from typing import Any -from urllib.parse import urlparse from aiounifi.interfaces.sites import Sites import voluptuous as vol @@ -33,13 +30,10 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import SectionConfig, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.service_info.ssdp import ( - ATTR_UPNP_MODEL_DESCRIPTION, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) +from homeassistant.helpers.typing import DiscoveryInfoType from . import UnifiConfigEntry from .const import ( @@ -50,6 +44,7 @@ from .const import ( CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, + CONF_MORE_OPTIONS, CONF_SITE_ID, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, @@ -66,12 +61,6 @@ DEFAULT_SITE_ID = "default" DEFAULT_VERIFY_SSL = False -MODEL_PORTS = { - "UniFi Dream Machine": 443, - "UniFi Dream Machine Pro": 443, -} - - class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a UniFi Network config flow.""" @@ -144,7 +133,10 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_PORT, default=self.config.get(CONF_PORT, DEFAULT_PORT) ): int, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + vol.Optional( + CONF_VERIFY_SSL, + default=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ): bool, } return self.async_show_form( @@ -176,7 +168,7 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="already_configured") - return self.async_update_reload_and_abort( + return self.async_update_and_abort( config_entry, data=self.config, reason=abort_reason ) @@ -215,33 +207,34 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo + async def async_step_integration_discovery( + self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: - """Handle a discovered UniFi device.""" - parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) + """Handle discovery via unifi_discovery.""" + source_ip = discovery_info["source_ip"] + if not source_ip: + return self.async_abort(reason="cannot_connect") + mac_address = format_mac(discovery_info["hw_addr"]) + direct_connect_domain = discovery_info.get("direct_connect_domain") + host = direct_connect_domain or source_ip self.config = { - CONF_HOST: parsed_url.hostname, + CONF_HOST: host, + CONF_VERIFY_SSL: bool(direct_connect_domain), } - self._async_abort_entries_match({CONF_HOST: self.config[CONF_HOST]}) + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_HOST) in (source_ip, direct_connect_domain): + return self.async_abort(reason="already_configured") await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured(updates=self.config) + self._abort_if_unique_id_configured(updates=self.config, reload_on_update=False) self.context["title_placeholders"] = { - CONF_HOST: self.config[CONF_HOST], + CONF_HOST: host, CONF_SITE_ID: DEFAULT_SITE_ID, } - - if (port := MODEL_PORTS.get(model_description)) is not None: - self.config[CONF_PORT] = port - self.context["configuration_url"] = ( - f"https://{self.config[CONF_HOST]}:{port}" - ) + self.context["configuration_url"] = f"https://{host}" return await self.async_step_user() @@ -262,53 +255,23 @@ class UnifiOptionsFlowHandler(OptionsFlow): self.hub = self.config_entry.runtime_data self.options[CONF_BLOCK_CLIENT] = self.hub.config.option_block_clients - if self.show_advanced_options: - return await self.async_step_configure_entity_sources() - - return await self.async_step_simple_options() - - async def async_step_simple_options( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """For users without advanced settings enabled.""" if user_input is not None: + more_options = user_input.pop(CONF_MORE_OPTIONS, {}) + user_input.update(more_options) self.options.update(user_input) - return await self._update_options() + return self.async_create_entry(title="", data=self.options) clients_to_block = {} - for client in self.hub.api.clients.values(): clients_to_block[client.mac] = ( f"{client.name or client.hostname} ({client.mac})" ) - return self.async_show_form( - step_id="simple_options", - data_schema=vol.Schema( - { - vol.Optional( - CONF_TRACK_CLIENTS, - default=self.hub.config.option_track_clients, - ): bool, - vol.Optional( - CONF_TRACK_DEVICES, - default=self.hub.config.option_track_devices, - ): bool, - vol.Optional( - CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] - ): cv.multi_select(clients_to_block), - } - ), - last_step=True, - ) - - async def async_step_configure_entity_sources( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Select sources for entities.""" - if user_input is not None: - self.options.update(user_input) - return await self.async_step_device_tracker() + selected_clients_to_block = [ + client + for client in self.options.get(CONF_BLOCK_CLIENT, []) + if client in clients_to_block + ] clients = { client.mac: f"{client.name or client.hostname} ({client.mac})" @@ -320,29 +283,6 @@ class UnifiOptionsFlowHandler(OptionsFlow): if mac not in clients } - return self.async_show_form( - step_id="configure_entity_sources", - data_schema=vol.Schema( - { - vol.Optional( - CONF_CLIENT_SOURCE, - default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select( - dict(sorted(clients.items(), key=operator.itemgetter(1))) - ), - } - ), - last_step=False, - ) - - async def async_step_device_tracker( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the device tracker options.""" - if user_input is not None: - self.options.update(user_input) - return await self.async_step_client_control() - ssids = ( {wlan.name for wlan in self.hub.api.wlans.values()} | { @@ -365,107 +305,76 @@ class UnifiOptionsFlowHandler(OptionsFlow): ] return self.async_show_form( - step_id="device_tracker", + step_id="init", data_schema=vol.Schema( { vol.Optional( CONF_TRACK_CLIENTS, default=self.hub.config.option_track_clients, ): bool, - vol.Optional( - CONF_TRACK_WIRED_CLIENTS, - default=self.hub.config.option_track_wired_clients, - ): bool, vol.Optional( CONF_TRACK_DEVICES, default=self.hub.config.option_track_devices, ): bool, - vol.Optional( - CONF_SSID_FILTER, default=selected_ssids_to_filter - ): cv.multi_select(ssid_filter), - vol.Optional( - CONF_DETECTION_TIME, - default=int( - self.hub.config.option_detection_time.total_seconds() - ), - ): int, - vol.Optional( - CONF_IGNORE_WIRED_BUG, - default=self.hub.config.option_ignore_wired_bug, - ): bool, - } - ), - last_step=False, - ) - - async def async_step_client_control( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage configuration of network access controlled clients.""" - if user_input is not None: - self.options.update(user_input) - return await self.async_step_statistics_sensors() - - clients_to_block = {} - - for client in self.hub.api.clients.values(): - clients_to_block[client.mac] = ( - f"{client.name or client.hostname} ({client.mac})" - ) - - selected_clients_to_block = [ - client - for client in self.options.get(CONF_BLOCK_CLIENT, []) - if client in clients_to_block - ] - - return self.async_show_form( - step_id="client_control", - data_schema=vol.Schema( - { vol.Optional( CONF_BLOCK_CLIENT, default=selected_clients_to_block ): cv.multi_select(clients_to_block), - vol.Optional( - CONF_DPI_RESTRICTIONS, - default=self.options.get( - CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS + vol.Required(CONF_MORE_OPTIONS): section( + vol.Schema( + { + vol.Optional( + CONF_CLIENT_SOURCE, + default=self.options.get(CONF_CLIENT_SOURCE, []), + ): cv.multi_select( + dict( + sorted( + clients.items(), + key=operator.itemgetter(1), + ) + ) + ), + vol.Optional( + CONF_TRACK_WIRED_CLIENTS, + default=self.hub.config.option_track_wired_clients, + ): bool, + vol.Optional( + CONF_SSID_FILTER, + default=selected_ssids_to_filter, + ): cv.multi_select(ssid_filter), + vol.Optional( + CONF_DETECTION_TIME, + default=int( + self.hub.config.option_detection_time.total_seconds() + ), + ): int, + vol.Optional( + CONF_IGNORE_WIRED_BUG, + default=self.hub.config.option_ignore_wired_bug, + ): bool, + vol.Optional( + CONF_DPI_RESTRICTIONS, + default=self.options.get( + CONF_DPI_RESTRICTIONS, + DEFAULT_DPI_RESTRICTIONS, + ), + ): bool, + vol.Optional( + CONF_ALLOW_BANDWIDTH_SENSORS, + default=self.hub.config.option_allow_bandwidth_sensors, + ): bool, + vol.Optional( + CONF_ALLOW_UPTIME_SENSORS, + default=self.hub.config.option_allow_uptime_sensors, + ): bool, + } ), - ): bool, - } - ), - last_step=False, - ) - - async def async_step_statistics_sensors( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the statistics sensors options.""" - if user_input is not None: - self.options.update(user_input) - return await self._update_options() - - return self.async_show_form( - step_id="statistics_sensors", - data_schema=vol.Schema( - { - vol.Optional( - CONF_ALLOW_BANDWIDTH_SENSORS, - default=self.hub.config.option_allow_bandwidth_sensors, - ): bool, - vol.Optional( - CONF_ALLOW_UPTIME_SENSORS, - default=self.hub.config.option_allow_uptime_sensors, - ): bool, + SectionConfig(collapsed=True), + ), } ), last_step=True, ) - async def _update_options(self) -> ConfigFlowResult: - """Update config entry options.""" - return self.async_create_entry(title="", data=self.options) - async def _async_discover_unifi(hass: HomeAssistant) -> str | None: """Discover UniFi Network address.""" diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index decbc8bb523..f3d088b42b1 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -33,6 +33,7 @@ CONF_IGNORE_WIRED_BUG = "ignore_wired_bug" CONF_TRACK_CLIENTS = "track_clients" CONF_TRACK_DEVICES = "track_devices" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" +CONF_MORE_OPTIONS = "more_options" CONF_SSID_FILTER = "ssid_filter" DEFAULT_ALLOW_BANDWIDTH_SENSORS = False diff --git a/homeassistant/components/unifi/coordinator.py b/homeassistant/components/unifi/coordinator.py index 9b840d77132..16e0d9c0094 100644 --- a/homeassistant/components/unifi/coordinator.py +++ b/homeassistant/components/unifi/coordinator.py @@ -1,7 +1,5 @@ """UniFi Network data update coordinator.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 8d82c7334c6..41ebfd2ed31 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,7 +1,5 @@ """Track both clients and devices using UniFi Network.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import timedelta @@ -19,22 +17,20 @@ from aiounifi.models.event import Event, EventKey from propcache.api import cached_property from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import UnifiConfigEntry -from .const import DOMAIN from .entity import UnifiEntity, UnifiEntityDescription, async_device_available_fn from .hub import UnifiHub LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 CLIENT_TRACKER = "client" DEVICE_TRACKER = "device" @@ -186,39 +182,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( ) -@callback -def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) -> None: - """Normalize client unique ID to have a prefix rather than suffix. - - Introduced with release 2023.12. - """ - hub = config_entry.runtime_data - ent_reg = er.async_get(hass) - - @callback - def update_unique_id(obj_id: str) -> None: - """Rework unique ID.""" - new_unique_id = f"{hub.site}-{obj_id}" - if ent_reg.async_get_entity_id(DEVICE_TRACKER_DOMAIN, DOMAIN, new_unique_id): - return - - unique_id = f"{obj_id}-{hub.site}" - if entity_id := ent_reg.async_get_entity_id( - DEVICE_TRACKER_DOMAIN, DOMAIN, unique_id - ): - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) - - for obj_id in list(hub.api.clients) + list(hub.api.clients_all): - update_unique_id(obj_id) - - async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" - async_update_unique_id(hass, config_entry) config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 49a9b678b0f..772e52b1f4e 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for UniFi Network.""" -from __future__ import annotations - from collections.abc import Mapping from itertools import chain from typing import Any diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 03fae17f689..27e7ad9408d 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -1,9 +1,7 @@ """UniFi entity representation.""" -from __future__ import annotations - from abc import abstractmethod -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING @@ -115,6 +113,8 @@ class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescrip """Entity name function, can be used to extend entity name beyond device name.""" supported_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: True """Determine if UniFi object supports providing relevant data for entity.""" + translation_placeholders_fn: Callable[[ItemT], Mapping[str, str]] | None = None + """Provide translation placeholders used together with translation_key.""" # Optional constants has_entity_name = True # Part of EntityDescription @@ -155,7 +155,12 @@ class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity): self._attr_unique_id = description.unique_id_fn(hub, obj_id) obj = description.object_fn(self.api, obj_id) - self._attr_name = description.name_fn(obj) + if (name := description.name_fn(obj)) is not None: + self._attr_name = name + if description.translation_placeholders_fn is not None: + self._attr_translation_placeholders = ( + description.translation_placeholders_fn(obj) + ) self.async_initiate_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py index 8cfe06c1b55..37ede4ab6c5 100644 --- a/homeassistant/components/unifi/hub/api.py +++ b/homeassistant/components/unifi/hub/api.py @@ -1,7 +1,5 @@ """Provide an object to communicate with UniFi Network application.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import ssl diff --git a/homeassistant/components/unifi/hub/config.py b/homeassistant/components/unifi/hub/config.py index 52b15e1353c..ef3c48e3c1b 100644 --- a/homeassistant/components/unifi/hub/config.py +++ b/homeassistant/components/unifi/hub/config.py @@ -1,7 +1,5 @@ """UniFi Network config entry abstraction.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import ssl diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py index b353ba6fc5c..16d36e7bdca 100644 --- a/homeassistant/components/unifi/hub/entity_helper.py +++ b/homeassistant/components/unifi/hub/entity_helper.py @@ -1,7 +1,5 @@ """UniFi Network entity helper.""" -from __future__ import annotations - from datetime import datetime, timedelta import aiounifi diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 3400e707ba2..067b8a44865 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -4,8 +4,6 @@ Central point to load entities for the different platforms. Make sure expected clients are available for platforms. """ -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index 6cf8825a26c..ee130ac4d99 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -1,12 +1,17 @@ """UniFi Network abstraction.""" -from __future__ import annotations - from datetime import datetime from typing import TYPE_CHECKING import aiounifi +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import ( @@ -131,6 +136,27 @@ class UnifiHub: the entry might already have been reset and thus is not available. """ hub = config_entry.runtime_data + check_keys = { + CONF_HOST: "host", + CONF_PORT: "port", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_SITE_ID: "site", + CONF_VERIFY_SSL: "ssl_context", + } + for key, value in check_keys.items(): + if key == CONF_VERIFY_SSL: + # ssl_context is either False or a SSLContext + # object, so we need to compare it differently + if config_entry.data[CONF_VERIFY_SSL] != bool( + getattr(hub.config, value) + ): + hass.config_entries.async_schedule_reload(config_entry.entry_id) + return + if config_entry.data[key] != getattr(hub.config, value): + hass.config_entries.async_schedule_reload(config_entry.entry_id) + return + hub.config = UnifiConfig.from_config_entry(config_entry) async_dispatcher_send(hass, hub.signal_options_update) diff --git a/homeassistant/components/unifi/hub/websocket.py b/homeassistant/components/unifi/hub/websocket.py index 143d6939e9c..3fdd92d7cb3 100644 --- a/homeassistant/components/unifi/hub/websocket.py +++ b/homeassistant/components/unifi/hub/websocket.py @@ -1,7 +1,5 @@ """Websocket handler for UniFi Network integration.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 842e9732b5e..6608cdb5eeb 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -3,8 +3,6 @@ Support for QR code for guest WLANs. """ -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -28,6 +26,8 @@ from .entity import ( ) from .hub import UnifiHub +PARALLEL_UPDATES = 0 + @callback def async_wlan_qr_code_image_fn(hub: UnifiHub, wlan: Wlan) -> bytes: @@ -54,7 +54,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, device_info_fn=async_wlan_device_info_fn, - name_fn=lambda wlan: "QR Code", object_fn=lambda api, obj_id: api.wlans[obj_id], unique_id_fn=lambda hub, obj_id: f"qr_code-{obj_id}", image_fn=async_wlan_qr_code_image_fn, diff --git a/homeassistant/components/unifi/light.py b/homeassistant/components/unifi/light.py index 32b66cf9da7..9b9d4026823 100644 --- a/homeassistant/components/unifi/light.py +++ b/homeassistant/components/unifi/light.py @@ -1,11 +1,10 @@ """Light platform for UniFi Network integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast +from aiounifi import AiounifiException from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.devices import Devices from aiounifi.models.api import ApiItem @@ -21,10 +20,12 @@ from homeassistant.components.light import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import rgb_hex_to_rgb_list from . import UnifiConfigEntry +from .const import DOMAIN from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -35,6 +36,8 @@ from .entity import ( if TYPE_CHECKING: from .hub import UnifiHub +PARALLEL_UPDATES = 1 + def convert_brightness_to_unifi(ha_brightness: int) -> int: """Convert Home Assistant brightness (0-255) to UniFi brightness (0-100).""" @@ -49,7 +52,7 @@ def convert_brightness_to_ha( def get_device_brightness_or_default(device: Device) -> int: - """Get device's current LED brightness. Defaults to 100 (full brightness) if not set.""" + """Get device LED brightness, default 100 if not set.""" value = device.led_override_color_brightness return value if value is not None else 100 @@ -125,7 +128,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = ( control_fn=async_device_led_control_fn, device_info_fn=async_device_device_info_fn, is_on_fn=async_device_led_is_on_fn, - name_fn=lambda device: "LED", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=async_device_led_supported_fn, unique_id_fn=lambda hub, obj_id: f"led-{obj_id}", @@ -171,13 +173,27 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" - await self.entity_description.control_fn(self.hub, self._obj_id, True, **kwargs) + try: + await self.entity_description.control_fn( + self.hub, self._obj_id, True, **kwargs + ) + except AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" - await self.entity_description.control_fn( - self.hub, self._obj_id, False, **kwargs - ) + try: + await self.entity_description.control_fn( + self.hub, self._obj_id, False, **kwargs + ) + except AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f025caaaa97..6b5b7631898 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,27 +3,11 @@ "name": "UniFi Network", "codeowners": ["@Kane610"], "config_flow": true, + "dependencies": ["unifi_discovery"], "documentation": "https://www.home-assistant.io/integrations/unifi", "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==90"], - "ssdp": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max" - } - ] + "quality_scale": "silver", + "requirements": ["aiounifi==91"] } diff --git a/homeassistant/components/unifi/quality_scale.yaml b/homeassistant/components/unifi/quality_scale.yaml new file mode 100644 index 00000000000..637c1caad3b --- /dev/null +++ b/homeassistant/components/unifi/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: + status: exempt + comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + The user flow currently allows updating existing config entry data + (host/credentials), which should be handled by a dedicated + async_step_reconfigure instead. + repair-issues: todo + stale-devices: + status: todo + comment: | + Only manual removal via async_remove_config_entry_device; no automatic + cleanup of devices removed from the UniFi controller. Consider also + whether device tracker clients should be split into their own device + registry entries. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 4f3e8528256..97725d07379 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -4,8 +4,6 @@ Support for bandwidth sensors of network clients. Support for uptime sensors of network clients. """ -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timedelta @@ -63,6 +61,8 @@ from .entity import ( ) from .hub import UnifiHub +PARALLEL_UPDATES = 0 + @callback def async_bandwidth_sensor_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: @@ -150,7 +150,7 @@ def async_device_clients_value_fn(hub: UnifiHub, device: Device) -> int: @callback def async_device_uptime_value_fn(hub: UnifiHub, device: Device) -> datetime | None: - """Calculate the approximate time the device started (based on uptime returned from API, in seconds).""" + """Calculate the approximate time the device started.""" if device.uptime <= 0: # Library defaults to 0 if uptime is not provided, e.g. when offline return None @@ -161,7 +161,7 @@ def async_device_uptime_value_fn(hub: UnifiHub, device: Device) -> datetime | No def async_uptime_value_changed_fn( old: StateType | date | datetime | Decimal, new: datetime | float | str | None ) -> bool: - """Reject the new uptime value if it's too similar to the old one. Avoids unwanted fluctuation.""" + """Reject new uptime if too similar to old. Avoids fluctuation.""" if isinstance(old, datetime) and isinstance(new, datetime): return new != old and abs((new - old).total_seconds()) > 120 return old is None or (new != old) @@ -268,6 +268,7 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]: name_wan = f"{name} {wan}" return UnifiSensorEntityDescription[Devices, Device]( key=f"{name_wan} latency", + translation_key="wan_latency", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, @@ -276,11 +277,11 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]: api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: f"{name_wan} latency", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=partial( async_device_wan_latency_supported_fn, wan, monitor_target ), + translation_placeholders_fn=lambda _: {"target": name, "wan": wan}, unique_id_fn=lambda hub, obj_id: f"{slugify(name_wan)}_latency-{obj_id}", value_fn=partial(async_device_wan_latency_value_fn, wan, monitor_target), ) @@ -352,6 +353,7 @@ def make_device_temperatur_sensors() -> tuple[UnifiSensorEntityDescription, ...] ) -> UnifiSensorEntityDescription: return UnifiSensorEntityDescription[Devices, Device]( key=f"Device {name} temperature", + translation_key="device_sub_temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -360,9 +362,9 @@ def make_device_temperatur_sensors() -> tuple[UnifiSensorEntityDescription, ...] api_handler_fn=lambda api: api.devices, available_fn=partial(async_device_temperatures_available_fn, name), device_info_fn=async_device_device_info_fn, - name_fn=lambda device: f"{device.name} {name} Temperature", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=partial(async_device_temperatures_supported_fn, name), + translation_placeholders_fn=lambda _: {"name": name}, unique_id_fn=lambda hub, obj_id: f"temperature-{slugify(name)}-{obj_id}", value_fn=partial(async_device_temperatures_value_fn, name), ) @@ -407,7 +409,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, is_connected_fn=async_client_is_connected_fn, - name_fn=lambda _: "RX", object_fn=lambda api, obj_id: api.clients[obj_id], supported_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors, unique_id_fn=lambda hub, obj_id: f"rx-{obj_id}", @@ -424,7 +425,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, is_connected_fn=async_client_is_connected_fn, - name_fn=lambda _: "TX", object_fn=lambda api, obj_id: api.clients[obj_id], supported_fn=lambda hub, _: hub.config.option_allow_bandwidth_sensors, unique_id_fn=lambda hub, obj_id: f"tx-{obj_id}", @@ -442,13 +442,13 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, is_connected_fn=async_client_is_connected_fn, - name_fn=lambda _: "Link speed", object_fn=lambda api, obj_id: api.clients[obj_id], unique_id_fn=lambda hub, obj_id: f"wired_speed-{obj_id}", value_fn=lambda hub, client: client.wired_rate_mbps, ), UnifiSensorEntityDescription[Ports, Port]( key="PoE port power sensor", + translation_key="port_poe_power", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -457,9 +457,9 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda port: f"{port.name} PoE Power", object_fn=lambda api, obj_id: api.ports[obj_id], supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe), + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"poe_power-{obj_id}", value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", ), @@ -476,8 +476,8 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda port: f"{port.name} RX", object_fn=lambda api, obj_id: api.ports[obj_id], + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"port_rx-{obj_id}", value_fn=lambda hub, port: port.rx_bytes_r, ), @@ -494,8 +494,8 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda port: f"{port.name} TX", object_fn=lambda api, obj_id: api.ports[obj_id], + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"port_tx-{obj_id}", value_fn=lambda hub, port: port.tx_bytes_r, ), @@ -510,21 +510,20 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda port: f"{port.name} link speed", object_fn=lambda api, obj_id: api.ports[obj_id], supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].raw.get("speed", 0) > 0, + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"port_link_speed-{obj_id}", value_fn=lambda hub, port: port.raw.get("speed", 0), ), UnifiSensorEntityDescription[Clients, Client]( key="Client uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, allowed_fn=async_uptime_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, device_info_fn=async_client_device_info_fn, - name_fn=lambda client: "Uptime", object_fn=lambda api, obj_id: api.clients[obj_id], supported_fn=lambda hub, _: hub.config.option_allow_uptime_sensors, unique_id_fn=lambda hub, obj_id: f"uptime-{obj_id}", @@ -553,7 +552,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "Clients", object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=True, unique_id_fn=lambda hub, obj_id: f"device_clients-{obj_id}", @@ -561,6 +559,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Outlets, Outlet]( key="Outlet power metering", + translation_key="outlet_power", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -568,15 +567,16 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.outlets, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda outlet: f"{outlet.name} Outlet Power", object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=True, supported_fn=async_device_outlet_power_supported_fn, + translation_placeholders_fn=lambda outlet: {"outlet_name": outlet.name}, unique_id_fn=lambda hub, obj_id: f"outlet_power-{obj_id}", value_fn=lambda _, obj: obj.power if obj.relay_state else "0", ), UnifiSensorEntityDescription[Devices, Device]( key="SmartPower AC power budget", + translation_key="smartpower_ac_power_budget", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -585,7 +585,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "AC Power Budget", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=async_device_outlet_supported_fn, unique_id_fn=lambda hub, obj_id: f"ac_power_budget-{obj_id}", @@ -593,6 +592,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="SmartPower AC power consumption", + translation_key="smartpower_ac_power_consumption", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -601,7 +601,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "AC Power Consumption", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=async_device_outlet_supported_fn, unique_id_fn=lambda hub, obj_id: f"ac_power_conumption-{obj_id}", @@ -609,12 +608,11 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "Uptime", object_fn=lambda api, obj_id: api.devices[obj_id], unique_id_fn=lambda hub, obj_id: f"device_uptime-{obj_id}", value_fn=async_device_uptime_value_fn, @@ -628,7 +626,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "Temperature", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=lambda hub, obj_id: hub.api.devices[obj_id].has_temperature, unique_id_fn=lambda hub, obj_id: f"device_temperature-{obj_id}", @@ -641,7 +638,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "Uplink MAC", object_fn=lambda api, obj_id: api.devices[obj_id], unique_id_fn=lambda hub, obj_id: f"device_uplink_mac-{obj_id}", supported_fn=async_device_uplink_mac_supported_fn, @@ -656,7 +652,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "State", object_fn=lambda api, obj_id: api.devices[obj_id], unique_id_fn=lambda hub, obj_id: f"device_state-{obj_id}", value_fn=async_device_state_value_fn, @@ -671,7 +666,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "CPU utilization", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=partial(device_system_stats_supported_fn, 0), unique_id_fn=lambda hub, obj_id: f"cpu_utilization-{obj_id}", @@ -686,7 +680,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, - name_fn=lambda device: "Memory utilization", object_fn=lambda api, obj_id: api.devices[obj_id], supported_fn=partial(device_system_stats_supported_fn, 1), unique_id_fn=lambda hub, obj_id: f"memory_utilization-{obj_id}", @@ -733,7 +726,8 @@ class UnifiSensorEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( """ description = self.entity_description obj = description.object_fn(self.api, self._obj_id) - # Update the value only if value is considered to have changed relative to its previous state + # Update the value only if value is considered to + # have changed relative to its previous state if description.value_changed_fn( self.native_value, (value := description.value_fn(self.hub, obj)) ): diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 6cd652871d8..3dbffa8bbe9 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -3,11 +3,13 @@ from collections.abc import Mapping from typing import Any +import aiounifi from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -55,7 +57,10 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - device_entry = device_registry.async_get(data[ATTR_DEVICE_ID]) if device_entry is None: - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="reconnect_client_device_not_found", + ) mac = "" for connection in device_entry.connections: @@ -64,7 +69,10 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - break if mac == "": - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="reconnect_client_no_mac", + ) for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): if ( @@ -74,7 +82,13 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - ): continue - await hub.api.request(ClientReconnectRequest.create(mac)) + try: + await hub.api.request(ClientReconnectRequest.create(mac)) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reconnect_client_request_failed", + ) from err async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> None: @@ -104,4 +118,10 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> clients_to_remove.append(client.mac) if clients_to_remove: - await hub.api.request(ClientRemoveRequest.create(clients_to_remove)) + try: + await hub.api.request(ClientRemoveRequest.create(clients_to_remove)) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="remove_clients_request_failed", + ) from err diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index ef6a7c1d42c..53804f6d085 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UniFi Network site is already configured", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "configuration_updated": "Configuration updated", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, @@ -15,6 +16,9 @@ "site": { "data": { "site": "Site ID" + }, + "data_description": { + "site": "The site ID of the UniFi Network site to manage." } }, "user": { @@ -27,20 +31,53 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "Hostname or IP address of your UniFi Network." + "host": "Hostname or IP address of your UniFi Network.", + "password": "The password of the local UniFi Network user.", + "port": "The port your UniFi Network is running on.", + "username": "The username of the local UniFi Network user.", + "verify_ssl": "Whether to verify the SSL certificate of the UniFi Network." }, "title": "Set up UniFi Network" } } }, "entity": { + "button": { + "port_power_cycle": { + "name": "{port_name} power cycle" + }, + "wlan_regenerate_password": { + "name": "Regenerate password" + } + }, + "image": { + "wlan_qr_code": { + "name": "QR code" + } + }, "light": { "led_control": { "name": "LED" } }, "sensor": { + "client_bandwidth_rx": { + "name": "RX" + }, + "client_bandwidth_tx": { + "name": "TX" + }, + "device_clients": { + "name": "Clients" + }, + "device_cpu_utilization": { + "name": "CPU utilization" + }, + "device_memory_utilization": { + "name": "Memory utilization" + }, "device_state": { + "name": "State", "state": { "adopting": "Adopting", "adoption_failed": "Adoption failed", @@ -56,12 +93,70 @@ "upgrading": "Upgrading" } }, + "device_sub_temperature": { + "name": "{name} temperature" + }, + "device_uplink_mac": { + "name": "Uplink MAC" + }, + "outlet_power": { + "name": "{outlet_name} outlet power" + }, + "port_bandwidth_rx": { + "name": "{port_name} RX" + }, + "port_bandwidth_tx": { + "name": "{port_name} TX" + }, "port_link_speed": { - "name": "Link speed" + "name": "{port_name} link speed" + }, + "port_poe_power": { + "name": "{port_name} PoE power" + }, + "smartpower_ac_power_budget": { + "name": "AC power budget" + }, + "smartpower_ac_power_consumption": { + "name": "AC power consumption" + }, + "wan_latency": { + "name": "{target} {wan} latency" }, "wired_client_link_speed": { "name": "Link speed" + }, + "wlan_clients": { + "name": "Clients" } + }, + "switch": { + "block_client": { + "name": "Blocked" + }, + "poe_port_control": { + "name": "{port_name} PoE" + }, + "wlan_control": { + "name": "Enabled" + } + } + }, + "exceptions": { + "action_request_failed": { + "message": "Failed to send action request to UniFi Network" + }, + "reconnect_client_device_not_found": { + "message": "Unable to reconnect client: device not found" + }, + "reconnect_client_no_mac": { + "message": "Unable to reconnect client: device does not have a network MAC address" + }, + "reconnect_client_request_failed": { + "message": "Failed to send reconnect request to UniFi Network" + }, + "remove_clients_request_failed": { + "message": "Failed to remove clients from UniFi Network" } }, "options": { @@ -69,49 +164,42 @@ "integration_not_setup": "UniFi integration is not set up" }, "step": { - "client_control": { + "init": { "data": { "block_client": "Network access controlled clients", - "dpi_restrictions": "Allow control of DPI restriction groups", - "poe_clients": "Allow POE control of clients" - }, - "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", - "title": "UniFi Network options 2/3" - }, - "configure_entity_sources": { - "data": { - "client_source": "Create entities from network clients" - }, - "description": "Select sources to create entities from", - "title": "UniFi Network Entity Sources" - }, - "device_tracker": { - "data": { - "detection_time": "Time in seconds from last seen until considered away", - "ignore_wired_bug": "Disable UniFi Network wired bug logic", - "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", - "track_devices": "Track network devices (Ubiquiti devices)", - "track_wired_clients": "Include wired network clients" + "track_devices": "Track network devices (Ubiquiti devices)" }, - "description": "Configure device tracking", - "title": "UniFi Network options 1/3" - }, - "simple_options": { - "data": { - "block_client": "[%key:component::unifi::options::step::client_control::data::block_client%]", - "track_clients": "[%key:component::unifi::options::step::device_tracker::data::track_clients%]", - "track_devices": "[%key:component::unifi::options::step::device_tracker::data::track_devices%]" + "data_description": { + "block_client": "Select clients whose network access you want to control via switches.", + "track_clients": "Create device tracker entities for network clients.", + "track_devices": "Create device tracker entities for Ubiquiti network devices." }, - "description": "Configure UniFi Network integration" - }, - "statistics_sensors": { - "data": { - "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients", - "allow_uptime_sensors": "Uptime sensors for network clients" - }, - "description": "Configure statistics sensors", - "title": "UniFi Network options 3/3" + "sections": { + "more_options": { + "data": { + "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients", + "allow_uptime_sensors": "Uptime sensors for network clients", + "client_source": "Create entities from network clients", + "detection_time": "Time in seconds from last seen until considered away", + "dpi_restrictions": "Allow control of DPI restriction groups", + "ignore_wired_bug": "Disable UniFi Network wired bug logic", + "ssid_filter": "Select SSIDs to track wireless clients on", + "track_wired_clients": "Include wired network clients" + }, + "data_description": { + "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients.", + "allow_uptime_sensors": "Create uptime sensors for network clients.", + "client_source": "Select which network clients to create entities from.", + "detection_time": "Number of seconds since last seen before a client is considered away.", + "dpi_restrictions": "Enable switches to control DPI restriction groups.", + "ignore_wired_bug": "Disable workaround for a UniFi Network bug that sometimes reports wired clients as wireless.", + "ssid_filter": "Only track wireless clients connected to selected SSIDs.", + "track_wired_clients": "Include wired clients in device tracking." + }, + "name": "More options" + } + } } } }, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index b39020204a5..24de12bfa3f 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -7,8 +7,6 @@ Support for controlling WLAN availability. Support for controlling zone based traffic rules. """ -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass @@ -50,7 +48,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -67,6 +65,8 @@ from .entity import ( ) from .hub import UnifiHub +PARALLEL_UPDATES = 1 + CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKED) CLIENT_UNBLOCKED = (EventKey.WIRED_CLIENT_UNBLOCKED, EventKey.WIRELESS_CLIENT_UNBLOCKED) @@ -147,7 +147,7 @@ async def async_firewall_policy_control_fn( @callback def async_firewall_policy_supported_fn(hub: UnifiHub, obj_id: str) -> bool: - """Check if firewall policy is able to be controlled. Predefined policies are unable to be turned off.""" + """Check if firewall policy can be controlled.""" policy = hub.api.firewall_policies[obj_id] return not policy.predefined @@ -258,8 +258,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[DPIRestrictionGroups, DPIRestrictionGroup]( key="DPI restriction", - translation_key="dpi_restriction", - has_entity_name=False, entity_category=EntityCategory.CONFIG, allowed_fn=lambda hub, obj_id: hub.config.option_dpi_restrictions, api_handler_fn=lambda api: api.dpi_groups, @@ -274,7 +272,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[FirewallPolicies, FirewallPolicy]( key="Firewall policy control", - translation_key="firewall_policy_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, api_handler_fn=lambda api: api.firewall_policies, @@ -301,7 +298,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", - translation_key="port_forward_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, api_handler_fn=lambda api: api.port_forwarding, @@ -314,7 +310,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[TrafficRules, TrafficRule]( key="Traffic rule control", - translation_key="traffic_rule_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, api_handler_fn=lambda api: api.traffic_rules, @@ -327,7 +322,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ), UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute]( key="Traffic route control", - translation_key="traffic_route_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, api_handler_fn=lambda api: api.traffic_routes, @@ -349,14 +343,13 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( control_fn=async_poe_port_control_fn, device_info_fn=async_device_device_info_fn, is_on_fn=lambda hub, port: port.poe_mode != "off", - name_fn=lambda port: f"{port.name} PoE", object_fn=lambda api, obj_id: api.ports[obj_id], supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe), + translation_placeholders_fn=lambda port: {"port_name": port.name}, unique_id_fn=lambda hub, obj_id: f"poe-{obj_id}", ), UnifiSwitchEntityDescription[Ports, Port]( key="Port control", - translation_key="port_control", device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -385,41 +378,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( ) -@callback -def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) -> None: - """Normalize switch unique ID to have a prefix rather than midfix. - - Introduced with release 2023.12. - """ - hub = config_entry.runtime_data - ent_reg = er.async_get(hass) - - @callback - def update_unique_id(obj_id: str, type_name: str) -> None: - """Rework unique ID.""" - new_unique_id = f"{type_name}-{obj_id}" - if ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, new_unique_id): - return - - prefix, _, suffix = obj_id.partition("_") - unique_id = f"{prefix}-{type_name}-{suffix}" - if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id): - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) - - for obj_id in hub.api.outlets: - update_unique_id(obj_id, "outlet") - - for obj_id in hub.api.ports: - update_unique_id(obj_id, "poe") - - async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" - async_update_unique_id(hass, config_entry) config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiSwitchEntity, @@ -442,7 +406,13 @@ class UnifiSwitchEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self.entity_description.control_fn(self.hub, self._obj_id, True) + try: + await self.entity_description.control_fn(self.hub, self._obj_id, True) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err if coordinator := self.hub.entity_loader.get_data_update_coordinator( self.entity_description.api_handler_fn(self.api) ): @@ -450,7 +420,13 @@ class UnifiSwitchEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self.entity_description.control_fn(self.hub, self._obj_id, False) + try: + await self.entity_description.control_fn(self.hub, self._obj_id, False) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err if coordinator := self.hub.entity_loader.get_data_update_coordinator( self.entity_description.api_handler_fn(self.api) ): diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index a53700ef969..42c5ee04896 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -1,7 +1,5 @@ """Update entities for Ubiquiti network devices.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging @@ -19,9 +17,11 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry +from .const import DOMAIN from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -30,6 +30,7 @@ from .entity import ( ) LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None: @@ -95,7 +96,13 @@ class UnifiDeviceUpdateEntity[_HandlerT: Devices, _DataT: Device]( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self.entity_description.control_fn(self.api, self._obj_id) + try: + await self.entity_description.control_fn(self.api, self._obj_id) + except aiounifi.AiounifiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_request_failed", + ) from err @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py index ab0c81881b0..5c7956eb85b 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -1,16 +1,20 @@ """The UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import create_no_verify_ssl_context +from .const import DOMAIN from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -23,6 +27,12 @@ PLATFORMS: list[Platform] = [ ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the UniFi Access integration.""" + await async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool: """Set up UniFi Access from a config entry.""" session = async_get_clientsession(hass, verify_ssl=entry.data[CONF_VERIFY_SSL]) @@ -42,11 +52,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) await client.authenticate() except ApiAuthError as err: raise ConfigEntryAuthFailed( - f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}" + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"host": entry.data[CONF_HOST]}, ) from err except ApiConnectionError as err: raise ConfigEntryNotReady( - f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": entry.data[CONF_HOST]}, ) from err coordinator = UnifiAccessCoordinator(hass, entry, client) diff --git a/homeassistant/components/unifi_access/binary_sensor.py b/homeassistant/components/unifi_access/binary_sensor.py index f8bf2b59065..05ea518a19a 100644 --- a/homeassistant/components/unifi_access/binary_sensor.py +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for the UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import Door, DoorPositionStatus from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/unifi_access/button.py b/homeassistant/components/unifi_access/button.py index 4527dfb048a..67e4d0004c3 100644 --- a/homeassistant/components/unifi_access/button.py +++ b/homeassistant/components/unifi_access/button.py @@ -1,7 +1,5 @@ """Button platform for the UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import Door, UnifiAccessError from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index 81f99f4473e..9fe32b7815b 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -1,7 +1,5 @@ """Config flow for UniFi Access integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -9,9 +7,10 @@ from typing import Any from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util.ssl import create_no_verify_ssl_context from .const import DOMAIN @@ -25,6 +24,11 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + def __init__(self) -> None: + """Init the config flow.""" + super().__init__() + self._discovered_device: dict[str, Any] = {} + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: """Validate user input and return errors dict.""" errors: dict[str, str] = {} @@ -44,7 +48,11 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): try: await client.authenticate() except ApiAuthError: - errors["base"] = "invalid_auth" + try: + is_protect = await client.is_protect_api_key() + except Exception: # noqa: BLE001 + is_protect = False + errors["base"] = "protect_api_key" if is_protect else "invalid_auth" except ApiConnectionError: errors["base"] = "cannot_connect" except Exception: @@ -113,6 +121,66 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_integration_discovery( + self, discovery_info: DiscoveryInfoType + ) -> ConfigFlowResult: + """Handle discovery via unifi_discovery.""" + self._discovered_device = discovery_info + source_ip = discovery_info["source_ip"] + mac = discovery_info["hw_addr"].replace(":", "").upper() + await self.async_set_unique_id(mac) + for entry in self._async_current_entries(): + if entry.source == SOURCE_IGNORE: + continue + if entry.data.get(CONF_HOST) == source_ip: + if not entry.unique_id: + self.hass.config_entries.async_update_entry(entry, unique_id=mac) + return self.async_abort(reason="already_configured") + self._abort_if_unique_id_configured(updates={CONF_HOST: source_ip}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery and collect API token.""" + errors: dict[str, str] = {} + discovery_info = self._discovered_device + source_ip = discovery_info["source_ip"] + + if user_input is not None: + merged_input = { + CONF_HOST: source_ip, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, False), + } + errors = await self._validate_input(merged_input) + if not errors: + return self.async_create_entry( + title="UniFi Access", + data=merged_input, + ) + + name = discovery_info.get("hostname") or discovery_info.get("platform") + if not name: + short_mac = discovery_info["hw_addr"].replace(":", "").upper()[-6:] + name = f"Access {short_mac}" + placeholders = { + "name": name, + "ip_address": source_ip, + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } + ), + description_placeholders=placeholders, + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/unifi_access/const.py b/homeassistant/components/unifi_access/const.py index 36ac8fee8f9..129bd24ca21 100644 --- a/homeassistant/components/unifi_access/const.py +++ b/homeassistant/components/unifi_access/const.py @@ -1,3 +1,9 @@ """Constants for the UniFi Access integration.""" DOMAIN = "unifi_access" +DEFAULT_LOCK_RULE_INTERVAL = 10 +MAX_LOCK_RULE_INTERVAL = 480 +MIN_LOCK_RULE_INTERVAL = 1 +SERVICE_SET_LOCK_RULE = "set_lock_rule" +ATTR_INTERVAL = "interval" +ATTR_RULE = "rule" diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index 0e33638c49e..5b64bc9afbb 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -1,12 +1,12 @@ """Data update coordinator for the UniFi Access integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass, replace import logging +import math from typing import Any, cast +import unicodedata from unifi_access_api import ( ApiAuthError, @@ -23,13 +23,16 @@ from unifi_access_api import ( WsMessageHandler, ) from unifi_access_api.models.websocket import ( + DeviceUpdate, HwDoorbell, InsightsAdd, LocationUpdateState, LocationUpdateV2, LogAdd, + RemoteView, SettingUpdate, ThumbnailInfo, + V2DeviceUpdate, V2LocationState, V2LocationUpdate, WebsocketMessage, @@ -37,14 +40,18 @@ from unifi_access_api.models.websocket import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import ( + DEFAULT_LOCK_RULE_INTERVAL, + DOMAIN, + MAX_LOCK_RULE_INTERVAL, + MIN_LOCK_RULE_INTERVAL, +) _LOGGER = logging.getLogger(__name__) -DEFAULT_LOCK_RULE_INTERVAL = 10 type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator] @@ -108,12 +115,38 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): self._event_listeners.append(event_callback) return _unsubscribe - async def async_set_lock_rule(self, door_id: str, rule_type: str) -> None: + def _normalize_interval(self, value: float | None) -> int: + """Clamp and normalize an interval value to valid integer minutes.""" + if value is None: + value = float(DEFAULT_LOCK_RULE_INTERVAL) + + normalized = min( + max(float(value), float(MIN_LOCK_RULE_INTERVAL)), + float(MAX_LOCK_RULE_INTERVAL), + ) + normalized = math.floor(normalized + 0.5) + normalized = min( + max(normalized, float(MIN_LOCK_RULE_INTERVAL)), + float(MAX_LOCK_RULE_INTERVAL), + ) + return int(normalized) + + async def async_set_lock_rule( + self, door_id: str, rule_type: str, interval: float | None = None + ) -> None: """Set a temporary lock rule for a door.""" if not rule_type: return - lock_rule_type = DoorLockRuleType(rule_type) - rule = DoorLockRule(type=lock_rule_type, interval=DEFAULT_LOCK_RULE_INTERVAL) + try: + lock_rule_type = DoorLockRuleType(rule_type) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_lock_rule_type", + ) from err + rule = DoorLockRule( + type=lock_rule_type, interval=self._normalize_interval(interval) + ) await self.client.set_door_lock_rule(door_id, rule) if self.data is None or door_id not in self.data.doors: return @@ -141,7 +174,10 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): handlers: dict[str, WsMessageHandler] = { "access.data.device.location_update_v2": self._handle_location_update, "access.data.v2.location.update": self._handle_v2_location_update, + "access.data.v2.device.update": self._handle_v2_device_update, + "access.data.device.update": self._handle_device_update, "access.hw.door_bell": self._handle_doorbell, + "access.remote_view": self._handle_remote_view, "access.logs.insights.add": self._handle_insights_add, "access.logs.add": self._handle_logs_add, "access.data.setting.update": self._handle_setting_update, @@ -161,13 +197,25 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): self.client.get_emergency_status(), ) except ApiAuthError as err: - raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="update_failed_auth", + ) from err except ApiConnectionError as err: - raise UpdateFailed(f"Error connecting to API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_connection", + ) from err except ApiError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_api", + ) from err except TimeoutError as err: - raise UpdateFailed("Timeout communicating with UniFi Access API") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_timeout", + ) from err previous_lock_rules = self.data.door_lock_rules.copy() if self.data else {} door_lock_rules: dict[str, DoorLockRuleStatus] = {} @@ -314,12 +362,13 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): updated_lock_rule = current_lock_rule lock_rule_updated = False if ws_state is not None: - if ws_state.dps is not None: + if "dps" in ws_state.model_fields_set and ws_state.dps is not None: updates["door_position_status"] = ws_state.dps - if ws_state.lock == "locked": - updates["door_lock_relay_status"] = DoorLockRelayStatus.LOCK - elif ws_state.lock == "unlocked": - updates["door_lock_relay_status"] = DoorLockRelayStatus.UNLOCK + if "lock" in ws_state.model_fields_set: + if ws_state.lock == "locked": + updates["door_lock_relay_status"] = DoorLockRelayStatus.LOCK + elif ws_state.lock == "unlocked": + updates["door_lock_relay_status"] = DoorLockRelayStatus.UNLOCK if "remain_lock" in ws_state.model_fields_set: lock_rule_updated = True @@ -397,6 +446,51 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): {}, ) + async def _handle_remote_view(self, msg: WebsocketMessage) -> None: + """Handle remote view (video intercom doorbell press) events.""" + remote_view = cast(RemoteView, msg) + device_id = remote_view.data.device_id + if device_id and device_id in self._device_to_door: + self._dispatch_door_event( + self._device_to_door[device_id], "doorbell", "ring", {} + ) + return + door_name = remote_view.data.door_name + if self.data and door_name: + normalized = unicodedata.normalize("NFC", door_name.strip()) + for door in self.data.doors.values(): + if unicodedata.normalize("NFC", door.name.strip()) == normalized: + self._dispatch_door_event(door.id, "doorbell", "ring", {}) + return + _LOGGER.debug( + "Received access.remote_view for unknown device %s (door '%s')", + device_id, + door_name, + ) + + async def _handle_v2_device_update(self, msg: WebsocketMessage) -> None: + """Handle V2 device update messages.""" + update = cast(V2DeviceUpdate, msg) + device_id = update.data.id + if not device_id: + return + first_valid_door_id: str | None = None + for loc_state in update.data.location_states: + door_id = loc_state.location_id + if not door_id: + continue + if first_valid_door_id is None: + first_valid_door_id = door_id + self._process_door_update(door_id, loc_state) + if first_valid_door_id is not None: + self._device_to_door[device_id] = first_valid_door_id + + async def _handle_device_update(self, msg: WebsocketMessage) -> None: + """Handle device update messages.""" + update = cast(DeviceUpdate, msg) + if update.data.unique_id and update.data.door and update.data.door.unique_id: + self._device_to_door[update.data.unique_id] = update.data.door.unique_id + async def _handle_insights_add(self, msg: WebsocketMessage) -> None: """Handle access insights events (entry/exit).""" insights = cast(InsightsAdd, msg) @@ -413,6 +507,8 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): attrs["authentication"] = insights.data.metadata.authentication.display_name if insights.data.result: attrs["result"] = insights.data.result + if insights.data.metadata.direction: + attrs["direction"] = insights.data.metadata.direction for door in door_entries: if door.id: self._dispatch_door_event(door.id, "access", event_type, attrs) @@ -422,9 +518,15 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): log = cast(LogAdd, msg) source = log.data.source device_target = source.device_config - if device_target is None or device_target.id not in self._device_to_door: + if device_target is None: + return + if device_target.id in self._device_to_door: + door_id = self._device_to_door[device_target.id] + elif msg.door_id: + # UAH-DOOR devices: door_id is enriched by the library via MAC→door map + door_id = msg.door_id + else: return - door_id = self._device_to_door[device_target.id] event_type = ( "access_granted" if source.event.result == "ACCESS" else "access_denied" ) @@ -435,6 +537,8 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): attrs["authentication"] = source.authentication.credential_provider if source.event.result: attrs["result"] = source.event.result + if source.direction: + attrs["direction"] = source.direction self._dispatch_door_event(door_id, "access", event_type, attrs) def get_lock_rule_status(self, door_id: str) -> DoorLockRuleStatus | None: diff --git a/homeassistant/components/unifi_access/diagnostics.py b/homeassistant/components/unifi_access/diagnostics.py index 903838dd6c6..2a69f5f378c 100644 --- a/homeassistant/components/unifi_access/diagnostics.py +++ b/homeassistant/components/unifi_access/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for UniFi Access.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/unifi_access/entity.py b/homeassistant/components/unifi_access/entity.py index 29b993caedb..0bba1656db9 100644 --- a/homeassistant/components/unifi_access/entity.py +++ b/homeassistant/components/unifi_access/entity.py @@ -1,7 +1,5 @@ """Base entity for the UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import Door from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/unifi_access/event.py b/homeassistant/components/unifi_access/event.py index 99a8b9b55ef..61c2677ab38 100644 --- a/homeassistant/components/unifi_access/event.py +++ b/homeassistant/components/unifi_access/event.py @@ -1,12 +1,11 @@ """Event platform for the UniFi Access integration.""" -from __future__ import annotations - from dataclasses import dataclass from unifi_access_api import Door from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -31,7 +30,7 @@ DOORBELL_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription( key="doorbell", translation_key="doorbell", device_class=EventDeviceClass.DOORBELL, - event_types=["ring"], + event_types=[DoorbellEventType.RING], category="doorbell", ) diff --git a/homeassistant/components/unifi_access/icons.json b/homeassistant/components/unifi_access/icons.json index 0480ee3603d..f34c24f9199 100644 --- a/homeassistant/components/unifi_access/icons.json +++ b/homeassistant/components/unifi_access/icons.json @@ -23,5 +23,10 @@ "default": "mdi:lock-alert" } } + }, + "services": { + "set_lock_rule": { + "service": "mdi:lock-clock" + } } } diff --git a/homeassistant/components/unifi_access/image.py b/homeassistant/components/unifi_access/image.py index b2ca2b5d242..44646d75ae9 100644 --- a/homeassistant/components/unifi_access/image.py +++ b/homeassistant/components/unifi_access/image.py @@ -1,16 +1,16 @@ """Image platform for the UniFi Access integration.""" -from __future__ import annotations - from datetime import UTC, datetime -from unifi_access_api import Door +from unifi_access_api import Door, UnifiAccessError from homeassistant.components.image import ImageEntity from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator from .entity import UnifiAccessEntity @@ -28,7 +28,7 @@ async def async_setup_entry( @callback def _async_add_new_doors() -> None: - new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + new_door_ids = sorted(set(coordinator.data.door_thumbnails) - added_doors) if not new_door_ids: return async_add_entities( @@ -72,7 +72,14 @@ class UnifiAccessDoorImageEntity(UnifiAccessEntity, ImageEntity): async def async_image(self) -> bytes | None: """Return the door thumbnail image bytes.""" if thumbnail := self.coordinator.data.door_thumbnails.get(self._door_id): - return await self.coordinator.client.get_thumbnail(thumbnail.url) + try: + return await self.coordinator.client.get_thumbnail(thumbnail.url) + # pylint: disable-next=home-assistant-action-swallowed-exception + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="fetch_thumbnail_failed", + ) from err return None def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json index f7ec9953fd6..07095919d5c 100644 --- a/homeassistant/components/unifi_access/manifest.json +++ b/homeassistant/components/unifi_access/manifest.json @@ -3,10 +3,11 @@ "name": "UniFi Access", "codeowners": ["@imhotep", "@RaHehl"], "config_flow": true, + "dependencies": ["unifi_discovery"], "documentation": "https://www.home-assistant.io/integrations/unifi_access", "integration_type": "hub", "iot_class": "local_push", "loggers": ["unifi_access_api"], - "quality_scale": "silver", - "requirements": ["py-unifi-access==1.1.3"] + "quality_scale": "platinum", + "requirements": ["py-unifi-access==1.3.0"] } diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index 72d0bb8590d..47ee52fa5b9 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + action-setup: done appropriate-polling: status: exempt comment: Integration uses WebSocket push updates, no polling. @@ -11,9 +9,7 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register custom actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -42,15 +38,17 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: done + discovery: + status: exempt + comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done diff --git a/homeassistant/components/unifi_access/select.py b/homeassistant/components/unifi_access/select.py index 4193a5a1d4f..a8d306993ac 100644 --- a/homeassistant/components/unifi_access/select.py +++ b/homeassistant/components/unifi_access/select.py @@ -1,7 +1,5 @@ """Select platform for the UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import Door, DoorLockRuleType, UnifiAccessError from homeassistant.components.select import SelectEntity diff --git a/homeassistant/components/unifi_access/sensor.py b/homeassistant/components/unifi_access/sensor.py index 44b356ce8b2..6c68040fa3b 100644 --- a/homeassistant/components/unifi_access/sensor.py +++ b/homeassistant/components/unifi_access/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the UniFi Access integration.""" -from __future__ import annotations - from datetime import UTC, datetime from unifi_access_api import Door, DoorLockRuleType diff --git a/homeassistant/components/unifi_access/services.py b/homeassistant/components/unifi_access/services.py new file mode 100644 index 00000000000..d5cf5c78fbb --- /dev/null +++ b/homeassistant/components/unifi_access/services.py @@ -0,0 +1,112 @@ +"""Services for UniFi Access.""" + +from datetime import timedelta + +from unifi_access_api import UnifiAccessError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) + +from .const import ( + ATTR_INTERVAL, + ATTR_RULE, + DOMAIN, + MAX_LOCK_RULE_INTERVAL, + MIN_LOCK_RULE_INTERVAL, + SERVICE_SET_LOCK_RULE, +) +from .coordinator import UnifiAccessConfigEntry + +LOCK_RULE_OPTIONS = [ + "keep_lock", + "keep_unlock", + "custom", + "reset", + "lock_early", +] + +SERVICE_SET_LOCK_RULE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_RULE): vol.In(LOCK_RULE_OPTIONS), + vol.Optional(ATTR_INTERVAL): vol.All( + cv.time_period, + cv.positive_timedelta, + vol.Range( + min=timedelta(minutes=MIN_LOCK_RULE_INTERVAL), + max=timedelta(minutes=MAX_LOCK_RULE_INTERVAL), + ), + ), + } +) + + +@callback +def _async_get_target( + hass: HomeAssistant, call: ServiceCall +) -> tuple[UnifiAccessConfigEntry, str]: + """Resolve a service call to a UniFi Access config entry and door ID.""" + device_registry = dr.async_get(hass) + device_id = call.data[ATTR_DEVICE_ID] + if (device := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + for entry_id in device.config_entries: + if ( + entry := hass.config_entries.async_get_entry(entry_id) + ) is None or entry.domain != DOMAIN: + continue + + config_entry: UnifiAccessConfigEntry = service.async_get_config_entry( + hass, DOMAIN, entry_id + ) + coordinator = config_entry.runtime_data + for identifier_domain, identifier_value in device.identifiers: + if ( + identifier_domain == DOMAIN + and identifier_value in coordinator.data.doors + ): + return config_entry, identifier_value + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the UniFi Access integration.""" + + async def _handle_set_lock_rule(call: ServiceCall) -> None: + """Set a temporary lock rule for a UniFi Access door.""" + config_entry, door_id = _async_get_target(hass, call) + interval: timedelta | None = call.data.get(ATTR_INTERVAL) + interval_minutes = ( + interval.total_seconds() / 60 if interval is not None else None + ) + try: + await config_entry.runtime_data.async_set_lock_rule( + door_id, call.data[ATTR_RULE], interval_minutes + ) + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="lock_rule_failed", + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_SET_LOCK_RULE, + _handle_set_lock_rule, + schema=SERVICE_SET_LOCK_RULE_SCHEMA, + ) diff --git a/homeassistant/components/unifi_access/services.yaml b/homeassistant/components/unifi_access/services.yaml new file mode 100644 index 00000000000..90458f2cf71 --- /dev/null +++ b/homeassistant/components/unifi_access/services.yaml @@ -0,0 +1,23 @@ +set_lock_rule: + fields: + device_id: + required: true + selector: + device: + integration: unifi_access + rule: + required: true + selector: + select: + options: + - keep_lock + - keep_unlock + - custom + - reset + - lock_early + translation_key: rule + interval: + selector: + duration: + enable_day: false + enable_second: false diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index 44cf6dd921b..5e416b54900 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -8,9 +8,21 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "protect_api_key": "This API key is associated with UniFi Protect, not UniFi Access. Please generate a new API key from the UniFi Access application settings.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "discovery_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]", + "verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]" + }, + "description": "A UniFi Access controller was discovered at {ip_address} ({name})." + }, "reauth_confirm": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]" @@ -121,14 +133,72 @@ } }, "exceptions": { + "auth_failed": { + "message": "Authentication failed for UniFi Access at {host}." + }, + "cannot_connect": { + "message": "Unable to connect to UniFi Access at {host}." + }, "emergency_failed": { "message": "Failed to set emergency status." }, + "fetch_thumbnail_failed": { + "message": "Failed to fetch door thumbnail." + }, + "invalid_lock_rule_type": { + "message": "The provided lock rule type is invalid." + }, + "invalid_target": { + "message": "The selected device is not a UniFi Access door." + }, "lock_rule_failed": { "message": "Failed to update the door lock rule." }, "unlock_failed": { "message": "Failed to unlock the door." + }, + "update_failed_api": { + "message": "Error communicating with the UniFi Access API." + }, + "update_failed_auth": { + "message": "Authentication failed while updating data." + }, + "update_failed_connection": { + "message": "Error connecting to the UniFi Access API." + }, + "update_failed_timeout": { + "message": "Timeout communicating with the UniFi Access API." + } + }, + "selector": { + "rule": { + "options": { + "custom": "Custom", + "keep_lock": "Keep locked", + "keep_unlock": "Keep unlocked", + "lock_early": "Lock early", + "reset": "Reset" + } + } + }, + "services": { + "set_lock_rule": { + "description": "Apply a temporary lock rule to a UniFi Access door.", + "fields": { + "device_id": { + "description": "The UniFi Access door to update.", + "name": "Door" + }, + "interval": { + "description": "How long the rule stays active. Defaults to 10 minutes.", + "name": "Interval" + }, + "rule": { + "description": "The lock rule to apply.", + "name": "Rule" + } + }, + "name": "Set lock rule" } } } diff --git a/homeassistant/components/unifi_access/switch.py b/homeassistant/components/unifi_access/switch.py index 33cb003c079..fe9259e96e1 100644 --- a/homeassistant/components/unifi_access/switch.py +++ b/homeassistant/components/unifi_access/switch.py @@ -1,7 +1,5 @@ """Switch platform for the UniFi Access integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, replace from typing import Any diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 1d7511aaae8..42e13a287f4 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -1,7 +1,5 @@ """Support for Unifi AP direct access.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/unifi_discovery/__init__.py b/homeassistant/components/unifi_discovery/__init__.py new file mode 100644 index 00000000000..6dd3a107f67 --- /dev/null +++ b/homeassistant/components/unifi_discovery/__init__.py @@ -0,0 +1,16 @@ +"""The UniFi Discovery integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .discovery import async_start_discovery + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up UniFi Discovery.""" + async_start_discovery(hass) + return True diff --git a/homeassistant/components/unifi_discovery/config_flow.py b/homeassistant/components/unifi_discovery/config_flow.py new file mode 100644 index 00000000000..29886871418 --- /dev/null +++ b/homeassistant/components/unifi_discovery/config_flow.py @@ -0,0 +1,44 @@ +"""Config flow for UniFi Discovery.""" + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from .const import DOMAIN +from .discovery import async_start_discovery + +_LOGGER = logging.getLogger(__name__) + + +class UnifiDiscoveryFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for UniFi Discovery.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a user-initiated flow.""" + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via DHCP.""" + _LOGGER.debug("Starting discovery via DHCP: %s", discovery_info) + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via SSDP.""" + _LOGGER.debug("Starting discovery via SSDP: %s", discovery_info) + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") diff --git a/homeassistant/components/unifi_discovery/const.py b/homeassistant/components/unifi_discovery/const.py new file mode 100644 index 00000000000..ebd5f2866d7 --- /dev/null +++ b/homeassistant/components/unifi_discovery/const.py @@ -0,0 +1,14 @@ +"""Constants for the UniFi Discovery integration.""" + +from unifi_discovery import UnifiService + +DOMAIN = "unifi_discovery" + +# Static mapping of UniFi service types to their Home Assistant integration domains. +# This must be static (not a runtime registry) because consumers may not be loaded +# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers. +CONSUMER_MAPPING: dict[UnifiService, str] = { + UnifiService.Access: "unifi_access", + UnifiService.Network: "unifi", + UnifiService.Protect: "unifiprotect", +} diff --git a/homeassistant/components/unifi_discovery/discovery.py b/homeassistant/components/unifi_discovery/discovery.py new file mode 100644 index 00000000000..224cdeaf15e --- /dev/null +++ b/homeassistant/components/unifi_discovery/discovery.py @@ -0,0 +1,95 @@ +"""UniFi network device discovery.""" + +from collections.abc import Mapping +from dataclasses import fields +from datetime import timedelta +import logging +from typing import Any + +from unifi_discovery import AIOUnifiScanner, UnifiDevice + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.hass_dict import HassKey + +from .const import CONSUMER_MAPPING, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DISCOVERY_INTERVAL = timedelta(minutes=60) + +DATA_DISCOVERY_STARTED: HassKey[bool] = HassKey(DOMAIN) + + +def _device_to_dict(device: UnifiDevice) -> dict[str, Any]: + """Convert a UnifiDevice to a plain dict. + + Avoid dataclasses.asdict() because it calls copy.deepcopy() on non-builtin + types. On Python 3.14+ deepcopy cannot pickle mappingproxy objects, and + Enum members (used as dict keys in ``services``) internally reference + ``__members__`` which is a mappingproxy. This causes asdict() to crash + with ``TypeError: cannot pickle 'mappingproxy' object``. + """ + data: dict[str, Any] = {} + for f in fields(device): + value = getattr(device, f.name) + if isinstance(value, Mapping): + value = dict(value) + data[f.name] = value + return data + + +@callback +def async_start_discovery(hass: HomeAssistant) -> None: + """Start discovery of UniFi devices.""" + if hass.data.get(DATA_DISCOVERY_STARTED): + return + hass.data[DATA_DISCOVERY_STARTED] = True + + async def _async_discovery() -> None: + async_trigger_discovery(hass, await async_discover_devices()) + + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task( + _async_discovery(), "unifi_discovery-discovery" + ) + + # Do not block startup since discovery takes 31s or more + _async_start_background_discovery() + async_track_time_interval( + hass, + _async_start_background_discovery, + DISCOVERY_INTERVAL, + cancel_on_shutdown=True, + ) + + +async def async_discover_devices() -> list[UnifiDevice]: + """Discover UniFi devices on the network.""" + scanner = AIOUnifiScanner() + devices = await scanner.async_scan() + _LOGGER.debug("Found devices: %s", devices) + return devices + + +@callback +def async_trigger_discovery( + hass: HomeAssistant, + discovered_devices: list[UnifiDevice], +) -> None: + """Trigger config flows for discovered devices.""" + for device in discovered_devices: + if not device.hw_addr: + continue + for service, domain in CONSUMER_MAPPING.items(): + if device.services.get(service): + discovery_flow.async_create_flow( + hass, + domain, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=_device_to_dict(device), + ) diff --git a/homeassistant/components/unifi_discovery/manifest.json b/homeassistant/components/unifi_discovery/manifest.json new file mode 100644 index 00000000000..84cbcfec26b --- /dev/null +++ b/homeassistant/components/unifi_discovery/manifest.json @@ -0,0 +1,63 @@ +{ + "domain": "unifi_discovery", + "name": "UniFi Discovery", + "codeowners": ["@RaHehl"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "B4FBE4*" + }, + { + "macaddress": "802AA8*" + }, + { + "macaddress": "F09FC2*" + }, + { + "macaddress": "68D79A*" + }, + { + "macaddress": "18E829*" + }, + { + "macaddress": "245A4C*" + }, + { + "macaddress": "784558*" + }, + { + "macaddress": "E063DA*" + }, + { + "macaddress": "265A4C*" + }, + { + "macaddress": "74ACB9*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/unifi_discovery", + "integration_type": "system", + "iot_class": "local_polling", + "loggers": ["unifi_discovery"], + "quality_scale": "internal", + "requirements": ["unifi-discovery==1.4.0"], + "single_config_entry": true, + "ssdp": [ + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine Pro" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine SE" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine Pro Max" + } + ] +} diff --git a/homeassistant/components/unifi_discovery/strings.json b/homeassistant/components/unifi_discovery/strings.json new file mode 100644 index 00000000000..0f2759c86d7 --- /dev/null +++ b/homeassistant/components/unifi_discovery/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "discovery_started": "Discovery started" + }, + "step": { + "user": { + "description": "UniFi Discovery is set up automatically." + } + } + } +} diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py index dbc73177f21..2d190151ca2 100644 --- a/homeassistant/components/unifiled/light.py +++ b/homeassistant/components/unifiled/light.py @@ -1,7 +1,5 @@ """Support for Unifi Led lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 9e359de481a..5d1886dc68b 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -1,7 +1,5 @@ """UniFi Protect Platform.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -40,7 +38,6 @@ from .const import ( PLATFORMS, ) from .data import ProtectData, UFPConfigEntry -from .discovery import DATA_UNIFIPROTECT, UniFiProtectRuntimeData, async_start_discovery from .migrate import async_migrate_data from .services import async_setup_services from .utils import ( @@ -64,11 +61,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" - # Initialize domain data structure (setdefault in case discovery already started) - hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - # Only start discovery once regardless of how many entries they have async_setup_services(hass) - async_start_discovery(hass) return True @@ -78,20 +71,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") + # Reuse ProtectData from previous retry or create new + if hasattr(entry, "runtime_data"): + data_service = entry.runtime_data + data_service.api = protect + else: + data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) + entry.runtime_data = data_service + try: await protect.update() except NotAuthorized as err: - domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - retries = domain_data.auth_retries.get(entry.entry_id, 0) - if retries < AUTH_RETRIES: - retries += 1 - domain_data.auth_retries[entry.entry_id] = retries - raise ConfigEntryNotReady from err - raise ConfigEntryAuthFailed(err) from err + data_service.auth_retries += 1 + if data_service.auth_retries > AUTH_RETRIES: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="entry_auth_failed", + ) from err + raise ConfigEntryNotReady from err except (TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err - - data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) @@ -142,7 +141,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) - entry.runtime_data = data_service entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) @@ -161,6 +159,13 @@ async def _async_setup_entry( await async_migrate_data(hass, entry, data_service.api, bootstrap) data_service.async_setup() + # Prime the public bootstrap. The devices websocket subscription was already + # registered in async_setup() per library docs (subscribe first, then prime). + try: + await data_service.api.update_public() + except Exception: # noqa: BLE001 + _LOGGER.debug("Public API bootstrap update failed", exc_info=True) + # Load PTZ patrol data before loading platforms await data_service.async_load_ptz_patrols() diff --git a/homeassistant/components/unifiprotect/alarm_control_panel.py b/homeassistant/components/unifiprotect/alarm_control_panel.py new file mode 100644 index 00000000000..d01b100771c --- /dev/null +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -0,0 +1,116 @@ +"""Support for UniFi Protect NVR alarm control panel.""" + +from uiprotect.data import NVR, NvrArmModeStatus +from uiprotect.exceptions import GlobalAlarmManagerError + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .data import ProtectData, ProtectDeviceType, UFPConfigEntry +from .entity import ProtectNVREntity +from .utils import async_ufp_instance_command + +PARALLEL_UPDATES = 0 + +_UIPROTECT_TO_HA: dict[NvrArmModeStatus, AlarmControlPanelState] = { + NvrArmModeStatus.DISABLED: AlarmControlPanelState.DISARMED, + NvrArmModeStatus.ARMING: AlarmControlPanelState.ARMING, + NvrArmModeStatus.ARMED: AlarmControlPanelState.ARMED_AWAY, + NvrArmModeStatus.BREACH: AlarmControlPanelState.TRIGGERED, + NvrArmModeStatus.UNKNOWN: AlarmControlPanelState.DISARMED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up alarm control panel for UniFi Protect NVR.""" + data = entry.runtime_data + api = data.api + + # No public Integration API available (e.g. older NVR firmware that does + # not expose the Alarm Manager endpoint, or no API key configured). + # Skip entity creation entirely; we cannot represent the alarm state. + if not api.has_public_bootstrap: + return + + # ``arm_mode`` is ``None`` on NVR firmware that predates the Alarm Manager + # public API. Skip entity creation so the user does not see a permanently + # unavailable entity. + if api.public_bootstrap.arm_mode is None: + return + + nvr = api.bootstrap.nvr + async_add_entities([ProtectNVRAlarmControlPanel(data, device=nvr)]) + + +class ProtectNVRAlarmControlPanel(ProtectNVREntity, AlarmControlPanelEntity): + """UniFi Protect NVR Alarm Control Panel.""" + + _attr_code_arm_required = False + _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_translation_key = "nvr_alarm" + _state_attrs = ("_attr_available", "_attr_alarm_state") + + def __init__(self, data: ProtectData, device: NVR) -> None: + """Initialize the alarm control panel.""" + super().__init__(data, device, EntityDescription(key="alarm")) + self._refresh_alarm_state() + + @callback + def _refresh_alarm_state(self) -> None: + """Update _attr_alarm_state from the public bootstrap cache.""" + api = self.data.api + arm_mode = api.public_bootstrap.arm_mode if api.has_public_bootstrap else None + if arm_mode is None: + # No alarm data available — force unavailable regardless of the + # private WebSocket state managed by the base class. + self._attr_available = False + self._attr_alarm_state = None + return + # Do NOT set _attr_available = True here. Availability when alarm data + # is present is determined exclusively by the base class via + # last_update_success (private WebSocket health). Only force it to + # False as an additional condition when alarm data is missing. + # Fall back to DISARMED for unknown future status values rather than + # rendering the entity as ``unknown``. + self._attr_alarm_state = _UIPROTECT_TO_HA.get( + arm_mode.status, AlarmControlPanelState.DISARMED + ) + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + super()._async_update_device_from_protect(device) + self._refresh_alarm_state() + + @async_ufp_instance_command + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + try: + await self.data.api.disable_arm_alarm_public() + except GlobalAlarmManagerError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="global_alarm_manager", + ) from err + + @async_ufp_instance_command + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command (arms with the currently selected profile).""" + try: + await self.data.api.enable_arm_alarm_public() + except GlobalAlarmManagerError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="global_alarm_manager", + ) from err diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 689b4ec99f9..20afe0cc940 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,7 +1,5 @@ """Component providing binary sensors for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Sequence import dataclasses diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 5c2fa1b7a7e..8f388f7980c 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -1,7 +1,5 @@ """Support for Ubiquiti's UniFi Protect NVR.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from functools import partial diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index f281bbe962f..48a85f7cb19 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -1,7 +1,5 @@ """Support for Ubiquiti's UniFi Protect NVR.""" -from __future__ import annotations - from collections.abc import Generator import logging diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 605c127d8c3..65904e4e69d 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -1,7 +1,5 @@ """Config Flow to configure UniFi Protect Integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from pathlib import Path @@ -36,8 +34,6 @@ from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) -from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -56,7 +52,6 @@ from .const import ( OUTDATED_LOG_MESSAGE, ) from .data import UFPConfigEntry, async_last_update_was_successful -from .discovery import async_start_discovery from .utils import ( _async_resolve, _async_short_mac, @@ -92,7 +87,7 @@ async def _async_clear_session_if_credentials_changed( entry: UFPConfigEntry, new_data: Mapping[str, Any], ) -> None: - """Clear stored session if credentials have changed to force fresh authentication.""" + """Clear stored session if credentials changed.""" existing_data = entry.data if existing_data.get(CONF_USERNAME) != new_data.get( CONF_USERNAME @@ -205,28 +200,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): super().__init__() self._discovered_device: dict[str, str] = {} - async def async_step_dhcp( - self, discovery_info: DhcpServiceInfo - ) -> ConfigFlowResult: - """Handle discovery via dhcp.""" - _LOGGER.debug("Starting discovery via: %s", discovery_info) - return await self._async_discovery_handoff() - - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo - ) -> ConfigFlowResult: - """Handle a discovered UniFi device.""" - _LOGGER.debug("Starting discovery via: %s", discovery_info) - return await self._async_discovery_handoff() - - async def _async_discovery_handoff(self) -> ConfigFlowResult: - """Ensure discovery is active.""" - # Discovery requires an additional check so we use - # SSDP and DHCP to tell us to start it so it only - # runs on networks where unifi devices are present. - async_start_discovery(self.hass) - return self.async_abort(reason="discovery_started") - async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: @@ -323,8 +296,8 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): step_id="discovery_confirm", description_placeholders={ **placeholders, - "local_user_documentation_url": await async_local_user_documentation_url( - self.hass + "local_user_documentation_url": ( + await async_local_user_documentation_url(self.hass) ), }, data_schema=self.add_suggested_values_to_schema( @@ -458,8 +431,8 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", description_placeholders={ - "local_user_documentation_url": await async_local_user_documentation_url( - self.hass + "local_user_documentation_url": ( + await async_local_user_documentation_url(self.hass) ), }, data_schema=self.add_suggested_values_to_schema(REAUTH_SCHEMA, form_data), @@ -508,8 +481,8 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reconfigure", description_placeholders={ - "local_user_documentation_url": await async_local_user_documentation_url( - self.hass + "local_user_documentation_url": ( + await async_local_user_documentation_url(self.hass) ), }, data_schema=self.add_suggested_values_to_schema( @@ -536,8 +509,8 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", description_placeholders={ - "local_user_documentation_url": await async_local_user_documentation_url( - self.hass + "local_user_documentation_url": ( + await async_local_user_documentation_url(self.hass) ) }, data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index c8d438a53d5..4fc9d85793e 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,6 +52,10 @@ DEVICES_THAT_ADOPT = { DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} +# Public API devices WebSocket: NVR (for arm_mode updates), Relay +# (for relay output state updates), and Siren (for siren active-state updates). +DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.RELAY, ModelType.SIREN} + MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" @@ -61,6 +65,7 @@ OUTDATED_LOG_MESSAGE = ( TYPE_EMPTY_VALUE = "" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, @@ -71,6 +76,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.TEXT, ] @@ -82,7 +88,6 @@ DISPATCH_CHANNELS = "new_camera_channels" EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified" EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified" EVENT_TYPE_NFC_SCANNED: Final = "scanned" -EVENT_TYPE_DOORBELL_RING: Final = "ring" EVENT_TYPE_VEHICLE_DETECTED: Final = "detected" # Delay in seconds before firing vehicle event after last thumbnail diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 1cb56b7311f..b2ccd496e45 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -1,7 +1,5 @@ """Base class for protect data.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable, Generator, Iterable @@ -19,6 +17,8 @@ from uiprotect.data import ( ModelType, ProtectAdoptableDeviceModel, PTZPatrol, + Relay, + Siren, WSSubscriptionMessage, ) from uiprotect.exceptions import ClientError, NotAuthorized @@ -83,9 +83,16 @@ class ProtectData: self._subscriptions: defaultdict[ str, set[Callable[[ProtectDeviceType], None]] ] = defaultdict(set) + self._relay_subscriptions: defaultdict[str, set[Callable[[Relay], None]]] = ( + defaultdict(set) + ) + self._siren_subscriptions: defaultdict[str, set[Callable[[Siren], None]]] = ( + defaultdict(set) + ) self._pending_camera_ids: set[str] = set() self._unsubs: list[CALLBACK_TYPE] = [] self._auth_failures = 0 + self.auth_retries = 0 self.last_update_success = False self.api = protect self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) @@ -163,8 +170,48 @@ class ProtectData: async_track_time_interval( self._hass, self._async_poll, self._update_interval ), + # Subscribe to the public devices websocket unconditionally so that + # it is active before update_public() primes the cache. + # Per library docs: subscribe first, then call update_public(). + api.subscribe_devices_websocket( + self._async_process_public_devices_ws_message + ), ] + @callback + def _async_process_public_devices_ws_message( + self, message: WSSubscriptionMessage + ) -> None: + """Process a message from the public devices websocket. + + The API client pre-filters messages to the model types listed in + DEVICES_WS_SUBSCRIBED_MODELS. NVR messages signal the private NVR so + alarm entities pick up the new arm state. Relay messages dispatch + the merged Relay object by mac so relay-output entities can refresh. + Siren messages dispatch the merged Siren object by mac so siren entities + can refresh. + """ + new_obj = message.new_obj + if new_obj is None: + # Delete event: notify subscribers so entities can be marked unavailable. + old_obj = message.old_obj + if old_obj is not None and old_obj.model is ModelType.SIREN: + self._async_signal_siren_update(cast(Siren, old_obj)) + return + if new_obj.model is ModelType.NVR: + self._async_signal_device_update(self.api.bootstrap.nvr) + return + if new_obj.model is ModelType.RELAY: + relay = cast(Relay, new_obj) + mac = relay.mac + if subscriptions := self._relay_subscriptions.get(mac): + _LOGGER.debug("Updating relay: %s (%s)", relay.name, mac) + for update_callback in subscriptions: + update_callback(relay) + return + if new_obj.model is ModelType.SIREN: + self._async_signal_siren_update(cast(Siren, new_obj)) + @callback def _async_websocket_state_changed(self, state: WebsocketState) -> None: """Handle a change in the websocket state.""" @@ -274,7 +321,8 @@ class ProtectData: self._pending_camera_ids.remove(device.id) async_dispatcher_send(self._hass, self.channels_signal, device) - # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates + # trigger update for all Cameras with LCD screens + # when NVR Doorbell settings updates if "doorbell_settings" in changed_data: _LOGGER.debug( "Doorbell messages updated. Updating devices with LCD screens" @@ -336,6 +384,13 @@ class ProtectData: self._async_signal_device_update(self.api.bootstrap.nvr) for device in self.get_by_types(DEVICES_THAT_ADOPT): self._async_signal_device_update(device) + if self.api.has_public_bootstrap: + for relay in self.api.public_bootstrap.relays.values(): + if subscriptions := self._relay_subscriptions.get(relay.mac): + for subscription_callback in subscriptions: + subscription_callback(relay) + for siren in self.api.public_bootstrap.sirens.values(): + self._async_signal_siren_update(siren) @callback def _async_poll(self, now: datetime) -> None: @@ -364,6 +419,40 @@ class ProtectData: if not self._subscriptions[mac]: del self._subscriptions[mac] + @callback + def async_subscribe_relay( + self, mac: str, update_callback: Callable[[Relay], None] + ) -> CALLBACK_TYPE: + """Add a callback subscriber for relay updates.""" + self._relay_subscriptions[mac].add(update_callback) + return partial(self._async_unsubscribe_relay, mac, update_callback) + + @callback + def _async_unsubscribe_relay( + self, mac: str, update_callback: Callable[[Relay], None] + ) -> None: + """Remove a relay callback subscriber.""" + self._relay_subscriptions[mac].remove(update_callback) + if not self._relay_subscriptions[mac]: + del self._relay_subscriptions[mac] + + @callback + def async_subscribe_siren( + self, mac: str, update_callback: Callable[[Siren], None] + ) -> CALLBACK_TYPE: + """Add a callback subscriber for siren updates.""" + self._siren_subscriptions[mac].add(update_callback) + return partial(self._async_unsubscribe_siren, mac, update_callback) + + @callback + def _async_unsubscribe_siren( + self, mac: str, update_callback: Callable[[Siren], None] + ) -> None: + """Remove a siren callback subscriber.""" + self._siren_subscriptions[mac].remove(update_callback) + if not self._siren_subscriptions[mac]: + del self._siren_subscriptions[mac] + @callback def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" @@ -374,6 +463,16 @@ class ProtectData: for update_callback in subscriptions: update_callback(device) + @callback + def _async_signal_siren_update(self, siren: Siren) -> None: + """Call the callbacks for a siren mac.""" + mac = siren.mac + if not (subscriptions := self._siren_subscriptions.get(mac)): + return + _LOGGER.debug("Updating siren: %s (%s)", siren.name, mac) + for update_callback in subscriptions: + update_callback(siren) + @callback def async_ufp_instance_for_config_entry_ids( diff --git a/homeassistant/components/unifiprotect/diagnostics.py b/homeassistant/components/unifiprotect/diagnostics.py index b72f35db0b5..b61fd37662d 100644 --- a/homeassistant/components/unifiprotect/diagnostics.py +++ b/homeassistant/components/unifiprotect/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for UniFi Network.""" -from __future__ import annotations - from typing import Any, cast from uiprotect.test_util.anonymize import anonymize_data diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifiprotect/discovery.py deleted file mode 100644 index 3a7fb7c65e0..00000000000 --- a/homeassistant/components/unifiprotect/discovery.py +++ /dev/null @@ -1,84 +0,0 @@ -"""The unifiprotect integration discovery.""" - -from __future__ import annotations - -from dataclasses import asdict, dataclass, field -from datetime import timedelta -import logging -from typing import Any - -from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService - -from homeassistant import config_entries -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.hass_dict import HassKey - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class UniFiProtectRuntimeData: - """Runtime data stored in hass.data[DOMAIN].""" - - auth_retries: dict[str, int] = field(default_factory=dict) - discovery_started: bool = False - - -# Typed key for hass.data access at DOMAIN level -DATA_UNIFIPROTECT: HassKey[UniFiProtectRuntimeData] = HassKey(DOMAIN) - -DISCOVERY_INTERVAL = timedelta(minutes=60) - - -@callback -def async_start_discovery(hass: HomeAssistant) -> None: - """Start discovery.""" - domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - if domain_data.discovery_started: - return - domain_data.discovery_started = True - - async def _async_discovery() -> None: - async_trigger_discovery(hass, await async_discover_devices()) - - @callback - def _async_start_background_discovery(*_: Any) -> None: - """Run discovery in the background.""" - hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") - - # Do not block startup since discovery takes 31s or more - _async_start_background_discovery() - async_track_time_interval( - hass, - _async_start_background_discovery, - DISCOVERY_INTERVAL, - cancel_on_shutdown=True, - ) - - -async def async_discover_devices() -> list[UnifiDevice]: - """Discover devices.""" - scanner = AIOUnifiScanner() - devices = await scanner.async_scan() - _LOGGER.debug("Found devices: %s", devices) - return devices - - -@callback -def async_trigger_discovery( - hass: HomeAssistant, - discovered_devices: list[UnifiDevice], -) -> None: - """Trigger config flows for discovered devices.""" - for device in discovered_devices: - if device.services[UnifiService.Protect] and device.hw_addr: - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=asdict(device), - ) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 35d750c2d8d..d8f6251b6cb 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,7 +1,5 @@ """Shared Entity definition for UniFi Protect Integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Sequence from dataclasses import dataclass from datetime import datetime @@ -371,7 +369,7 @@ class EventEntityMixin(ProtectDeviceEntity): @dataclass(frozen=True, kw_only=True) -class ProtectEntityDescription(EntityDescription, Generic[T]): +class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 """Base class for protect entity descriptions.""" ufp_required_field: str | None = None diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 59363abbcb0..6f154b311d8 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -1,7 +1,5 @@ """Platform providing event entities for UniFi Protect.""" -from __future__ import annotations - import dataclasses from typing import Any @@ -9,6 +7,7 @@ from uiprotect.data import ModelType from uiprotect.data.nvr import Event, EventDetectedThumbnail from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -20,7 +19,6 @@ from homeassistant.helpers.event import async_call_at from . import Bootstrap from .const import ( ATTR_EVENT_ID, - EVENT_TYPE_DOORBELL_RING, EVENT_TYPE_FINGERPRINT_IDENTIFIED, EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, EVENT_TYPE_NFC_SCANNED, @@ -96,7 +94,7 @@ class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventE and not self._event_already_ended(prev_event, prev_event_end) and event.type is EventType.RING ): - self._trigger_event(EVENT_TYPE_DOORBELL_RING, {ATTR_EVENT_ID: event.id}) + self._trigger_event(DoorbellEventType.RING, {ATTR_EVENT_ID: event.id}) self.async_write_ha_state() @@ -367,7 +365,7 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( device_class=EventDeviceClass.DOORBELL, ufp_required_field="feature_flags.is_doorbell", ufp_event_obj="last_ring_event", - event_types=[EVENT_TYPE_DOORBELL_RING], + event_types=[DoorbellEventType.RING], entity_class=ProtectDeviceRingEventEntity, ), ProtectEventEntityDescription( diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index f66a963da4e..9c0b7a2732e 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -1,5 +1,10 @@ { "entity": { + "alarm_control_panel": { + "nvr_alarm": { + "default": "mdi:shield-home" + } + }, "binary_sensor": { "alarm_sound_detection": { "default": "mdi:alarm-bell" diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index d0472c7b390..92bff2d6c7d 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,7 +1,5 @@ """Component providing Lights for UniFi Protect.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 6cda3d5bbd6..786f324068d 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -1,7 +1,5 @@ """Support for locks on Ubiquiti's UniFi Protect NVR.""" -from __future__ import annotations - import logging from typing import Any, cast diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b74946294ff..82baade26ac 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,61 +3,11 @@ "name": "UniFi Protect", "codeowners": ["@RaHehl"], "config_flow": true, - "dependencies": ["http", "repairs"], - "dhcp": [ - { - "macaddress": "B4FBE4*" - }, - { - "macaddress": "802AA8*" - }, - { - "macaddress": "F09FC2*" - }, - { - "macaddress": "68D79A*" - }, - { - "macaddress": "18E829*" - }, - { - "macaddress": "245A4C*" - }, - { - "macaddress": "784558*" - }, - { - "macaddress": "E063DA*" - }, - { - "macaddress": "265A4C*" - }, - { - "macaddress": "74ACB9*" - } - ], + "dependencies": ["http", "repairs", "unifi_discovery"], "documentation": "https://www.home-assistant.io/integrations/unifiprotect", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["uiprotect", "unifi_discovery"], + "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.3.0"], - "ssdp": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max" - } - ] + "requirements": ["uiprotect==10.5.0"] } diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 4ebc64942a9..24d70dfdb43 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -1,7 +1,5 @@ """Support for Ubiquiti's UniFi Protect NVR.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index e866568b15f..9424880dd6b 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -1,7 +1,5 @@ """UniFi Protect media sources.""" -from __future__ import annotations - import asyncio from calendar import monthrange from datetime import date, datetime, timedelta @@ -107,10 +105,14 @@ def _get_month_start_end(start: datetime) -> tuple[datetime, datetime]: @callback def _bad_identifier(identifier: str, err: Exception | None = None) -> NoReturn: - msg = f"Unexpected identifier: {identifier}" + exc = BrowseError( + translation_domain=DOMAIN, + translation_key="unexpected_identifier", + translation_placeholders={"identifier": identifier}, + ) if err is None: - raise BrowseError(msg) - raise BrowseError(msg) from err + raise exc + raise exc from err @callback @@ -264,11 +266,13 @@ class ProtectMediaSource(MediaSource): * {nvr_id}:browse:all|{camera_id}:all|{event_type} Root Camera(s) Event Type(s) browse source * {nvr_id}:browse:all|{camera_id}:all|{event_type}:recent:{day_count} - Listing of all events in last {day_count}, sorted in reverse chronological order + Listing of all events in last {day_count}, + sorted in reverse chronological order * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month} List of folders for each day in month + all events for month * {nvr_id}:browse:all|{camera_id}:all|{event_type}:range:{year}:{month}:all|{day} - Listing of all events for give {day} + {month} + {year} combination in chronological order + All events for given day/month/year + in chronological order """ if not item.identifier: @@ -377,7 +381,10 @@ class ProtectMediaSource(MediaSource): _bad_identifier(f"{data.api.bootstrap.nvr.id}:{subtype}:{event_id}", err) if event.start is None or event.end is None: - raise BrowseError("Event is still ongoing") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="event_ongoing", + ) return await self._build_event(data, event, thumbnail_only) @@ -787,7 +794,11 @@ class ProtectMediaSource(MediaSource): if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) if camera is None: - raise BrowseError(f"Unknown Camera ID: {camera_id}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="unknown_camera_id", + translation_placeholders={"camera_id": camera_id}, + ) name = camera.name or camera.market_name or camera.type is_doorbell = camera.feature_flags.is_doorbell has_smart = camera.feature_flags.has_smart_detect diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 2c631489217..4133148e1e9 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -1,7 +1,5 @@ """UniFi Protect data migrations.""" -from __future__ import annotations - from itertools import chain import logging from typing import TypedDict @@ -116,8 +114,10 @@ async def async_migrate_data( def async_deprecate_hdr(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Check for usages of hdr_mode switch and raise repair if it is used. - UniFi Protect v3.0.22 changed how HDR works so it is no longer a simple on/off toggle. There is - Always On, Always Off and Auto. So it has been migrated to a select. The old switch is now deprecated. + UniFi Protect v3.0.22 changed how HDR works so it is + no longer a simple on/off toggle. There is Always On, + Always Off and Auto. So it has been migrated to a + select. The old switch is now deprecated. Added in 2024.4.0 """ diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index ab21d0a8670..7971b9b34ed 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -1,7 +1,5 @@ """Component providing number entities for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from datetime import timedelta @@ -68,6 +66,14 @@ def _get_chime_duration(obj: Camera) -> int: return int(obj.chime_duration_seconds) +async def _set_chime_volume(obj: Chime, value: float) -> None: + """Set chime volume per paired camera via the public API.""" + level = int(value) + ring_settings = [setting.to_api_dict(volume=level) for setting in obj.ring_settings] + if ring_settings: + await obj.set_ring_settings_public(ring_settings) + + CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="wdr_value", @@ -86,13 +92,13 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( translation_key="microphone_level", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, - ufp_min=0, + ufp_min=1, ufp_max=100, ufp_step=1, ufp_required_field="has_mic", ufp_value="mic_volume", ufp_enabled="feature_flags.has_mic", - ufp_set_method="set_mic_volume", + ufp_set_method="set_mic_volume_public", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( @@ -174,7 +180,6 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_required_field=None, ufp_value="light_device_settings.pir_sensitivity", ufp_set_method="set_sensitivity", ufp_perm=PermRequired.WRITE, @@ -187,7 +192,6 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=15, ufp_max=900, ufp_step=15, - ufp_required_field=None, ufp_value_fn=_get_pir_duration, ufp_set_method_fn=_set_pir_duration, ufp_perm=PermRequired.WRITE, @@ -203,7 +207,6 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_required_field=None, ufp_value="motion_settings.sensitivity", ufp_set_method="set_motion_sensitivity", ufp_perm=PermRequired.WRITE, @@ -219,7 +222,6 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=3600, ufp_step=15, - ufp_required_field=None, ufp_value_fn=_get_auto_close, ufp_set_method_fn=_set_auto_close, ufp_perm=PermRequired.WRITE, @@ -227,7 +229,7 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ) CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( - ProtectNumberEntityDescription( + ProtectNumberEntityDescription[Chime]( key="volume", translation_key="volume", entity_category=EntityCategory.CONFIG, @@ -236,7 +238,7 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_max=100, ufp_step=1, ufp_value="volume", - ufp_set_method="set_volume", + ufp_set_method_fn=_set_chime_volume, ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/quality_scale.yaml b/homeassistant/components/unifiprotect/quality_scale.yaml index 01d7a68afc3..edd61c9d060 100644 --- a/homeassistant/components/unifiprotect/quality_scale.yaml +++ b/homeassistant/components/unifiprotect/quality_scale.yaml @@ -39,7 +39,9 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: exempt + comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY. docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 495805825ce..833a91b2dd2 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -1,15 +1,16 @@ """unifiprotect.repairs.""" -from __future__ import annotations - from typing import cast from uiprotect import ProtectApiClient from uiprotect.data import Bootstrap, Camera import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -47,14 +48,14 @@ class CloudAccountRepair(ProtectRepair): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" if user_input is None: @@ -117,14 +118,14 @@ class RTSPRepair(ProtectRepair): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_start() async def async_step_start( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" if user_input is None: @@ -149,7 +150,7 @@ class RTSPRepair(ProtectRepair): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: return self.async_create_entry(data={}) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 24a2791c88b..c307c6ce5a9 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -1,7 +1,5 @@ """Component providing select entities for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum @@ -10,6 +8,7 @@ from typing import Any from uiprotect.api import ProtectApiClient from uiprotect.data import ( + NVR, Camera, ChimeType, DoorbellMessageType, @@ -22,22 +21,27 @@ from uiprotect.data import ( MountType, ProtectAdoptableDeviceModel, PTZPatrol, + PublicHdrMode, RecordingMode, Sensor, Viewer, ) +from uiprotect.exceptions import GlobalAlarmManagerError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import TYPE_EMPTY_VALUE +from .const import DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( PermRequired, ProtectDeviceEntity, ProtectEntityDescription, + ProtectNVREntity, ProtectSettableKeysMixin, T, async_all_device_entities, @@ -181,11 +185,15 @@ async def _set_paired_camera(obj: Light | Sensor | Doorlock, camera_id: str) -> async def _set_doorbell_message(obj: Camera, message: str) -> None: if message.startswith(DoorbellMessageType.CUSTOM_MESSAGE.value): message = message.rsplit(":", maxsplit=1)[-1] - await obj.set_lcd_text(DoorbellMessageType.CUSTOM_MESSAGE, text=message) + await obj.set_lcd_message_public( + DoorbellMessageType.CUSTOM_MESSAGE, text=message + ) elif message == TYPE_EMPTY_VALUE: + # Public API has no endpoint to clear the LCD message; fall back to + # the non-deprecated legacy helper. await obj.set_lcd_text(None) else: - await obj.set_lcd_text(DoorbellMessageType(message)) + await obj.set_lcd_message_public(DoorbellMessageType(message)) async def _set_liveview(obj: Viewer, liveview_id: str) -> None: @@ -203,6 +211,18 @@ async def _set_ptz_patrol(obj: Camera, patrol_slot: str) -> None: await obj.ptz_patrol_start_public(slot=slot) +_HDR_MODE_MAP = { + "auto": PublicHdrMode.AUTO, + "always": PublicHdrMode.ON, + "off": PublicHdrMode.OFF, +} + + +async def _set_hdr_mode(obj: Camera, mode: str) -> None: + """Set HDR mode via the public API.""" + await obj.set_hdr_mode_public(_HDR_MODE_MAP[mode]) + + PTZ_PATROL_DESCRIPTION = ProtectSelectEntityDescription[Camera]( key=_KEY_PTZ_PATROL, translation_key="ptz_patrol", @@ -255,14 +275,14 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_set_method="set_chime_type", ufp_perm=PermRequired.WRITE, ), - ProtectSelectEntityDescription( + ProtectSelectEntityDescription[Camera]( key="hdr_mode", translation_key="hdr_mode", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, ufp_value="hdr_mode_display", - ufp_set_method="set_hdr_mode", + ufp_set_method_fn=_set_hdr_mode, ufp_perm=PermRequired.WRITE, ), ) @@ -326,7 +346,6 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Viewer]( key="viewer", translation_key="liveview", - entity_category=None, ufp_options_fn=_get_viewer_options, ufp_value_fn=_get_viewer_current, ufp_set_method_fn=_set_liveview, @@ -379,6 +398,14 @@ async def async_setup_entry( patrols = data.ptz_patrols.get(camera.id, []) entities.append(ProtectPTZPatrolSelect(data, camera, patrols)) + api = data.api + if ( + api.has_public_bootstrap + and api.public_bootstrap.arm_mode is not None + and api.public_bootstrap.arm_profiles + ): + entities.append(ProtectNVRArmProfileSelect(data, device=api.bootstrap.nvr)) + async_add_entities(entities) @@ -500,3 +527,62 @@ class ProtectPTZPatrolSelect(ProtectDeviceEntity, SelectEntity): unifi_value = self._hass_to_unifi_options[option] await _set_ptz_patrol(self.device, unifi_value) # State will be updated via websocket when active_patrol_slot changes + + +class ProtectNVRArmProfileSelect(ProtectNVREntity, SelectEntity): + """UniFi Protect NVR arm profile select entity.""" + + _attr_translation_key = "nvr_arm_profile" + _attr_current_option: str | None = None + _state_attrs = ("_attr_available", "_attr_options", "_attr_current_option") + + def __init__(self, data: ProtectData, device: NVR) -> None: + """Initialize the NVR arm profile select entity.""" + self._id_to_name: dict[str, str] = {} + self._name_to_id: dict[str, str] = {} + super().__init__(data, device, EntityDescription(key="nvr_arm_profile")) + self._refresh_arm_profile_state() + + @callback + def _refresh_arm_profile_state(self) -> None: + """Update options and current option from the public bootstrap cache.""" + api = self.data.api + pb = api.public_bootstrap if api.has_public_bootstrap else None + arm_mode = pb.arm_mode if pb is not None else None + + if pb is None or arm_mode is None: + self._attr_available = False + self._attr_options = [] + self._attr_current_option = None + return + + # Always append a short id suffix so every option label is unique + # and stable even if another profile with the same name is added later. + self._id_to_name = {} + self._name_to_id = {} + for pid, profile in pb.arm_profiles.items(): + label = f"{profile.name} ({pid[-6:]})" + self._id_to_name[pid] = label + self._name_to_id[label] = pid + self._attr_options = sorted(self._name_to_id) + profile_id = arm_mode.arm_profile_id + self._attr_current_option = ( + self._id_to_name.get(profile_id) if profile_id else None + ) + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + super()._async_update_device_from_protect(device) + self._refresh_arm_profile_state() + + @async_ufp_instance_command + async def async_select_option(self, option: str) -> None: + """Change the currently active arm profile.""" + profile_id = self._name_to_id[option] + try: + await self.data.api.set_current_arm_profile_public(profile_id) + except GlobalAlarmManagerError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="global_alarm_manager", + ) from err diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 894c1dad871..1409f3dd876 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -1,7 +1,5 @@ """Component providing sensors for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 3737bde8ffe..bb89766c771 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -1,7 +1,5 @@ """UniFi Protect Integration services.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import logging diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py new file mode 100644 index 00000000000..d1ca11add96 --- /dev/null +++ b/homeassistant/components/unifiprotect/siren.py @@ -0,0 +1,221 @@ +"""UniFi Protect siren platform (Public API).""" + +from datetime import datetime +import logging +from typing import Any + +from uiprotect.data import Siren, SirenDuration + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityFeature, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN +from .data import ProtectData, UFPConfigEntry +from .utils import async_ufp_instance_command + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +# Durations (in seconds) accepted by the UniFi Protect siren public API. +VALID_DURATIONS: tuple[int, ...] = tuple(d.value for d in SirenDuration) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Protect siren entities from a config entry.""" + data: ProtectData = entry.runtime_data + + api = data.api + if not api.has_public_bootstrap: + return + + async_add_entities( + ProtectSiren(data, siren) for siren in api.public_bootstrap.sirens.values() + ) + + +class ProtectSiren(SirenEntity): + """Siren entity for a UniFi Protect siren device (Public API).""" + + _attr_has_entity_name = True + _attr_attribution = DEFAULT_ATTRIBUTION + _attr_name = None # device name is the entity name + _attr_should_poll = False + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + + def __init__(self, data: ProtectData, siren: Siren) -> None: + """Initialise the siren entity.""" + self.data = data + self._siren_id = siren.id + self._attr_unique_id = f"{siren.mac}_siren" + nvr = data.api.bootstrap.nvr + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, siren.mac)}, + identifiers={(DOMAIN, siren.mac)}, + manufacturer=DEFAULT_BRAND, + name=siren.name, + model="Siren", + via_device=(DOMAIN, nvr.mac), + ) + self._siren_mac = siren.mac + self._cancel_scheduled_off: CALLBACK_TYPE | None = None + self._update_from_siren(siren) + + @property + def _siren(self) -> Siren | None: + api = self.data.api + if not api.has_public_bootstrap: + return None + return api.public_bootstrap.sirens.get(self._siren_id) + + @callback + def _update_from_siren(self, siren: Siren) -> None: + """Refresh cached attributes from the siren object.""" + self._attr_available = self.data.last_update_success + self._attr_is_on = siren.is_active + + @callback + def _async_updated(self, siren: Siren) -> None: + """Handle a public devices WS update for this siren.""" + # Cancel any previous auto-off timer before scheduling a new one. + self._cancel_off_timer() + + prev_state = (self._attr_available, self._attr_is_on) + + # If the siren is no longer in the public bootstrap (delete event), + # mark it unavailable and off, then bail out. + if self._siren is None: + self._attr_available = False + self._attr_is_on = False + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + return + + self._update_from_siren(siren) + + # The server never emits a WS message when a timed run expires, so we + # must schedule our own callback. Both activated_at and duration are + # in milliseconds in the WS payload. + status = siren.siren_status + if ( + status.is_active + and status.activated_at is not None + and status.duration is not None + ): + delay = ( + status.activated_at + status.duration + ) / 1000 - dt_util.utcnow().timestamp() + if delay <= 0: + # Already expired (e.g. stale bootstrap after a reconnect): + # override the is_active=True from the payload immediately so + # we never briefly write ON into the state machine. + self._attr_is_on = False + else: + self._cancel_scheduled_off = async_call_later( + self.hass, delay, self._async_scheduled_off + ) + + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + + @callback + def _async_scheduled_off(self, _now: datetime) -> None: + """Timed siren run has expired — push state to OFF.""" + self._cancel_scheduled_off = None + self._attr_is_on = False + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to public WS updates dispatched by ProtectData.""" + await super().async_added_to_hass() + self.async_on_remove( + self.data.async_subscribe_siren(self._siren_mac, self._async_updated) + ) + self.async_on_remove(self._cancel_off_timer) + # Schedule the auto-off timer for any already-active timed run so + # a siren that was running when HA started does not remain stuck ON. + if (siren := self._siren) is not None: + self._async_updated(siren) + + @callback + def _cancel_off_timer(self) -> None: + """Cancel the pending auto-off timer if any.""" + if self._cancel_scheduled_off is not None: + self._cancel_scheduled_off() + self._cancel_scheduled_off = None + + @async_ufp_instance_command + async def async_turn_on(self, **kwargs: Any) -> None: + """Activate the siren, optionally for a given duration and/or volume.""" + if (siren := self._siren) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="siren_not_available", + ) + + duration: int | None = kwargs.get(ATTR_DURATION) + volume_level: float | None = kwargs.get(ATTR_VOLUME_LEVEL) + + # Validate duration first (synchronous) before making any API calls. + norm_duration: SirenDuration | None = None + if duration is not None: + try: + norm_duration = SirenDuration(duration) + except ValueError: + valid = ", ".join(str(v) for v in VALID_DURATIONS) + _LOGGER.debug( + "Rejected invalid siren duration %ds for %s (valid: %s s)", + duration, + siren.name, + valid, + ) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="siren_invalid_duration", + translation_placeholders={ + "duration": str(duration), + "valid": valid, + }, + ) from None + + # Set volume if requested (separate API call). + if volume_level is not None: + # HA passes volume as 0.0-1.0; UFP expects 0-100. + await siren.set_volume(round(volume_level * 100)) + + await siren.play(duration=norm_duration) + + @async_ufp_instance_command + async def async_turn_off(self, **kwargs: Any) -> None: + """Stop the siren.""" + if (siren := self._siren) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="siren_not_available", + ) + await siren.stop() + # The server does not emit a WS event after a manual stop, so we set + # the state optimistically and cancel any pending auto-off timer. + self._cancel_off_timer() + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 69ac175ae39..deec8671017 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -90,6 +90,11 @@ } }, "entity": { + "alarm_control_panel": { + "nvr_alarm": { + "name": "Alarm Manager" + } + }, "binary_sensor": { "alarm_sound_detection": { "name": "Alarm sound detection" @@ -403,6 +408,9 @@ "window": "Window" } }, + "nvr_arm_profile": { + "name": "Alarm profile" + }, "paired_camera": { "name": "Paired camera" }, @@ -564,7 +572,7 @@ "detections_baby_cry": { "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_baby_cry::name%]" }, - "detections_barking": { + "detections_bark": { "name": "[%key:component::unifiprotect::entity::binary_sensor::detections_barking::name%]" }, "detections_car_alarm": { @@ -636,6 +644,9 @@ "privacy_mode": { "name": "Privacy mode" }, + "relay_output": { + "name": "Output {output_name}" + }, "ssh_enabled": { "name": "[%key:component::unifiprotect::entity::binary_sensor::ssh_enabled::name%]" }, @@ -668,6 +679,15 @@ "device_not_found": { "message": "No device found for device id: {device_id}" }, + "entry_auth_failed": { + "message": "Authentication failed, please reauthenticate" + }, + "event_ongoing": { + "message": "Event is still ongoing" + }, + "global_alarm_manager": { + "message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally." + }, "no_users_found": { "message": "No users found, please check Protect permissions" }, @@ -689,11 +709,26 @@ "ptz_preset_not_found": { "message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}" }, + "relay_not_available": { + "message": "Relay is no longer available" + }, "service_error": { "message": "Error calling UniFi Protect service, check the logs for more details" }, + "siren_invalid_duration": { + "message": "Invalid siren duration {duration}s. Valid values are: {valid} seconds" + }, + "siren_not_available": { + "message": "Siren is no longer available" + }, "stream_error": { "message": "Error playing audio, check the logs for more details" + }, + "unexpected_identifier": { + "message": "Unexpected identifier: {identifier}" + }, + "unknown_camera_id": { + "message": "Unknown camera ID: {camera_id}" } }, "issues": { diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index a5b399ef8c4..52ae6167444 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -1,26 +1,32 @@ """Component providing Switches for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from functools import partial -from typing import Any +from typing import Any, Literal from uiprotect.data import ( Camera, ModelType, ProtectAdoptableDeviceModel, + PublicHdrMode, + PublicRelayOutput, RecordingMode, + Relay, + RelayOutputState, VideoMode, ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, @@ -48,7 +54,11 @@ class ProtectSwitchEntityDescription( async def _set_highfps(obj: Camera, value: bool) -> None: - await obj.set_video_mode(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) + await obj.set_video_mode_public(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) + + +async def _set_hdr(obj: Camera, value: bool) -> None: + await obj.set_hdr_mode_public(PublicHdrMode.AUTO if value else PublicHdrMode.OFF) CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( @@ -67,17 +77,17 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", - ufp_set_method="set_status_light", + ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), - ProtectSwitchEntityDescription( + ProtectSwitchEntityDescription[Camera]( key="hdr_mode", translation_key="hdr_mode", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ufp_required_field="feature_flags.has_hdr", ufp_value="hdr_mode", - ufp_set_method="set_hdr", + ufp_set_method_fn=_set_hdr, ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( @@ -104,7 +114,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_name", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", - ufp_set_method="set_osd_name", + ufp_set_method="set_osd_name_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -112,7 +122,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_date", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", - ufp_set_method="set_osd_date", + ufp_set_method="set_osd_date_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -120,7 +130,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_logo", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", - ufp_set_method="set_osd_logo", + ufp_set_method="set_osd_logo_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -128,7 +138,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_nerd_mode", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", - ufp_set_method="set_osd_bitrate", + ufp_set_method="set_osd_nerd_mode_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -156,7 +166,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_person_detection", + ufp_set_method="set_person_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -166,7 +176,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_vehicle_detection", + ufp_set_method="set_vehicle_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -176,7 +186,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_animal", ufp_value="is_animal_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_animal_detection", + ufp_set_method="set_animal_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -186,7 +196,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_package_detection", + ufp_set_method="set_package_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -196,7 +206,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_license_plate", ufp_value="is_license_plate_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_license_plate_detection", + ufp_set_method="set_license_plate_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -206,7 +216,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_smoke", ufp_value="is_smoke_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_smoke_detection", + ufp_set_method="set_smoke_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -216,7 +226,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_co", ufp_value="is_co_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_cmonx_detection", + ufp_set_method="set_co_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -226,7 +236,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_siren", ufp_value="is_siren_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_siren_detection", + ufp_set_method="set_siren_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -236,7 +246,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_baby_cry", ufp_value="is_baby_cry_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_baby_cry_detection", + ufp_set_method="set_baby_cry_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -246,7 +256,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_speaking", ufp_value="is_speaking_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_speaking_detection", + ufp_set_method="set_speaking_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -256,7 +266,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_bark", ufp_value="is_bark_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_bark_detection", + ufp_set_method="set_bark_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -266,7 +276,8 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_car_alarm", ufp_value="is_car_alarm_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_car_alarm_detection", + # Public API renamed "car alarm" to "burglar"; internal model keeps the legacy name. + ufp_set_method="set_burglar_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -276,7 +287,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_car_horn", ufp_value="is_car_horn_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_car_horn_detection", + ufp_set_method="set_car_horn_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -286,7 +297,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_glass_break", ufp_value="is_glass_break_detection_on", ufp_enabled="is_recording_enabled", - ufp_set_method="set_glass_break_detection", + ufp_set_method="set_glass_break_detection_public", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -421,6 +432,12 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ) +_RELAY_STATE_MAP: dict[RelayOutputState, bool] = { + RelayOutputState.ON: True, + RelayOutputState.OFF: False, + RelayOutputState.OFF_OTP: False, +} + _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { ModelType.CAMERA: CAMERA_SWITCHES, ModelType.LIGHT: LIGHT_SWITCHES, @@ -562,3 +579,119 @@ async def async_setup_entry( for switch in NVR_SWITCHES ) async_add_entities(entities) + + # Public API: relay output switches. Only available when the public + # bootstrap has been primed (requires API key + supported NVR firmware). + api = data.api + if api.has_public_bootstrap: + relay_entities: list[ProtectRelayOutputSwitch] = [ + ProtectRelayOutputSwitch(data, relay, output) + for relay in api.public_bootstrap.relays.values() + for output in relay.outputs + ] + if relay_entities: + async_add_entities(relay_entities) + + +class ProtectRelayOutputSwitch(SwitchEntity): + """Switch entity for a single relay output channel (Public API). + + The relay device and its outputs are exposed through UniFi Protect's + public integration API and cached in :attr:`ProtectApiClient.public_bootstrap`. + Each output channel is represented as its own switch entity; turning it + on/off goes through :meth:`Relay.activate_output`. + """ + + _attr_has_entity_name = True + _attr_attribution = DEFAULT_ATTRIBUTION + _attr_should_poll = False + _attr_translation_key = "relay_output" + + def __init__( + self, + data: ProtectData, + relay: Relay, + output: PublicRelayOutput, + ) -> None: + """Initialize the relay output switch.""" + self.data = data + self._relay_id = relay.id + self._relay_mac = relay.mac + self._output_id = output.id + self._attr_unique_id = f"{relay.mac}_relay_output_{output.id}" + self._attr_translation_placeholders = { + "output_name": output.name or str(output.id), + } + nvr = data.api.bootstrap.nvr + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)}, + identifiers={(DOMAIN, relay.mac)}, + manufacturer=DEFAULT_BRAND, + name=relay.name, + model="Relay", + via_device=(DOMAIN, nvr.mac), + ) + self._update_from_relay(relay) + + @property + def _relay(self) -> Relay | None: + api = self.data.api + if not api.has_public_bootstrap: + return None + return api.public_bootstrap.relays.get(self._relay_id) + + @callback + def _update_from_relay(self, relay: Relay) -> None: + """Refresh ``_attr_is_on`` and availability from the cached relay.""" + output = relay.get_output(self._output_id) + if output is None: + self._attr_available = False + self._attr_is_on = None + return + self._attr_available = self.data.last_update_success + self._attr_is_on = ( + _RELAY_STATE_MAP.get(output.state) if output.state is not None else None + ) + + @callback + def _async_updated(self, relay: Relay) -> None: + """Handle a public relay WS update for this relay.""" + prev_state = (self._attr_available, self._attr_is_on) + self._update_from_relay(relay) + # If the relay was removed from the bootstrap while the WS update + # was in flight, mark unavailable so commands cannot succeed. + if self._relay is None: + self._attr_available = False + if (self._attr_available, self._attr_is_on) != prev_state: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to public relay WS updates dispatched by ProtectData.""" + await super().async_added_to_hass() + self.async_on_remove( + self.data.async_subscribe_relay(self._relay_mac, self._async_updated) + ) + + async def _activate_output(self, state: Literal["on", "off"]) -> None: + """Send activate_output to the relay, raising if unavailable.""" + if (relay := self._relay) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="relay_not_available", + ) + if relay.get_output(self._output_id) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="relay_not_available", + ) + await relay.activate_output(self._output_id, state=state) + + @async_ufp_instance_command + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the relay output on.""" + await self._activate_output("on") + + @async_ufp_instance_command + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the relay output off.""" + await self._activate_output("off") diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 473acf1a40c..1254626c420 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -1,7 +1,5 @@ """Text entities for UniFi Protect.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index b520e83a592..9e5f7af14c0 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -1,7 +1,5 @@ """UniFi Protect Integration utils.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Generator, Iterable import contextlib from functools import wraps @@ -37,13 +35,13 @@ from .const import ( CONF_ALL_UPDATES, CONF_OVERRIDE_CHOST, DEVICES_FOR_SUBSCRIBE, + DEVICES_WS_SUBSCRIBED_MODELS, DOMAIN, ModelType, ) if TYPE_CHECKING: from .data import UFPConfigEntry - from .entity import BaseProtectEntity @callback @@ -126,6 +124,7 @@ def async_create_api_client( session=session, public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, + devices_ws_subscribed_models=DEVICES_WS_SUBSCRIBED_MODELS, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), ignore_unadopted=False, @@ -145,7 +144,7 @@ def get_camera_base_name(channel: CameraChannel) -> str: return camera_name -def async_ufp_instance_command[_EntityT: "BaseProtectEntity", **_P]( +def async_ufp_instance_command[_EntityT, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: """Decorate UniFi Protect entity instance commands to handle exceptions. diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index cc2e1c6a5fc..6053c8c4620 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -1,7 +1,5 @@ """UniFi Protect Integration views.""" -from __future__ import annotations - from datetime import datetime from http import HTTPStatus import logging diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 0f9df0c10f3..c56a39b6ac8 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -1,7 +1,5 @@ """Combination of multiple media players for a universal controller.""" -from __future__ import annotations - from copy import copy from typing import Any @@ -78,6 +76,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -107,7 +106,8 @@ STATES_ORDER = [ STATE_UNAVAILABLE, MediaPlayerState.OFF, MediaPlayerState.IDLE, - MediaPlayerState.STANDBY, + # Not using MediaPlayerState.STANDBY to avoid deprecation warning + "standby", MediaPlayerState.ON, MediaPlayerState.PAUSED, MediaPlayerState.BUFFERING, @@ -144,7 +144,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the universal media players.""" - await async_setup_reload_service(hass, "universal", ["media_player"]) + await async_setup_reload_service(hass, "universal", [Platform.MEDIA_PLAYER]) player = UniversalMediaPlayer(hass, config) async_add_entities([player]) @@ -154,6 +154,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Representation of an universal media player.""" _attr_should_poll = False + _attr_media_image_remotely_accessible = True def __init__( self, diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index ebfc8eaeece..b4150c631ab 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -5,32 +5,33 @@ import logging import upb_lib from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, CONF_FILE_PATH, CONF_HOST, Platform +from homeassistant.const import ( + ATTR_COMMAND, + CONF_DEVICE, + CONF_FILE_PATH, + CONF_HOST, + Platform, +) from homeassistant.core import HomeAssistant -from .const import ( - ATTR_ADDRESS, - ATTR_BRIGHTNESS_PCT, - ATTR_RATE, - DOMAIN, - EVENT_UPB_SCENE_CHANGED, -) +from .const import ATTR_ADDRESS, ATTR_BRIGHTNESS_PCT, ATTR_RATE, EVENT_UPB_SCENE_CHANGED _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.SCENE] +type UpbConfigEntry = ConfigEntry[upb_lib.UpbPim] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, config_entry: UpbConfigEntry) -> bool: """Set up a new config_entry for UPB PIM.""" - url = config_entry.data[CONF_HOST] + url = config_entry.data[CONF_DEVICE] file = config_entry.data[CONF_FILE_PATH] upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file}) await upb.load_upstart_file() await upb.async_connect() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = {"upb": upb} + config_entry.runtime_data = upb await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -57,29 +58,39 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: UpbConfigEntry) -> bool: """Unload the config_entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: - upb = hass.data[DOMAIN][config_entry.entry_id]["upb"] - upb.disconnect() - hass.data[DOMAIN].pop(config_entry.entry_id) + config_entry.runtime_data.disconnect() return unload_ok async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate entry.""" - _LOGGER.debug("Migrating from version %s", entry.version) + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) if entry.version == 1: - # 1 -> 2: Unique ID from integer to string + # 1.1 -> 1.2: Unique ID from integer to string if entry.minor_version == 1: - minor_version = 2 hass.config_entries.async_update_entry( - entry, unique_id=str(entry.unique_id), minor_version=minor_version + entry, unique_id=str(entry.unique_id), minor_version=2 + ) + + # 1.2 -> 1.3: Migrate from legacy CONF_HOST URL to CONF_DEVICE + if entry.minor_version < 3: + # upb-lib is backward compatible with the older URL formats, + # but we need to move to CONF_DEVICE + device = entry.data[CONF_HOST] + file_path = entry.data[CONF_FILE_PATH] + + hass.config_entries.async_update_entry( + entry, + data={CONF_DEVICE: device, CONF_FILE_PATH: file_path}, + minor_version=3, ) _LOGGER.debug("Migration successful") diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index af1ee7d5ab0..803a7a2d3b6 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -4,32 +4,30 @@ import asyncio from contextlib import suppress import logging from typing import Any -from urllib.parse import urlparse import upb_lib import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_FILE_PATH, CONF_HOST, CONF_PROTOCOL +from homeassistant.const import CONF_DEVICE, CONF_FILE_PATH from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SerialPortSelector from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PROTOCOL_MAP = {"TCP": "tcp://", "Serial port": "serial://"} + DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_PROTOCOL, default="Serial port"): vol.In( - ["TCP", "Serial port"] - ), - vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_DEVICE): SerialPortSelector(), vol.Required(CONF_FILE_PATH, default=""): str, } ) + VALIDATE_TIMEOUT = 15 -async def _validate_input(data): +async def _validate_input(data: dict[str, Any]) -> tuple[str, dict[str, Any]]: """Validate the user input allows us to connect.""" def _connected_callback(): @@ -37,7 +35,7 @@ async def _validate_input(data): connected_event = asyncio.Event() file_path = data.get(CONF_FILE_PATH) - url = _make_url_from_data(data) + url = data[CONF_DEVICE] upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file_path}) upb.add_handler("connected", _connected_callback) @@ -63,33 +61,26 @@ async def _validate_input(data): raise CannotConnect # Return info that you want to store in the config entry. - return (upb.network_id, {"title": "UPB", CONF_HOST: url, CONF_FILE_PATH: file_path}) - - -def _make_url_from_data(data): - if host := data.get(CONF_HOST): - return host - - protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]] - address = data[CONF_ADDRESS] - return f"{protocol}{address}" + return ( + upb.network_id, + {"title": "UPB", CONF_DEVICE: url, CONF_FILE_PATH: file_path}, + ) class UPBConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for UPB PIM.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: + self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]}) try: - if self._url_already_configured(_make_url_from_data(user_input)): - return self.async_abort(reason="already_configured") network_id, info = await _validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" @@ -106,8 +97,8 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=info["title"], data={ - CONF_HOST: info[CONF_HOST], - CONF_FILE_PATH: user_input[CONF_FILE_PATH], + CONF_DEVICE: info[CONF_DEVICE], + CONF_FILE_PATH: info[CONF_FILE_PATH], }, ) @@ -115,14 +106,6 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - def _url_already_configured(self, url): - """See if we already have a UPB PIM matching user input configured.""" - existing_hosts = { - urlparse(entry.data[CONF_HOST]).hostname - for entry in self._async_current_entries() - } - return urlparse(url).hostname in existing_hosts - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index ca88784c65e..c314dafe549 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -10,12 +10,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA +from . import UpbConfigEntry +from .const import UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbAttachedEntity SERVICE_LIGHT_FADE_START = "light_fade_start" @@ -25,12 +25,12 @@ SERVICE_LIGHT_BLINK = "light_blink" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpbConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB light based on a config entry.""" - upb = hass.data[DOMAIN][config_entry.entry_id]["upb"] + upb = config_entry.runtime_data unique_id = config_entry.entry_id async_add_entities( UpbLight(upb.devices[dev], unique_id, upb) for dev in upb.devices diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index b40388be71b..8eb8e99fd35 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.6.1"] + "requirements": ["upb-lib==0.7.2"] } diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py index 45a1d664b15..a4c31207e26 100644 --- a/homeassistant/components/upb/scene.py +++ b/homeassistant/components/upb/scene.py @@ -3,12 +3,12 @@ from typing import Any from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA +from . import UpbConfigEntry +from .const import UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbEntity SERVICE_LINK_DEACTIVATE = "link_deactivate" @@ -20,11 +20,11 @@ SERVICE_LINK_BLINK = "link_blink" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpbConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB link based on a config entry.""" - upb = hass.data[DOMAIN][config_entry.entry_id]["upb"] + upb = config_entry.runtime_data unique_id = config_entry.entry_id async_add_entities(UpbLink(upb.links[link], unique_id, upb) for link in upb.links) diff --git a/homeassistant/components/upb/strings.json b/homeassistant/components/upb/strings.json index 95cf158f216..a27357cb31e 100644 --- a/homeassistant/components/upb/strings.json +++ b/homeassistant/components/upb/strings.json @@ -11,11 +11,14 @@ "step": { "user": { "data": { - "address": "Address (see description above)", - "file_path": "Path and name of the UPStart UPB export file.", - "protocol": "Protocol" + "device": "Device", + "file_path": "UPStart export file" }, - "description": "Connect a Universal Powerline Bus Powerline Interface Module (UPB PIM). The address string must be in the form 'address[:port]' for 'tcp'. The port is optional and defaults to 2101. Example: '192.168.1.42'. For the serial protocol, the address must be in the form 'tty[:baud]'. The baud is optional and defaults to 4800. Example: '/dev/ttyS1'.", + "data_description": { + "device": "The serial port or network address of the UPB PIM.", + "file_path": "Path and name of the UPStart UPB export file." + }, + "description": "Connect a Universal Powerline Bus Powerline Interface Module (UPB PIM).", "title": "Connect to UPB PIM" } } diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index bdaf01518f1..498ddd7020b 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -1,7 +1,5 @@ """Support for UPC ConnectBox router.""" -from __future__ import annotations - import logging from connect_box import ConnectBox diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index a3fec73dca8..b2bf3a1c894 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -1,7 +1,5 @@ """Support for UpCloud.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 16adcc51ddf..e68c817a396 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for UpCloud.""" -from __future__ import annotations - import logging from typing import Any @@ -107,6 +105,8 @@ class UpCloudOptionsFlow(OptionsFlow): data_schema = vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=home-assistant-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get(CONF_SCAN_INTERVAL) diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index 8088b3a72ea..8afaa75824e 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for UpCloud.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index 67a3e6cdff1..4e1d407aa9c 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -1,7 +1,5 @@ """Support for UpCloud.""" -from __future__ import annotations - from typing import Any import upcloud_api diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 200bb346c4d..47e28cec615 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -1,7 +1,5 @@ """Component to allow for providing device or service updates.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum from functools import lru_cache diff --git a/homeassistant/components/person/condition.py b/homeassistant/components/update/condition.py similarity index 52% rename from homeassistant/components/person/condition.py rename to homeassistant/components/update/condition.py index 5a820e717f5..fd74562ec51 100644 --- a/homeassistant/components/person/condition.py +++ b/homeassistant/components/update/condition.py @@ -1,17 +1,17 @@ -"""Provides conditions for persons.""" +"""Provides conditions for updates.""" -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.condition import Condition, make_entity_state_condition from .const import DOMAIN CONDITIONS: dict[str, type[Condition]] = { - "is_home": make_entity_state_condition(DOMAIN, STATE_HOME), - "is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME), + "is_available": make_entity_state_condition(DOMAIN, STATE_ON), + "is_not_available": make_entity_state_condition(DOMAIN, STATE_OFF), } async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the conditions for persons.""" + """Return the update conditions.""" return CONDITIONS diff --git a/homeassistant/components/update/conditions.yaml b/homeassistant/components/update/conditions.yaml new file mode 100644 index 00000000000..610cb4f65ff --- /dev/null +++ b/homeassistant/components/update/conditions.yaml @@ -0,0 +1,19 @@ +.condition_common: &condition_common + target: + entity: + domain: update + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_available: *condition_common +is_not_available: *condition_common diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py index 83a74ef6789..cae5e954f36 100644 --- a/homeassistant/components/update/const.py +++ b/homeassistant/components/update/const.py @@ -1,7 +1,5 @@ """Constants for the update component.""" -from __future__ import annotations - from enum import IntFlag from typing import Final diff --git a/homeassistant/components/update/device_trigger.py b/homeassistant/components/update/device_trigger.py index 1058acc3ee3..b2a049627d8 100644 --- a/homeassistant/components/update/device_trigger.py +++ b/homeassistant/components/update/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for update entities.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import toggle_entity diff --git a/homeassistant/components/update/icons.json b/homeassistant/components/update/icons.json index 3ed26f4b6bd..7c015da2478 100644 --- a/homeassistant/components/update/icons.json +++ b/homeassistant/components/update/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_available": { + "condition": "mdi:package-up" + }, + "is_not_available": { + "condition": "mdi:package" + } + }, "entity_component": { "_": { "default": "mdi:package-up", diff --git a/homeassistant/components/update/significant_change.py b/homeassistant/components/update/significant_change.py index 30f6dd3244e..6bd1f51d7e3 100644 --- a/homeassistant/components/update/significant_change.py +++ b/homeassistant/components/update/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant update state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 7634d59a3c3..0b8484d0baf 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,6 +1,35 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_available": { + "description": "Tests if one or more updates are available.", + "fields": { + "behavior": { + "name": "[%key:component::update::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::condition_for_name%]" + } + }, + "name": "Update is available" + }, + "is_not_available": { + "description": "Tests if one or more updates are not available.", + "fields": { + "behavior": { + "name": "[%key:component::update::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::condition_for_name%]" + } + }, + "name": "Update is not available" + } }, "device_automation": { "extra_fields": { @@ -58,15 +87,6 @@ "name": "Firmware" } }, - "selector": { - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "clear_skipped": { "description": "Removes the skipped version marker from an update.", @@ -98,6 +118,9 @@ "fields": { "behavior": { "name": "[%key:component::update::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::trigger_for_name%]" } }, "name": "Update became available" diff --git a/homeassistant/components/update/triggers.yaml b/homeassistant/components/update/triggers.yaml index e4a276dd38e..910f8dfb573 100644 --- a/homeassistant/components/update/triggers.yaml +++ b/homeassistant/components/update/triggers.yaml @@ -5,13 +5,14 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: update_became_available: *trigger_common diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 757cad221b5..04b7af360fa 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,7 +1,5 @@ """UPnP/IGD integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta @@ -51,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool async def device_discovered( headers: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - if change == ssdp.SsdpChange.BYEBYE: + if change is ssdp.SsdpChange.BYEBYE: return nonlocal discovery_info diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 0c7b7aa5dc2..f024602d5c3 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -1,7 +1,5 @@ """Support for UPnP/IGD Binary Sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 95fd1ff0ea5..ab0c2deee77 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,7 +1,5 @@ """Config flow for UPNP.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast from urllib.parse import urlparse @@ -93,7 +91,8 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 # Paths: - # 1: ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry() + # 1: ssdp(discovery_info) --> ssdp_confirm(None) + # --> ssdp_confirm({}) --> create_entry() # 2: user(None): scan --> user({...}) --> create_entry() @staticmethod diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 7067d1d2e1a..a8d6a4a019d 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,7 +1,5 @@ """Home Assistant representation of an UPnP/IGD.""" -from __future__ import annotations - from datetime import datetime from functools import partial from ipaddress import ip_address diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index 9fef27cb7a1..5d87a133fdc 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -1,7 +1,5 @@ """Entity for UPnP/IGD.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.helpers.device_registry import DeviceInfo @@ -38,7 +36,10 @@ class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): super().__init__(coordinator) self._device = coordinator.device self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.device.original_udn}_{entity_description.unique_id or entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.device.original_udn}" + f"_{entity_description.unique_id or entity_description.key}" + ) self._attr_device_info = DeviceInfo( connections=coordinator.device_entry.connections, name=coordinator.device_entry.name, diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index c7e343d36b5..e74ba35ddb2 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,7 +1,5 @@ """Support for UPnP/IGD Sensors.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/uptime/config_flow.py b/homeassistant/components/uptime/config_flow.py index 6dd68bae148..db42b8549d9 100644 --- a/homeassistant/components/uptime/config_flow.py +++ b/homeassistant/components/uptime/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Uptime integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 488682a79c6..a9a3640851c 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -1,7 +1,5 @@ """Platform to retrieve uptime for Home Assistant.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -24,7 +22,7 @@ async def async_setup_entry( class UptimeSensor(SensorEntity): """Representation of an uptime sensor.""" - _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_device_class = SensorDeviceClass.UPTIME _attr_has_entity_name = True _attr_name = None _attr_should_poll = False diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index cdeae16cc5a..270490c9726 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -1,7 +1,5 @@ """The Uptime Kuma integration.""" -from __future__ import annotations - from pythonkuma.update import UpdateChecker from homeassistant.const import Platform diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 19eb6240d76..caa9a01458e 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Uptime Kuma integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 93d3243ecf0..d282eea92d5 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Uptime Kuma integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py index 48e23adc40d..b67540afb00 100644 --- a/homeassistant/components/uptime_kuma/diagnostics.py +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for Uptime Kuma.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index b234ca2ab68..670b77bb5af 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", - "requirements": ["pythonkuma==0.5.0"] + "requirements": ["pythonkuma==0.5.1"] } diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py index ff2ba2fed17..44f2423005e 100644 --- a/homeassistant/components/uptime_kuma/sensor.py +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Uptime Kuma integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index d6cde392546..b9020d13ce8 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -118,6 +118,7 @@ "mongodb": "MongoDB", "mqtt": "MQTT", "mysql": "MySQL/MariaDB", + "oracledb": "Oracle Database", "ping": "Ping", "port": "TCP port", "postgres": "PostgreSQL", diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py index 0e9f3846415..1eff71e9021 100644 --- a/homeassistant/components/uptime_kuma/update.py +++ b/homeassistant/components/uptime_kuma/update.py @@ -1,7 +1,5 @@ """Update platform for the Uptime Kuma integration.""" -from __future__ import annotations - from enum import StrEnum from yarl import URL diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index e5829882200..b88f3b60fbc 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,7 +1,5 @@ """The UptimeRobot integration.""" -from __future__ import annotations - from pyuptimerobot import UptimeRobot from homeassistant.const import CONF_API_KEY @@ -9,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator @@ -18,7 +16,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) key: str = entry.data[CONF_API_KEY] if key.startswith(("ur", "m")): raise ConfigEntryAuthFailed( - "Wrong API key type detected, use the 'main' API key" + translation_domain=DOMAIN, + translation_key="api_key_wrong_type", ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index d76a727cba1..f1d2c501f17 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,7 +1,5 @@ """UptimeRobot binary_sensor platform.""" -from __future__ import annotations - from pyuptimerobot import UptimeRobotMonitor from homeassistant.components.binary_sensor import ( @@ -12,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import STATUS_UP +from .const import STATUSES_ON from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity from .utils import new_device_listener @@ -38,7 +36,6 @@ async def async_setup_entry( key=str(monitor.id), device_class=BinarySensorDeviceClass.CONNECTIVITY, ), - monitor=monitor, ) for monitor in new_monitors ] @@ -54,4 +51,4 @@ class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the entity is on.""" - return bool(self._monitor.status == STATUS_UP) + return bool(self._monitor.status in STATUSES_ON) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 3e419d6827c..9258f368b11 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,7 +1,5 @@ """Config flow for UptimeRobot integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py index b0fa6346ae2..ca2b5c204b8 100644 --- a/homeassistant/components/uptimerobot/const.py +++ b/homeassistant/components/uptimerobot/const.py @@ -1,7 +1,5 @@ """Constants for the UptimeRobot integration.""" -from __future__ import annotations - from datetime import timedelta from logging import Logger, getLogger from typing import Final @@ -24,3 +22,6 @@ API_ATTR_OK: Final = "ok" STATUS_UP = "UP" STATUS_DOWN = "DOWN" +STATUS_STARTED = "STARTED" + +STATUSES_ON = [STATUS_UP, STATUS_STARTED] diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 16e49f6e408..7a4abce09c9 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the uptimerobot integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from pyuptimerobot import ( @@ -50,9 +48,16 @@ class UptimeRobotDataUpdateCoordinator( try: response = await self.api.async_get_monitors() except UptimeRobotAuthenticationException as exception: - raise ConfigEntryAuthFailed(exception) from exception + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication_exception", + ) from exception except UptimeRobotException as exception: - raise UpdateFailed(exception) from exception + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_generic_exception", + translation_placeholders={"error": "Generic UptimeRobot exception"}, + ) from exception if TYPE_CHECKING: assert isinstance(response.data, list) diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 937c8bfa794..c82961135de 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for UptimeRobot.""" -from __future__ import annotations - from typing import Any from pyuptimerobot import UptimeRobotException diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index f01902f8387..1d6f4e480ca 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -1,7 +1,5 @@ """Base UptimeRobot entity.""" -from __future__ import annotations - from pyuptimerobot import UptimeRobotMonitor from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,22 +21,26 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]): self, coordinator: UptimeRobotDataUpdateCoordinator, description: EntityDescription, - monitor: UptimeRobotMonitor, ) -> None: """Initialize UptimeRobot entities.""" super().__init__(coordinator) self.entity_description = description - self._monitor = monitor + self._monitor_id = description.key self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(self._monitor.id))}, + identifiers={(DOMAIN, self._monitor_id)}, name=self._monitor.friendlyName, manufacturer="UptimeRobot Team", entry_type=DeviceEntryType.SERVICE, model=self._monitor.type, - configuration_url=f"https://uptimerobot.com/dashboard#{self._monitor.id}", + configuration_url=f"https://uptimerobot.com/dashboard#{self._monitor_id}", ) self._attr_extra_state_attributes = { ATTR_TARGET: self._monitor.url, } - self._attr_unique_id = str(self._monitor.id) + self._attr_unique_id = self._monitor_id self.api = coordinator.api + + @property + def _monitor(self) -> UptimeRobotMonitor: + """Handle monitor updates.""" + return self.coordinator.data[int(self._monitor_id)] diff --git a/homeassistant/components/uptimerobot/icons.json b/homeassistant/components/uptimerobot/icons.json index 2cfbc6e32ab..f5bf123a68d 100644 --- a/homeassistant/components/uptimerobot/icons.json +++ b/homeassistant/components/uptimerobot/icons.json @@ -7,6 +7,7 @@ "down": "mdi:television-off", "pause": "mdi:television-pause", "seems_down": "mdi:television-off", + "started": "mdi:television-play", "up": "mdi:television-shimmer" } } diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index c7c2ea469a8..d4285e5aae6 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], - "quality_scale": "gold", - "requirements": ["pyuptimerobot==24.0.1"] + "quality_scale": "platinum", + "requirements": ["pyuptimerobot==25.0.0"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 1957ab189e3..d60020f5e47 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -73,6 +73,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: - status: todo - comment: Requirement 'pyuptimerobot==22.2.0' appears untyped + strict-typing: done diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 37cfcc1266d..3dcab5e0aac 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -1,7 +1,5 @@ """UptimeRobot sensor platform.""" -from __future__ import annotations - from pyuptimerobot import UptimeRobotMonitor from homeassistant.components.sensor import ( @@ -43,11 +41,11 @@ async def async_setup_entry( "not_checked_yet", "pause", "seems_down", + "started", "up", ], translation_key="monitor_status", ), - monitor=monitor, ) for monitor in new_monitors ] @@ -61,9 +59,12 @@ class UptimeRobotSensor(UptimeRobotEntity, SensorEntity): """Representation of a UptimeRobot sensor.""" @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return the status of the monitor.""" + if not self._monitor.status: + return None + status = self._monitor.status.lower() # The API returns "paused" # but the entity state will be "pause" to avoid a breaking change - return {"paused": "pause"}.get(status, status) # type: ignore[no-any-return] + return {"paused": "pause"}.get(status, status) diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 2b11b51dbb0..93b764c4feb 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -50,13 +50,23 @@ "not_checked_yet": "Not checked yet", "pause": "[%key:common::action::pause%]", "seems_down": "Seems down", + "started": "Started", "up": "Up" } } } }, "exceptions": { - "api_exception": { + "api_authentication_exception": { + "message": "API authentication failed, please check your API key" + }, + "api_generic_exception": { + "message": "API error: {error}" + }, + "api_key_wrong_type": { + "message": "Wrong API key type detected, use the 'main' API key" + }, + "api_switch_exception": { "message": "Could not turn on/off monitoring: {error}" } } diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index dc519555859..8f2fa4fbfca 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -1,14 +1,8 @@ """UptimeRobot switch platform.""" -from __future__ import annotations - from typing import Any -from pyuptimerobot import ( - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobotMonitor from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,13 +10,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, STATUS_DOWN, STATUS_UP +from .const import STATUSES_ON from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity -from .utils import new_device_listener +from .utils import new_device_listener, uptimerobot_api_call # Limit the number of parallel updates to 1 PARALLEL_UPDATES = 1 @@ -45,7 +38,6 @@ async def async_setup_entry( key=str(monitor.id), device_class=SwitchDeviceClass.SWITCH, ), - monitor=monitor, ) for monitor in new_monitors ] @@ -63,28 +55,16 @@ class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): @property def is_on(self) -> bool: """Return True if the entity is on.""" - return bool(self._monitor.status == STATUS_UP) - - async def _async_edit_monitor(self, **kwargs: Any) -> None: - """Edit monitor status.""" - try: - await self.api.async_edit_monitor(**kwargs) - except UptimeRobotAuthenticationException: - self.coordinator.config_entry.async_start_reauth(self.hass) - return - except UptimeRobotException as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_exception", - translation_placeholders={"error": "Generic UptimeRobot exception"}, - ) from exception - - await self.coordinator.async_request_refresh() + return bool(self._monitor.status in STATUSES_ON) + @uptimerobot_api_call async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self._async_edit_monitor(monitor_id=self._monitor.id, status=STATUS_DOWN) + await self.api.async_pause_monitor(monitor_id=self._monitor.id) + await self.coordinator.async_request_refresh() + @uptimerobot_api_call async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self._async_edit_monitor(monitor_id=self._monitor.id, status=STATUS_UP) + await self.api.async_start_monitor(monitor_id=self._monitor.id) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/uptimerobot/utils.py b/homeassistant/components/uptimerobot/utils.py index 57978527366..eae44544e4a 100644 --- a/homeassistant/components/uptimerobot/utils.py +++ b/homeassistant/components/uptimerobot/utils.py @@ -1,12 +1,43 @@ """Utility functions for the UptimeRobot integration.""" -from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate -from collections.abc import Callable +from pyuptimerobot import ( + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) -from pyuptimerobot import UptimeRobotMonitor +from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator +from .entity import UptimeRobotEntity + + +def uptimerobot_api_call[_T: UptimeRobotEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch UptimeRobot API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except UptimeRobotAuthenticationException: + self.coordinator.config_entry.async_start_reauth(self.hass) + return + except UptimeRobotException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_switch_exception", + translation_placeholders={"error": "Generic UptimeRobot exception"}, + ) from exception + + return cmd_wrapper def new_device_listener( diff --git a/homeassistant/components/usage_prediction/__init__.py b/homeassistant/components/usage_prediction/__init__.py index 0388591c323..72198e42022 100644 --- a/homeassistant/components/usage_prediction/__init__.py +++ b/homeassistant/components/usage_prediction/__init__.py @@ -1,7 +1,5 @@ """The usage prediction integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta from typing import Any diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index cfa93e4cb9d..15d17dceaae 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -1,7 +1,5 @@ """Code to generate common control usage patterns.""" -from __future__ import annotations - from collections import Counter from collections.abc import Callable, Sequence from datetime import datetime, timedelta diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 0ffba6bdb92..1d2ab9b8f6c 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -1,9 +1,9 @@ """The USB Discovery integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine, Sequence +from contextlib import suppress +import dataclasses from datetime import datetime, timedelta import logging import os @@ -26,38 +26,45 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .models import ( - SerialDevice, # noqa: F401 - USBDevice, -) +from .models import SerialDevice, USBDevice +from .serial_proxy_stub import register_serialx_transport from .utils import ( scan_serial_ports, - usb_device_from_path, # noqa: F401 - usb_device_from_port, # noqa: F401 + usb_device_from_path, usb_device_matches_matcher, usb_service_info_from_device, - usb_unique_id_from_service_info, # noqa: F401 + usb_unique_id_from_service_info, ) _LOGGER = logging.getLogger(__name__) _USB_DATA: HassKey[USBDiscovery] = HassKey(DOMAIN) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] +SERIAL_PORT_SCANNER_TYPE = Callable[[HomeAssistant], Sequence[USBDevice | SerialDevice]] POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register __all__ = [ + "SerialDevice", "USBCallbackMatcher", + "USBDevice", "async_register_port_event_callback", "async_register_scan_request_callback", + "async_register_serial_port_scanner", + "async_scan_serial_ports", + "scan_serial_ports", + "usb_device_from_path", + "usb_device_matches_matcher", + "usb_service_info_from_device", + "usb_unique_id_from_service_info", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -94,6 +101,21 @@ def async_register_port_event_callback( return hass.data[_USB_DATA].async_register_port_event_callback(callback) +async def async_scan_serial_ports( + hass: HomeAssistant, +) -> Sequence[USBDevice | SerialDevice]: + """Scan serial ports and return USB and other serial devices.""" + return await hass.data[_USB_DATA].async_scan_serial_ports() + + +@hass_callback +def async_register_serial_port_scanner( + hass: HomeAssistant, scanner: SERIAL_PORT_SCANNER_TYPE +) -> CALLBACK_TYPE: + """Register a scanner that contributes additional serial ports to scans.""" + return hass.data[_USB_DATA].async_register_serial_port_scanner(scanner) + + @hass_callback def async_get_usb_matchers_for_device( hass: HomeAssistant, device: USBDevice @@ -162,6 +184,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await usb_discovery.async_setup() hass.data[_USB_DATA] = usb_discovery websocket_api.async_register_command(hass, websocket_usb_scan) + websocket_api.async_register_command(hass, websocket_usb_list_serial_ports) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, register_serialx_transport()) return True @@ -191,6 +216,7 @@ class USBDiscovery: self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() + self._serial_port_scanners: list[SERIAL_PORT_SCANNER_TYPE] = [] self._last_processed_devices: set[USBDevice] = set() self._scan_lock = asyncio.Lock() @@ -306,6 +332,41 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_serial_port_scanner( + self, + scanner: SERIAL_PORT_SCANNER_TYPE, + ) -> CALLBACK_TYPE: + """Register a scanner that contributes additional serial ports to scans.""" + self._serial_port_scanners.append(scanner) + + @hass_callback + def _async_remove_callback() -> None: + with suppress(ValueError): + self._serial_port_scanners.remove(scanner) + + return _async_remove_callback + + async def async_scan_serial_ports(self) -> Sequence[USBDevice | SerialDevice]: + """Scan serial ports and return USB and other serial devices. + + Ports returned by registered scanners override real ports with the same + device path, letting integrations enhance the metadata for known devices. + """ + ports: dict[str, USBDevice | SerialDevice] = { + p.device: p + for p in await self.hass.async_add_executor_job(scan_serial_ports) + } + + for scanner in self._serial_port_scanners: + try: + for port in scanner(self.hass): + ports[port.device] = port + except Exception: + _LOGGER.exception("Error in USB scanner callback") + + return list(ports.values()) + @hass_callback def async_get_usb_matchers_for_device(self, device: USBDevice) -> list[USBMatcher]: """Return a list of matchers that match the given device.""" @@ -357,7 +418,7 @@ class USBDiscovery: for matcher in matched: for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - _UsbServiceInfo, + UsbServiceInfo, lambda flow_service_info: flow_service_info == service_info, ): if matcher["domain"] != flow["handler"]: @@ -433,7 +494,7 @@ class USBDiscovery: # Only consider USB-serial ports for discovery usb_ports = [ p - for p in await self.hass.async_add_executor_job(scan_serial_ports) + for p in await self.async_scan_serial_ports() if isinstance(p, USBDevice) ] @@ -476,3 +537,35 @@ async def websocket_usb_scan( """Scan for new usb devices.""" await async_request_scan(hass) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "usb/list_serial_ports"}) +@websocket_api.async_response +async def websocket_usb_list_serial_ports( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], +) -> None: + """List available serial ports.""" + try: + ports = await async_scan_serial_ports(hass) + except OSError as err: + connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) + return + + result = [] + for port in ports: + entry = dataclasses.asdict(port) + + if isinstance(port, USBDevice): + matchers = async_get_usb_matchers_for_device(hass, port) + entry["matching_integrations"] = list( + dict.fromkeys(matcher["domain"] for matcher in matchers) + ) + else: + entry["matching_integrations"] = [] + + result.append(entry) + + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 7035e2ab2cb..64dc450f613 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"] + "requirements": ["aiousbwatcher==1.1.2", "serialx==1.8.0"] } diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 149b86627ea..840978e5ea4 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -1,7 +1,5 @@ """Models helper class for the usb integration.""" -from __future__ import annotations - from dataclasses import dataclass @@ -13,6 +11,8 @@ class SerialDevice: serial_number: str | None manufacturer: str | None description: str | None + interface_description: str | None = None + interface_num: int | None = None @dataclass(slots=True, frozen=True, kw_only=True) @@ -21,3 +21,6 @@ class USBDevice(SerialDevice): vid: str pid: str + + # bcdDevice descriptor, often the firmware revision + bcd_device: int | None = None diff --git a/homeassistant/components/usb/serial_proxy_stub.py b/homeassistant/components/usb/serial_proxy_stub.py new file mode 100644 index 00000000000..0986b9e66e7 --- /dev/null +++ b/homeassistant/components/usb/serial_proxy_stub.py @@ -0,0 +1,41 @@ +"""ESPHome serial proxy URI handler stub for serialx.""" + +from collections.abc import Callable + +from serialx import register_uri_handler +from serialx.platforms.serial_esphome import ESPHomeSerial, ESPHomeSerialTransport + +from homeassistant.core import Event, callback +from homeassistant.exceptions import ConfigEntryNotReady + + +class HassESPHomeSerialStub(ESPHomeSerial): + """ESPHomeSerial that throws `ConfigEntryNotReady` until ESPHome itself loads.""" + + async def _async_open(self) -> None: + """Open a connection.""" + raise ConfigEntryNotReady("ESPHome has not loaded yet") + + +class HassESPHomeSerialStubTransport(ESPHomeSerialTransport): + """Transport variant that constructs `HassESPHomeSerialStub`.""" + + transport_name = "esphome-hass" + _serial_cls = HassESPHomeSerialStub + + +def register_serialx_transport() -> Callable[[Event], None]: + """Register the stub URI handler.""" + unregister = register_uri_handler( + scheme="esphome-hass://", + unique_scheme="esphome-hass-usb://", + sync_cls=HassESPHomeSerialStub, + async_transport_cls=HassESPHomeSerialStubTransport, + weight=-1, # We want the ESPHome integration transport to take precedence + ) + + @callback + def _unregister(event: Event) -> None: + unregister() + + return _unregister diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index d9481ff8ba9..3d048a56777 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -1,14 +1,10 @@ """The USB Discovery integration.""" -from __future__ import annotations - from collections.abc import Sequence -import dataclasses import fnmatch import os -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo +from serialx import SerialPortInfo, list_serial_ports from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.loader import USBMatcher @@ -16,8 +12,8 @@ from homeassistant.loader import USBMatcher from .models import SerialDevice, USBDevice -def usb_device_from_port(port: ListPortInfo) -> USBDevice: - """Convert serial ListPortInfo to USBDevice.""" +def usb_device_from_port(port: SerialPortInfo) -> USBDevice: + """Convert serialx SerialPortInfo to USBDevice.""" assert port.vid is not None assert port.pid is not None @@ -28,52 +24,34 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice: serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.description, + bcd_device=port.bcd_device, + interface_description=port.interface_description, + interface_num=port.interface_num, ) -def serial_device_from_port(port: ListPortInfo) -> SerialDevice: - """Convert serial ListPortInfo to SerialDevice.""" +def serial_device_from_port(port: SerialPortInfo) -> SerialDevice: + """Convert serialx SerialPortInfo to SerialDevice.""" return SerialDevice( device=port.device, serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.description, + interface_description=port.interface_description, + interface_num=port.interface_num, ) -def usb_serial_device_from_port(port: ListPortInfo) -> USBDevice | SerialDevice: - """Convert serial ListPortInfo to USBDevice or SerialDevice.""" - if port.vid is not None or port.pid is not None: - assert port.vid is not None - assert port.pid is not None - +def usb_serial_device_from_port(port: SerialPortInfo) -> USBDevice | SerialDevice: + """Convert serialx SerialPortInfo to USBDevice or SerialDevice.""" + if port.vid is not None and port.pid is not None: return usb_device_from_port(port) return serial_device_from_port(port) def scan_serial_ports() -> Sequence[USBDevice | SerialDevice]: """Scan serial ports and return USB and other serial devices.""" - - # Scan all symlinks first - by_id = "/dev/serial/by-id" - realpath_to_by_id: dict[str, str] = {} - if os.path.isdir(by_id): - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - realpath_to_by_id[os.path.realpath(path)] = path - - serial_ports = [] - - for port in comports(): - device = usb_serial_device_from_port(port) - device_path = realpath_to_by_id.get(port.device, port.device) - - if device_path != port.device: - # Prefer the unique /dev/serial/by-id/ path if it exists - device = dataclasses.replace(device, device=device_path) - - serial_ports.append(device) - - return serial_ports + return [usb_serial_device_from_port(port) for port in list_serial_ports()] def usb_device_from_path(device_path: str) -> USBDevice | None: diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 3dd380e79a8..9138dbdcae5 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -1,7 +1,5 @@ """Support for U.S. Geological Survey Earthquake Hazards Program Feeds.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 58216217930..6eebea8e6ba 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -60,7 +60,7 @@ def validate_cron_pattern(pattern: str) -> str: def period_or_cron(config: ConfigType) -> ConfigType: - """Check that if cron pattern is used, then meter type and offsite must be removed.""" + """Check cron pattern excludes meter type and offset.""" if CONF_CRON_PATTERN in config and CONF_METER_TYPE in config: raise vol.Invalid(f"Use <{CONF_CRON_PATTERN}> or <{CONF_METER_TYPE}>") if ( diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index 06706c79216..cf2a40417d8 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Utility Meter integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index ec4f88abc2e..bb22c0f9904 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -1,7 +1,5 @@ """Constants for the utility meter component.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING, Final, TypedDict diff --git a/homeassistant/components/utility_meter/diagnostics.py b/homeassistant/components/utility_meter/diagnostics.py index 1ff723f7a89..5163de2c883 100644 --- a/homeassistant/components/utility_meter/diagnostics.py +++ b/homeassistant/components/utility_meter/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Utility Meter.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index aa4f7970d23..32531daab5f 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -1,10 +1,8 @@ """Support for tariff selection.""" -from __future__ import annotations - import logging -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant @@ -13,11 +11,12 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, + async_create_platform_config_not_supported_issue, ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_METER, CONF_SOURCE_SENSOR, CONF_TARIFFS, DATA_UTILITY +from .const import CONF_METER, CONF_SOURCE_SENSOR, CONF_TARIFFS, DATA_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -55,9 +54,13 @@ async def async_setup_platform( ) -> None: """Set up the utility meter select.""" if discovery_info is None: - _LOGGER.error( - "This platform is not available to configure " - "from 'select:' in configuration.yaml" + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + SELECT_DOMAIN, + yaml_config_under_integration_supported=True, + learn_more_url="https://www.home-assistant.io/integrations/utility_meter/", + logger=_LOGGER, ) return diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index c9c737c4999..96acabae0a1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,7 +1,5 @@ """Utility meter from sensors providing raw data.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta @@ -15,13 +13,15 @@ import voluptuous as vol from homeassistant.components.sensor import ( ATTR_LAST_RESET, + DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, + DOMAIN as SENSOR_DOMAIN, RestoreSensor, SensorDeviceClass, SensorExtraStoredData, SensorStateClass, ) -from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import +from homeassistant.components.sensor.recorder import ( # pylint: disable=home-assistant-component-root-import _suggest_report_issue, ) from homeassistant.config_entries import ConfigEntry @@ -47,6 +47,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, + async_create_platform_config_not_supported_issue, ) from homeassistant.helpers.event import ( async_track_point_in_time, @@ -76,6 +77,7 @@ from .const import ( DAILY, DATA_TARIFF_SENSORS, DATA_UTILITY, + DOMAIN, HOURLY, MONTHLY, QUARTER_HOURLY, @@ -213,9 +215,13 @@ async def async_setup_platform( ) -> None: """Set up the utility meter sensor.""" if discovery_info is None: - _LOGGER.error( - "This platform is not available to configure " - "from 'sensor:' in configuration.yaml" + async_create_platform_config_not_supported_issue( + hass, + DOMAIN, + SENSOR_DOMAIN, + yaml_config_under_integration_supported=True, + learn_more_url="https://www.home-assistant.io/integrations/utility_meter/", + logger=_LOGGER, ) return @@ -389,7 +395,8 @@ class UtilityMeterSensor(RestoreSensor): self._attr_native_unit_of_measurement = None self._period = meter_type if meter_type is not None: - # For backwards compatibility reasons we convert the period and offset into a cron pattern + # For backwards compatibility reasons we convert + # the period and offset into a cron pattern self._cron_pattern = PERIOD2CRON[meter_type].format( minute=meter_offset.seconds % 3600 // 60, hour=meter_offset.seconds // 3600, @@ -408,11 +415,12 @@ class UtilityMeterSensor(RestoreSensor): self._current_tz = None self._config_scheduler() - def _config_scheduler(self): + def _config_scheduler(self, start_time: datetime | None = None) -> None: self.scheduler = ( CronSim( self._cron_pattern, - dt_util.now( + start_time + or dt_util.now( dt_util.get_default_time_zone() ), # we need timezone for DST purposes (see issue #102984) ) @@ -429,7 +437,7 @@ class UtilityMeterSensor(RestoreSensor): @staticmethod def _validate_state(state: State | None) -> Decimal | None: - """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + """Parse the state as a Decimal if available.""" try: return ( None @@ -455,7 +463,7 @@ class UtilityMeterSensor(RestoreSensor): if ( not self._sensor_periodically_resetting and self._last_valid_state is not None - ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + ): return new_state_val - self._last_valid_state if (old_state_val := self._validate_state(old_state)) is not None: @@ -542,9 +550,13 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None - # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, - # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state - # to None and let the fallback mechanism handle the case that the old state was valid + # Reset the last_valid_state during state change + # because if the last state before the tariff + # change was invalid, there is no way to know how + # much "adjustment" counts for which tariff. + # Therefore, we set the last_valid_state to None + # and let the fallback mechanism handle the case + # that the old state was valid self._last_valid_state = None _LOGGER.debug( @@ -610,8 +622,6 @@ class UtilityMeterSensor(RestoreSensor): # and we need to reconfigure the scheduler self._current_tz = self.hass.config.time_zone - await self._program_reset() - self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RESET_METER, self.async_reset_meter @@ -630,6 +640,13 @@ class UtilityMeterSensor(RestoreSensor): if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None + # Reconfigure the scheduler from the restored last_reset so that + # next_reset is not shifted forward on entity restore/rename. + self._config_scheduler( + dt_util.as_local(self._last_reset) if self._last_reset else None + ) + + await self._program_reset() @callback def async_source_tracking(event): @@ -697,12 +714,18 @@ class UtilityMeterSensor(RestoreSensor): @property def state_class(self) -> SensorStateClass: - """Return the device class of the sensor.""" - return ( - SensorStateClass.TOTAL - if self._sensor_net_consumption - else SensorStateClass.TOTAL_INCREASING - ) + """Return the state class of the sensor.""" + if self._sensor_net_consumption: + return SensorStateClass.TOTAL + if ( + self._input_device_class is not None + and SensorStateClass.TOTAL_INCREASING + not in DEVICE_CLASS_STATE_CLASSES.get( + self._input_device_class, {SensorStateClass.TOTAL_INCREASING} + ) + ): + return SensorStateClass.TOTAL + return SensorStateClass.TOTAL_INCREASING @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 0e09408551d..023062f95f6 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -1,7 +1,5 @@ """Support for Ubiquiti's UVC cameras.""" -from __future__ import annotations - from datetime import datetime import logging import re diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 7cd5e71f3ae..b32dcd94d7f 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -1,7 +1,5 @@ """The V2C integration.""" -from __future__ import annotations - from pytrydan import Trydan from homeassistant.const import CONF_HOST, Platform @@ -12,6 +10,7 @@ from .coordinator import V2CConfigEntry, V2CUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 85f03d6b4fb..10c9990e816 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -1,7 +1,5 @@ """Support for V2C binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py index 0421d882ee6..49fe035e63a 100644 --- a/homeassistant/components/v2c/config_flow.py +++ b/homeassistant/components/v2c/config_flow.py @@ -1,7 +1,5 @@ """Config flow for V2C integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index de8015985f9..687fed2b71d 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -1,7 +1,5 @@ """The v2c component.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/v2c/diagnostics.py b/homeassistant/components/v2c/diagnostics.py index 994f702a7bd..38250ca2e8f 100644 --- a/homeassistant/components/v2c/diagnostics.py +++ b/homeassistant/components/v2c/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for V2C.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/v2c/entity.py b/homeassistant/components/v2c/entity.py index e71c4d5d7c5..1b2020f7741 100644 --- a/homeassistant/components/v2c/entity.py +++ b/homeassistant/components/v2c/entity.py @@ -1,7 +1,5 @@ """Support for V2C EVSE.""" -from __future__ import annotations - from pytrydan import TrydanData from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 29a0ecd2081..fe1b4b8a648 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -1,5 +1,13 @@ { "entity": { + "light": { + "light_led": { + "default": "mdi:led-on" + }, + "logo_led": { + "default": "mdi:led-on" + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" diff --git a/homeassistant/components/v2c/light.py b/homeassistant/components/v2c/light.py new file mode 100644 index 00000000000..6f589434ee0 --- /dev/null +++ b/homeassistant/components/v2c/light.py @@ -0,0 +1,126 @@ +"""Light platform for V2C EVSE LEDs.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pytrydan import Trydan, TrydanData + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator +from .entity import V2CBaseEntity + +LED_ON_VALUE = 100 +LED_OFF_VALUE = 0 +BRIGHTNESS_SCALE = (LED_OFF_VALUE, LED_ON_VALUE) + + +@dataclass(frozen=True, kw_only=True) +class V2CLightEntityDescription(LightEntityDescription): + """Describes V2C EVSE light entity.""" + + supports_brightness: bool = False + value_fn: Callable[[TrydanData], int | None] + update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] + + +TRYDAN_LIGHTS = ( + V2CLightEntityDescription( + key="light_led", + translation_key="light_led", + entity_registry_enabled_default=False, + value_fn=lambda evse_data: evse_data.light_led, + update_fn=lambda evse, value: evse.light_led(value), + ), + V2CLightEntityDescription( + key="logo_led", + translation_key="logo_led", + supports_brightness=True, + value_fn=lambda evse_data: evse_data.logo_led, + update_fn=lambda evse, value: evse.logo_led(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: V2CConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up V2C Trydan light platform.""" + coordinator = config_entry.runtime_data + data = coordinator.data + assert data is not None + + async_add_entities( + V2CLightEntity( + coordinator, + description, + config_entry.entry_id, + ) + for description in TRYDAN_LIGHTS + if description.value_fn(data) is not None + ) + + +class V2CLightEntity(V2CBaseEntity, LightEntity): + """Representation of V2C EVSE LED light entity.""" + + entity_description: V2CLightEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CLightEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C light entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_color_mode = ( + ColorMode.BRIGHTNESS if description.supports_brightness else ColorMode.ONOFF + ) + self._attr_supported_color_modes = {self._attr_color_mode} + + @property + def brightness(self) -> int | None: + """Return the light brightness.""" + if not self.entity_description.supports_brightness: + return None + value = self.entity_description.value_fn(self.data) + if value is None: + return None + return value_to_brightness(BRIGHTNESS_SCALE, value) + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + value = self.entity_description.value_fn(self.data) + if value is None: + return None + return value > 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the LED.""" + value = LED_ON_VALUE + if self.entity_description.supports_brightness: + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + value = round(brightness_to_value(BRIGHTNESS_SCALE, brightness)) + if brightness: + value = max(value, 1) + await self.entity_description.update_fn(self.coordinator.evse, value) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the LED.""" + await self.entity_description.update_fn(self.coordinator.evse, LED_OFF_VALUE) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ea9f3e3579e..2cabf8952e1 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==0.8.0"] + "requirements": ["pytrydan==1.0.0"] } diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index e52242f0ce0..321fa9f5664 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -1,7 +1,5 @@ """Number platform for V2C settings.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index cfccaacda18..a9c474e5bdd 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -1,7 +1,5 @@ """Support for V2C EVSE sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index 39453ebb625..eeb4a849d8c 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -30,6 +30,14 @@ "name": "Ready" } }, + "light": { + "light_led": { + "name": "Light LED" + }, + "logo_led": { + "name": "Logo LED" + } + }, "number": { "intensity": { "name": "Intensity" diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index f3489700acc..ea53dc65c05 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -1,7 +1,5 @@ """Switch platform for V2C EVSE.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 0347e401da8..1622df54f80 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,7 +1,5 @@ """Support for vacuum cleaner robots (botvacs).""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from dataclasses import dataclass @@ -22,16 +20,19 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + service as service_helper, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from .const import DATA_COMPONENT, DOMAIN, VacuumActivity, VacuumEntityFeature from .websocket import async_register_websocket_handlers @@ -71,7 +72,6 @@ _BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) # mypy: disallow-any-generics -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the vacuum is on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) @@ -111,12 +111,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_clean_spot", [VacuumEntityFeature.CLEAN_SPOT], ) - component.async_register_entity_service( + component.async_register_batched_entity_service( SERVICE_CLEAN_AREA, { vol.Required("cleaning_area_id"): vol.All(cv.ensure_list, [str]), }, - "async_internal_clean_area", + StateVacuumEntity.async_internal_clean_area, [VacuumEntityFeature.CLEAN_AREA], ) component.async_register_entity_service( @@ -279,7 +279,8 @@ class StateVacuumEntity( # Don't report usage until after entity added to hass, after init report_usage( f"is setting the battery supported feature which has been deprecated." - f" Integration {self.platform.platform_name} should remove this as part of migrating" + f" Integration {self.platform.platform_name}" + " should remove this as part of migrating" " the battery level and icon to a sensor", core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.IGNORE, @@ -424,45 +425,68 @@ class StateVacuumEntity( return [Segment(**segment) for segment in last_seen_segments] @final + @staticmethod async def async_internal_clean_area( - self, cleaning_area_id: list[str], **kwargs: Any + entities: list[StateVacuumEntity], call: ServiceCall ) -> None: """Perform an area clean. - Calls async_clean_segments. + Calls async_clean_segments for each entity. """ - if self.registry_entry is None: - raise RuntimeError( - "Cannot perform area clean, registry entry is not set for" - f" {self.entity_id}" + data = dict(call.data) + cleaning_area_id: list[str] = data.pop("cleaning_area_id") + + entity_data: list[tuple[StateVacuumEntity, dict[str, Any]]] = [] + handled_areas: set[str] = set() + for entity in entities: + if entity.registry_entry is None: + raise RuntimeError( + "Cannot perform area clean, registry entry is not set for" + f" {entity.entity_id}" + ) + + options: Mapping[str, Any] = entity.registry_entry.options.get(DOMAIN, {}) + area_mapping: dict[str, list[str]] | None = options.get("area_mapping") + + if area_mapping is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="area_mapping_not_configured", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # We use a dict to preserve the order of segments. + segment_ids: dict[str, None] = {} + for area_id in cleaning_area_id: + if (segments := area_mapping.get(area_id)) is None: + continue + handled_areas.add(area_id) + for segment_id in segments: + segment_ids[segment_id] = None + + if not segment_ids: + _LOGGER.debug( + "No segments found for cleaning_area_id %s on vacuum %s", + cleaning_area_id, + entity.entity_id, + ) + continue + + entity_data.append((entity, {"segment_ids": list(segment_ids), **data})) + + if entity_data: + await service_helper.async_handle_entity_calls( + "async_clean_segments", entity_data, context=call.context ) - options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) - area_mapping: dict[str, list[str]] | None = options.get("area_mapping") - - if area_mapping is None: + unhandled_areas = set(cleaning_area_id) - handled_areas + if unhandled_areas: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="area_mapping_not_configured", - translation_placeholders={"entity_id": self.entity_id}, + translation_key="areas_not_mapped", + translation_placeholders={"areas": ", ".join(sorted(unhandled_areas))}, ) - # We use a dict to preserve the order of segments. - segment_ids: dict[str, None] = {} - for area_id in cleaning_area_id: - for segment_id in area_mapping.get(area_id, []): - segment_ids[segment_id] = None - - if not segment_ids: - _LOGGER.debug( - "No segments found for cleaning_area_id %s on vacuum %s", - cleaning_area_id, - self.entity_id, - ) - return - - await self.async_clean_segments(list(segment_ids), **kwargs) - def clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Perform an area clean.""" raise NotImplementedError diff --git a/homeassistant/components/vacuum/conditions.yaml b/homeassistant/components/vacuum/conditions.yaml index 17932be49bf..a3ccd4a68a9 100644 --- a/homeassistant/components/vacuum/conditions.yaml +++ b/homeassistant/components/vacuum/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_cleaning: *condition_common is_docked: *condition_common diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index 919eb1df566..c8b20a00ae3 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -1,7 +1,5 @@ """Support for vacuum cleaner robots (botvacs).""" -from __future__ import annotations - from enum import IntFlag, StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 0ae03d9219e..62a757afdfb 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Vacuum.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index 4da64484bf7..d4c251cecaa 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -1,7 +1,5 @@ """Provide the device automations for Vacuum.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index fe682ef21d3..25616430bcc 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Vacuum.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py index ef3fb329686..779cc698774 100644 --- a/homeassistant/components/vacuum/reproduce_state.py +++ b/homeassistant/components/vacuum/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Vacuum state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py index 857e6e822c5..c57711c042d 100644 --- a/homeassistant/components/vacuum/significant_change.py +++ b/homeassistant/components/vacuum/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Vacuum state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 364a4bfef0e..95267e6a1e2 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_cleaning": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is cleaning" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is docked" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is encountering an error" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is paused" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is returning" @@ -85,29 +102,16 @@ "exceptions": { "area_mapping_not_configured": { "message": "Area mapping is not configured for `{entity_id}`. Configure the segment-to-area mapping before using this action." + }, + "areas_not_mapped": { + "message": "The following areas are not mapped to any segments of targeted vacuums: {areas}" } }, "issues": { "segments_changed": { - "description": "", "title": "Vacuum segments have changed for {entity_id}" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "clean_area": { "description": "Tells a vacuum cleaner to clean one or more areas.", @@ -191,6 +195,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum returned to dock" @@ -200,6 +207,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum encountered an error" @@ -209,6 +219,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner paused cleaning" @@ -218,6 +231,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner started cleaning" @@ -227,6 +243,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner started returning to dock" diff --git a/homeassistant/components/vacuum/triggers.yaml b/homeassistant/components/vacuum/triggers.yaml index e0266db92bc..0f5d75715d6 100644 --- a/homeassistant/components/vacuum/triggers.yaml +++ b/homeassistant/components/vacuum/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: docked: *trigger_common errored: *trigger_common diff --git a/homeassistant/components/vacuum/websocket.py b/homeassistant/components/vacuum/websocket.py index 7be4187bc13..16c540f26c4 100644 --- a/homeassistant/components/vacuum/websocket.py +++ b/homeassistant/components/vacuum/websocket.py @@ -1,7 +1,5 @@ """Websocket commands for the Vacuum integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 785ecd09fb1..2768a3cc3ff 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,30 +1,18 @@ """Support for Vallox ventilation units.""" -from __future__ import annotations - import ipaddress -import logging -from typing import NamedTuple -from vallox_websocket_api import Profile, Vallox, ValloxApiException +from vallox_websocket_api import Vallox import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import ( - DEFAULT_FAN_SPEED_AWAY, - DEFAULT_FAN_SPEED_BOOST, - DEFAULT_FAN_SPEED_HOME, - DEFAULT_NAME, - DOMAIN, - I18N_KEY_TO_VALLOX_PROFILE, -) -from .coordinator import ValloxDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator +from .services import async_setup_services CONFIG_SCHEMA = vol.Schema( vol.All( @@ -50,64 +38,16 @@ PLATFORMS: list[str] = [ Platform.SWITCH, ] -ATTR_PROFILE_FAN_SPEED = "fan_speed" -SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema( - { - vol.Required(ATTR_PROFILE_FAN_SPEED): vol.All( - vol.Coerce(int), vol.Clamp(min=0, max=100) - ) - } -) - -ATTR_PROFILE = "profile" -ATTR_DURATION = "duration" - -SERVICE_SCHEMA_SET_PROFILE = vol.Schema( - { - vol.Required(ATTR_PROFILE): vol.In(I18N_KEY_TO_VALLOX_PROFILE), - vol.Optional(ATTR_DURATION): vol.All( - vol.Coerce(int), vol.Clamp(min=1, max=65535) - ), - } -) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Vallox integration.""" + async_setup_services(hass) + return True -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -SERVICE_SET_PROFILE_FAN_SPEED_HOME = "set_profile_fan_speed_home" -SERVICE_SET_PROFILE_FAN_SPEED_AWAY = "set_profile_fan_speed_away" -SERVICE_SET_PROFILE_FAN_SPEED_BOOST = "set_profile_fan_speed_boost" -SERVICE_SET_PROFILE = "set_profile" - -SERVICE_TO_METHOD = { - SERVICE_SET_PROFILE_FAN_SPEED_HOME: ServiceMethodDetails( - method="async_set_profile_fan_speed_home", - schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, - ), - SERVICE_SET_PROFILE_FAN_SPEED_AWAY: ServiceMethodDetails( - method="async_set_profile_fan_speed_away", - schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, - ), - SERVICE_SET_PROFILE_FAN_SPEED_BOOST: ServiceMethodDetails( - method="async_set_profile_fan_speed_boost", - schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, - ), - SERVICE_SET_PROFILE: ServiceMethodDetails( - method="async_set_profile", schema=SERVICE_SCHEMA_SET_PROFILE - ), -} - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ValloxConfigEntry) -> bool: """Set up the client and boot the platforms.""" host = entry.data[CONF_HOST] - name = entry.data[CONF_NAME] client = Vallox(host) @@ -115,120 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - service_handler = ValloxServiceHandler(client, coordinator) - for vallox_service, service_details in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, - vallox_service, - service_handler.async_handle, - schema=service_details.schema, - ) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "client": client, - "coordinator": coordinator, - "name": name, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ValloxConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - if hass.data[DOMAIN]: - return unload_ok - - for service in SERVICE_TO_METHOD: - hass.services.async_remove(DOMAIN, service) - - return unload_ok - - -class ValloxServiceHandler: - """Services implementation.""" - - def __init__( - self, client: Vallox, coordinator: ValloxDataUpdateCoordinator - ) -> None: - """Initialize the proxy.""" - self._client = client - self._coordinator = coordinator - - async def async_set_profile_fan_speed_home( - self, fan_speed: int = DEFAULT_FAN_SPEED_HOME - ) -> bool: - """Set the fan speed in percent for the Home profile.""" - _LOGGER.debug("Setting Home fan speed to: %d%%", fan_speed) - - try: - await self._client.set_fan_speed(Profile.HOME, fan_speed) - except ValloxApiException as err: - _LOGGER.error("Error setting fan speed for Home profile: %s", err) - return False - return True - - async def async_set_profile_fan_speed_away( - self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY - ) -> bool: - """Set the fan speed in percent for the Away profile.""" - _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed) - - try: - await self._client.set_fan_speed(Profile.AWAY, fan_speed) - except ValloxApiException as err: - _LOGGER.error("Error setting fan speed for Away profile: %s", err) - return False - return True - - async def async_set_profile_fan_speed_boost( - self, fan_speed: int = DEFAULT_FAN_SPEED_BOOST - ) -> bool: - """Set the fan speed in percent for the Boost profile.""" - _LOGGER.debug("Setting Boost fan speed to: %d%%", fan_speed) - - try: - await self._client.set_fan_speed(Profile.BOOST, fan_speed) - except ValloxApiException as err: - _LOGGER.error("Error setting fan speed for Boost profile: %s", err) - return False - return True - - async def async_set_profile( - self, profile: str, duration: int | None = None - ) -> bool: - """Activate profile for given duration.""" - _LOGGER.debug("Activating profile %s for %s min", profile, duration) - try: - await self._client.set_profile( - I18N_KEY_TO_VALLOX_PROFILE[profile], duration - ) - except ValloxApiException as err: - _LOGGER.error( - "Error setting profile %d for duration %s: %s", profile, duration, err - ) - return False - return True - - async def async_handle(self, call: ServiceCall) -> None: - """Dispatch a service call.""" - service_details = SERVICE_TO_METHOD.get(call.service) - params = call.data.copy() - - if service_details is None: - return - - if not hasattr(self, service_details.method): - _LOGGER.error("Service not implemented: %s", service_details.method) - return - - result = await getattr(self, service_details.method)(**params) - - # This state change affects other entities like sensors. Force an immediate update that can - # be observed by all parties involved. - if result: - await self._coordinator.async_request_refresh() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index a205dd2039e..dcc737a915b 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -1,20 +1,16 @@ """Support for Vallox ventilation unit binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -61,14 +57,11 @@ BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" - - data = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - ValloxBinarySensorEntity(data["name"], data["coordinator"], description) + ValloxBinarySensorEntity(entry.data[CONF_NAME], entry.runtime_data, description) for description in BINARY_SENSOR_ENTITIES ) diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index c7e6af8891a..d0918846fff 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Vallox integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index 418f57a22c8..6c7c3154dad 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -48,3 +48,7 @@ VALLOX_CELL_STATE_TO_STR = { 2: "Bypass", 3: "Defrosting", } + +# The vallox_websocket_api client uses a hardcoded value of 65535 to +# represent an indefinite duration. +PROFILE_DURATION_INDEFINITE = 65535 diff --git a/homeassistant/components/vallox/coordinator.py b/homeassistant/components/vallox/coordinator.py index 2fe7fa533db..9303a914935 100644 --- a/homeassistant/components/vallox/coordinator.py +++ b/homeassistant/components/vallox/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Vallox ventilation units.""" -from __future__ import annotations - import logging from vallox_websocket_api import MetricData, Vallox, ValloxApiException @@ -15,16 +13,18 @@ from .const import STATE_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type ValloxConfigEntry = ConfigEntry[ValloxDataUpdateCoordinator] + class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): """The DataUpdateCoordinator for Vallox.""" - config_entry: ConfigEntry + config_entry: ValloxConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ValloxConfigEntry, client: Vallox, ) -> None: """Initialize Vallox data coordinator.""" diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py index da2906c02c2..e35f6fab4c8 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -1,19 +1,13 @@ """Support for Vallox date platform.""" -from __future__ import annotations - from datetime import date -from vallox_websocket_api import Vallox - from homeassistant.components.date import DateEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -27,13 +21,11 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): self, name: str, coordinator: ValloxDataUpdateCoordinator, - client: Vallox, ) -> None: """Initialize the Vallox date.""" super().__init__(name, coordinator) self._attr_unique_id = f"{self._device_uuid}-filter_change_date" - self._client = client @property def native_value(self) -> date | None: @@ -44,23 +36,18 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): async def async_set_value(self, value: date) -> None: """Change the date.""" - await self._client.set_filter_change_date(value) + await self.coordinator.client.set_filter_change_date(value) await self.coordinator.async_request_refresh() async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vallox filter change date entity.""" - - data = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - [ - ValloxFilterChangeDateEntity( - data["name"], data["coordinator"], data["client"] - ) - ] + [ValloxFilterChangeDateEntity(entry.data[CONF_NAME], coordinator)] ) diff --git a/homeassistant/components/vallox/entity.py b/homeassistant/components/vallox/entity.py index b0657c561a8..6a6938ccc25 100644 --- a/homeassistant/components/vallox/entity.py +++ b/homeassistant/components/vallox/entity.py @@ -1,7 +1,5 @@ """Support for Vallox ventilation units.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 8519b4cb913..326c108c8b9 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -1,21 +1,18 @@ """Support for the Vallox ventilation unit fan.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, NamedTuple -from vallox_websocket_api import Vallox, ValloxApiException, ValloxInvalidInputException +from vallox_websocket_api import ValloxApiException, ValloxInvalidInputException from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( - DOMAIN, METRIC_KEY_MODE, METRIC_KEY_PROFILE_FAN_SPEED_AWAY, METRIC_KEY_PROFILE_FAN_SPEED_BOOST, @@ -25,7 +22,7 @@ from .const import ( PRESET_MODE_TO_VALLOX_PROFILE, VALLOX_PROFILE_TO_PRESET_MODE, ) -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -58,19 +55,13 @@ def _convert_to_int(value: StateType) -> int | None: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan device.""" - data = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data - client = data["client"] - - device = ValloxFanEntity( - data["name"], - client, - data["coordinator"], - ) + device = ValloxFanEntity(entry.data[CONF_NAME], coordinator) async_add_entities([device]) @@ -89,14 +80,11 @@ class ValloxFanEntity(ValloxEntity, FanEntity): def __init__( self, name: str, - client: Vallox, coordinator: ValloxDataUpdateCoordinator, ) -> None: """Initialize the fan.""" super().__init__(name, coordinator) - self._client = client - self._attr_unique_id = str(self._device_uuid) self._attr_preset_modes = list(PRESET_MODE_TO_VALLOX_PROFILE) @@ -136,8 +124,9 @@ class ValloxFanEntity(ValloxEntity, FanEntity): update_needed = await self._async_set_preset_mode_internal(preset_mode) if update_needed: - # This state change affects other entities like sensors. Force an immediate update that - # can be observed by all parties involved. + # This state change affects other entities like + # sensors. Force an immediate update that can be + # observed by all parties involved. await self.coordinator.async_request_refresh() async def async_turn_on( @@ -161,8 +150,9 @@ class ValloxFanEntity(ValloxEntity, FanEntity): ) if update_needed: - # This state change affects other entities like sensors. Force an immediate update that - # can be observed by all parties involved. + # This state change affects other entities like + # sensors. Force an immediate update that can be + # observed by all parties involved. await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: @@ -188,7 +178,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): async def _async_set_power(self, mode: bool) -> bool: try: - await self._client.set_values( + await self.coordinator.client.set_values( {METRIC_KEY_MODE: MODE_ON if mode else MODE_OFF} ) except ValloxApiException as err: @@ -206,7 +196,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): try: profile = PRESET_MODE_TO_VALLOX_PROFILE[preset_mode] - await self._client.set_profile(profile) + await self.coordinator.client.set_profile(profile) except ValloxApiException as err: raise HomeAssistantError(f"Failed to set profile: {preset_mode}") from err @@ -227,7 +217,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): ) try: - await self._client.set_fan_speed(vallox_profile, percentage) + await self.coordinator.client.set_fan_speed(vallox_profile, percentage) except ValloxInvalidInputException as err: # This can happen if current profile does not support setting the fan speed. raise ValueError( diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index ce3b9c72a6d..9e83ab5a7d8 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -1,23 +1,17 @@ """Support for Vallox ventilation unit numbers.""" -from __future__ import annotations - from dataclasses import dataclass -from vallox_websocket_api import Vallox - from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import CONF_NAME, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -32,7 +26,6 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): name: str, coordinator: ValloxDataUpdateCoordinator, description: ValloxNumberEntityDescription, - client: Vallox, ) -> None: """Initialize the Vallox number entity.""" super().__init__(name, coordinator) @@ -40,7 +33,6 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): self.entity_description = description self._attr_unique_id = f"{self._device_uuid}-{description.key}" - self._client = client @property def native_value(self) -> float | None: @@ -54,7 +46,7 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self._client.set_values( + await self.coordinator.client.set_values( {self.entity_description.metric_key: float(value)} ) await self.coordinator.async_request_refresh() @@ -103,15 +95,13 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" - data = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - ValloxNumberEntity( - data["name"], data["coordinator"], description, data["client"] - ) + ValloxNumberEntity(entry.data[CONF_NAME], coordinator, description) for description in NUMBER_ENTITIES ) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index e9194a8254c..a4175c4f37f 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -1,7 +1,5 @@ """Support for Vallox ventilation unit sensors.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, time @@ -11,9 +9,9 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, EntityCategory, @@ -26,13 +24,12 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( - DOMAIN, METRIC_KEY_MODE, MODE_ON, VALLOX_CELL_STATE_TO_STR, VALLOX_PROFILE_TO_PRESET_MODE, ) -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -81,11 +78,14 @@ class ValloxProfileSensor(ValloxSensorEntity): return VALLOX_PROFILE_TO_PRESET_MODE.get(vallox_profile) -# There is a quirk with respect to the fan speed reporting. The device keeps on reporting the last -# valid fan speed from when the device was in regular operation mode, even if it left that state and -# has been shut off in the meantime. +# There is a quirk with respect to the fan speed +# reporting. The device keeps on reporting the last valid +# fan speed from when the device was in regular operation +# mode, even if it left that state and has been shut off +# in the meantime. # -# Therefore, first query the overall state of the device, and report zero percent fan speed in case +# Therefore, first query the overall state of the device, +# and report zero percent fan speed in case # it is not in regular operation mode. class ValloxFanSpeedSensor(ValloxSensorEntity): """Child class for fan speed reporting.""" @@ -279,12 +279,12 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" - name = hass.data[DOMAIN][entry.entry_id]["name"] - coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + name = entry.data[CONF_NAME] + coordinator = entry.runtime_data async_add_entities( description.entity_type(name, coordinator, description) diff --git a/homeassistant/components/vallox/services.py b/homeassistant/components/vallox/services.py new file mode 100644 index 00000000000..f92956a4d26 --- /dev/null +++ b/homeassistant/components/vallox/services.py @@ -0,0 +1,149 @@ +"""Services for the Vallox integration.""" + +from enum import StrEnum, auto +import logging + +from vallox_websocket_api import Profile, ValloxApiException +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, I18N_KEY_TO_VALLOX_PROFILE, PROFILE_DURATION_INDEFINITE +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_PROFILE_FAN_SPEED = "fan_speed" +ATTR_PROFILE = "profile" +ATTR_DURATION = "duration" + + +class ValloxService(StrEnum): + """Vallox service names.""" + + SET_PROFILE_FAN_SPEED_HOME = auto() + SET_PROFILE_FAN_SPEED_AWAY = auto() + SET_PROFILE_FAN_SPEED_BOOST = auto() + SET_PROFILE = auto() + + +SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema( + { + vol.Required(ATTR_PROFILE_FAN_SPEED): vol.All( + vol.Coerce(int), vol.Clamp(min=0, max=100) + ) + } +) + +SERVICE_SCHEMA_SET_PROFILE = vol.Schema( + { + vol.Required(ATTR_PROFILE): vol.In(I18N_KEY_TO_VALLOX_PROFILE), + vol.Optional(ATTR_DURATION): vol.All( + vol.Coerce(int), vol.Clamp(min=1, max=PROFILE_DURATION_INDEFINITE) + ), + } +) + + +def _get_coordinator( + hass: HomeAssistant, +) -> ValloxDataUpdateCoordinator: + """Return the coordinator for the Vallox config entry.""" + entries: list[ValloxConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if len(entries) != 1: + raise ValueError("Expected exactly one loaded Vallox config entry") + + return entries[0].runtime_data + + +async def _async_set_profile_fan_speed(call: ServiceCall, profile: Profile) -> None: + """Set the fan speed in percent for the profile matching the called service.""" + fan_speed: int = call.data[ATTR_PROFILE_FAN_SPEED] + _LOGGER.debug("Setting %s fan speed to: %d%%", profile.name, fan_speed) + + coordinator = _get_coordinator(call.hass) + try: + await coordinator.client.set_fan_speed(profile, fan_speed) + except ValloxApiException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_fan_speed_for_profile", + translation_placeholders={ + "profile": profile.name.lower(), + "fan_speed": str(fan_speed), + }, + ) from err + else: + await coordinator.async_request_refresh() + + +async def _async_set_profile_fan_speed_away(call: ServiceCall) -> None: + """Set the fan speed in percent for the Away profile.""" + await _async_set_profile_fan_speed(call, Profile.AWAY) + + +async def _async_set_profile_fan_speed_boost(call: ServiceCall) -> None: + """Set the fan speed in percent for the Boost profile.""" + await _async_set_profile_fan_speed(call, Profile.BOOST) + + +async def _async_set_profile_fan_speed_home(call: ServiceCall) -> None: + """Set the fan speed in percent for the Home profile.""" + await _async_set_profile_fan_speed(call, Profile.HOME) + + +async def _async_set_profile(call: ServiceCall) -> None: + """Activate the given profile for the given duration.""" + profile_key: str = call.data[ATTR_PROFILE] + duration: int | None = call.data.get(ATTR_DURATION) + _LOGGER.debug("Activating profile %s for %s min", profile_key, duration) + + coordinator = _get_coordinator(call.hass) + try: + await coordinator.client.set_profile( + I18N_KEY_TO_VALLOX_PROFILE[profile_key], duration + ) + except ValloxApiException as err: + placeholders = {"profile": profile_key} + if duration is not None and duration != PROFILE_DURATION_INDEFINITE: + placeholders["duration"] = str(duration) + translation_key = "failed_to_set_profile_for_duration" + else: + translation_key = "failed_to_set_profile" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) from err + else: + await coordinator.async_request_refresh() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Vallox services.""" + hass.services.async_register( + DOMAIN, + ValloxService.SET_PROFILE_FAN_SPEED_AWAY, + _async_set_profile_fan_speed_away, + schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, + ) + hass.services.async_register( + DOMAIN, + ValloxService.SET_PROFILE_FAN_SPEED_BOOST, + _async_set_profile_fan_speed_boost, + schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, + ) + hass.services.async_register( + DOMAIN, + ValloxService.SET_PROFILE_FAN_SPEED_HOME, + _async_set_profile_fan_speed_home, + schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, + ) + hass.services.async_register( + DOMAIN, + ValloxService.SET_PROFILE, + _async_set_profile, + schema=SERVICE_SCHEMA_SET_PROFILE, + ) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index ab459a1c29c..0b65834a3dd 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -103,6 +103,17 @@ } } }, + "exceptions": { + "failed_to_set_fan_speed_for_profile": { + "message": "Failed to set fan speed to {fan_speed}% for profile {profile}" + }, + "failed_to_set_profile": { + "message": "Failed to set profile to {profile}" + }, + "failed_to_set_profile_for_duration": { + "message": "Failed to set profile to {profile} for {duration} minutes" + } + }, "selector": { "profile": { "options": { diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 9386f914f58..1263aa309e5 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -1,20 +1,14 @@ """Support for Vallox ventilation unit switches.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any -from vallox_websocket_api import Vallox - from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ValloxDataUpdateCoordinator +from .coordinator import ValloxConfigEntry, ValloxDataUpdateCoordinator from .entity import ValloxEntity @@ -29,7 +23,6 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): name: str, coordinator: ValloxDataUpdateCoordinator, description: ValloxSwitchEntityDescription, - client: Vallox, ) -> None: """Initialize the Vallox switch.""" super().__init__(name, coordinator) @@ -37,7 +30,6 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): self.entity_description = description self._attr_unique_id = f"{self._device_uuid}-{description.key}" - self._client = client @property def is_on(self) -> bool | None: @@ -59,7 +51,7 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): async def _set_value(self, value: bool) -> None: """Update the current value.""" metric_key = self.entity_description.metric_key - await self._client.set_values({metric_key: 1 if value else 0}) + await self.coordinator.client.set_values({metric_key: 1 if value else 0}) await self.coordinator.async_request_refresh() @@ -81,16 +73,13 @@ SWITCH_ENTITIES: tuple[ValloxSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ValloxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches.""" - - data = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - ValloxSwitchEntity( - data["name"], data["coordinator"], description, data["client"] - ) + ValloxSwitchEntity(entry.data[CONF_NAME], coordinator, description) for description in SWITCH_ENTITIES ) diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index aa25491a89b..f13fd9e3a94 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -1,12 +1,7 @@ """Support for Valve devices.""" -from __future__ import annotations - -from dataclasses import dataclass from datetime import timedelta -from enum import IntFlag, StrEnum import logging -from typing import Any, final import voluptuous as vol @@ -24,12 +19,22 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, ValveState +from .const import ( # noqa: F401 + DOMAIN, + ValveDeviceClass, + ValveEntityFeature, + ValveState, +) +from .entity import ( # noqa: F401 + ATTR_CURRENT_POSITION, + ATTR_IS_CLOSED, + ValveEntity, + ValveEntityDescription, +) _LOGGER = logging.getLogger(__name__) @@ -40,29 +45,9 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=15) -class ValveDeviceClass(StrEnum): - """Device class for valve.""" - - # Refer to the valve dev docs for device class descriptions - WATER = "water" - GAS = "gas" - - DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ValveDeviceClass)) -# mypy: disallow-any-generics -class ValveEntityFeature(IntFlag): - """Supported features of the valve entity.""" - - OPEN = 1 - CLOSE = 2 - SET_POSITION = 4 - STOP = 8 - - -ATTR_CURRENT_POSITION = "current_position" -ATTR_IS_CLOSED = "is_closed" ATTR_POSITION = "position" @@ -118,169 +103,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.data[DATA_COMPONENT].async_unload_entry(entry) - - -@dataclass(frozen=True, kw_only=True) -class ValveEntityDescription(EntityDescription): - """A class that describes valve entities.""" - - device_class: ValveDeviceClass | None = None - reports_position: bool = False - - -class ValveEntity(Entity): - """Base class for valve entities.""" - - entity_description: ValveEntityDescription - _attr_current_valve_position: int | None = None - _attr_device_class: ValveDeviceClass | None - _attr_is_closed: bool | None = None - _attr_is_closing: bool | None = None - _attr_is_opening: bool | None = None - _attr_reports_position: bool - _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) - - __is_last_toggle_direction_open = True - - @property - def reports_position(self) -> bool: - """Return True if entity reports position, False otherwise.""" - if hasattr(self, "_attr_reports_position"): - return self._attr_reports_position - if hasattr(self, "entity_description"): - return self.entity_description.reports_position - raise ValueError(f"'reports_position' not set for {self.entity_id}.") - - @property - def current_valve_position(self) -> int | None: - """Return current position of valve. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._attr_current_valve_position - - @property - def device_class(self) -> ValveDeviceClass | None: - """Return the class of this entity.""" - if hasattr(self, "_attr_device_class"): - return self._attr_device_class - if hasattr(self, "entity_description"): - return self.entity_description.device_class - return None - - @property - @final - def state(self) -> str | None: - """Return the state of the valve.""" - reports_position = self.reports_position - if self.is_opening: - self.__is_last_toggle_direction_open = True - return ValveState.OPENING - if self.is_closing: - self.__is_last_toggle_direction_open = False - return ValveState.CLOSING - if reports_position is True: - if (current_valve_position := self.current_valve_position) is None: - return None - position_zero = current_valve_position == 0 - return ValveState.CLOSED if position_zero else ValveState.OPEN - if (closed := self.is_closed) is None: - return None - return ValveState.CLOSED if closed else ValveState.OPEN - - @final - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - data: dict[str, Any] = {} - - if self.reports_position: - if (current_valve_position := self.current_valve_position) is None: - data[ATTR_IS_CLOSED] = None - else: - data[ATTR_IS_CLOSED] = current_valve_position == 0 - data[ATTR_CURRENT_POSITION] = current_valve_position - else: - data[ATTR_IS_CLOSED] = self.is_closed - - return data - - @property - def supported_features(self) -> ValveEntityFeature: - """Flag supported features.""" - return self._attr_supported_features - - @property - def is_opening(self) -> bool | None: - """Return if the valve is opening or not.""" - return self._attr_is_opening - - @property - def is_closing(self) -> bool | None: - """Return if the valve is closing or not.""" - return self._attr_is_closing - - @property - def is_closed(self) -> bool | None: - """Return if the valve is closed or not.""" - return self._attr_is_closed - - def open_valve(self) -> None: - """Open the valve.""" - raise NotImplementedError - - async def async_open_valve(self) -> None: - """Open the valve.""" - await self.hass.async_add_executor_job(self.open_valve) - - @final - async def async_handle_open_valve(self) -> None: - """Open the valve.""" - if self.supported_features & ValveEntityFeature.SET_POSITION: - await self.async_set_valve_position(100) - return - await self.async_open_valve() - - def close_valve(self) -> None: - """Close valve.""" - raise NotImplementedError - - async def async_close_valve(self) -> None: - """Close valve.""" - await self.hass.async_add_executor_job(self.close_valve) - - @final - async def async_handle_close_valve(self) -> None: - """Close the valve.""" - if self.supported_features & ValveEntityFeature.SET_POSITION: - await self.async_set_valve_position(0) - return - await self.async_close_valve() - - async def async_toggle(self) -> None: - """Toggle the entity.""" - if self.supported_features & ValveEntityFeature.STOP and ( - self.is_closing or self.is_opening - ): - return await self.async_stop_valve() - if self.is_closed: - return await self.async_handle_open_valve() - if self.__is_last_toggle_direction_open: - return await self.async_handle_close_valve() - return await self.async_handle_open_valve() - - def set_valve_position(self, position: int) -> None: - """Move the valve to a specific position.""" - raise NotImplementedError - - async def async_set_valve_position(self, position: int) -> None: - """Move the valve to a specific position.""" - await self.hass.async_add_executor_job(self.set_valve_position, position) - - def stop_valve(self) -> None: - """Stop the valve.""" - raise NotImplementedError - - async def async_stop_valve(self) -> None: - """Stop the valve.""" - await self.hass.async_add_executor_job(self.stop_valve) diff --git a/homeassistant/components/valve/conditions.yaml b/homeassistant/components/valve/conditions.yaml index b639ae832e7..eaf8a041cc2 100644 --- a/homeassistant/components/valve/conditions.yaml +++ b/homeassistant/components/valve/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_open: *condition_common is_closed: *condition_common diff --git a/homeassistant/components/valve/const.py b/homeassistant/components/valve/const.py index 5f590b5015a..88aceaa07e2 100644 --- a/homeassistant/components/valve/const.py +++ b/homeassistant/components/valve/const.py @@ -1,10 +1,27 @@ """Constants for the Valve entity platform.""" -from enum import StrEnum +from enum import IntFlag, StrEnum DOMAIN = "valve" +class ValveDeviceClass(StrEnum): + """Device class for valve.""" + + # Refer to the valve dev docs for device class descriptions + WATER = "water" + GAS = "gas" + + +class ValveEntityFeature(IntFlag): + """Supported features of the valve entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + + class ValveState(StrEnum): """State of Valve entities.""" diff --git a/homeassistant/components/valve/entity.py b/homeassistant/components/valve/entity.py new file mode 100644 index 00000000000..e076a204039 --- /dev/null +++ b/homeassistant/components/valve/entity.py @@ -0,0 +1,177 @@ +"""Base entity for the Valve platform.""" + +from dataclasses import dataclass +from typing import Any, final + +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import ValveDeviceClass, ValveEntityFeature, ValveState + +ATTR_CURRENT_POSITION = "current_position" +ATTR_IS_CLOSED = "is_closed" + + +@dataclass(frozen=True, kw_only=True) +class ValveEntityDescription(EntityDescription): + """A class that describes valve entities.""" + + device_class: ValveDeviceClass | None = None + reports_position: bool = False + + +class ValveEntity(Entity): + """Base class for valve entities.""" + + entity_description: ValveEntityDescription + _attr_current_valve_position: int | None = None + _attr_device_class: ValveDeviceClass | None + _attr_is_closed: bool | None = None + _attr_is_closing: bool | None = None + _attr_is_opening: bool | None = None + _attr_reports_position: bool + _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) + + __is_last_toggle_direction_open = True + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + if hasattr(self, "_attr_reports_position"): + return self._attr_reports_position + if hasattr(self, "entity_description"): + return self.entity_description.reports_position + raise ValueError(f"'reports_position' not set for {self.entity_id}.") + + @property + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._attr_current_valve_position + + @property + def device_class(self) -> ValveDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + @final + def state(self) -> str | None: + """Return the state of the valve.""" + reports_position = self.reports_position + if self.is_opening: + self.__is_last_toggle_direction_open = True + return ValveState.OPENING + if self.is_closing: + self.__is_last_toggle_direction_open = False + return ValveState.CLOSING + if reports_position is True: + if (current_valve_position := self.current_valve_position) is None: + return None + position_zero = current_valve_position == 0 + return ValveState.CLOSED if position_zero else ValveState.OPEN + if (closed := self.is_closed) is None: + return None + return ValveState.CLOSED if closed else ValveState.OPEN + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + data: dict[str, Any] = {} + + if self.reports_position: + if (current_valve_position := self.current_valve_position) is None: + data[ATTR_IS_CLOSED] = None + else: + data[ATTR_IS_CLOSED] = current_valve_position == 0 + data[ATTR_CURRENT_POSITION] = current_valve_position + else: + data[ATTR_IS_CLOSED] = self.is_closed + + return data + + @property + def supported_features(self) -> ValveEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._attr_is_opening + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._attr_is_closing + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed or not.""" + return self._attr_is_closed + + def open_valve(self) -> None: + """Open the valve.""" + raise NotImplementedError + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job(self.open_valve) + + @final + async def async_handle_open_valve(self) -> None: + """Open the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + await self.async_set_valve_position(100) + return + await self.async_open_valve() + + def close_valve(self) -> None: + """Close valve.""" + raise NotImplementedError + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.hass.async_add_executor_job(self.close_valve) + + @final + async def async_handle_close_valve(self) -> None: + """Close the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + await self.async_set_valve_position(0) + return + await self.async_close_valve() + + async def async_toggle(self) -> None: + """Toggle the entity.""" + if self.supported_features & ValveEntityFeature.STOP and ( + self.is_closing or self.is_opening + ): + return await self.async_stop_valve() + if self.is_closed: + return await self.async_handle_open_valve() + if self.__is_last_toggle_direction_open: + return await self.async_handle_close_valve() + return await self.async_handle_open_valve() + + def set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + raise NotImplementedError + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.hass.async_add_executor_job(self.set_valve_position, position) + + def stop_valve(self) -> None: + """Stop the valve.""" + raise NotImplementedError + + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.hass.async_add_executor_job(self.stop_valve) diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index c5bccd46b14..eae5903f1b1 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -11,7 +11,9 @@ "_": { "default": "mdi:valve-open", "state": { - "closed": "mdi:valve-closed" + "closed": "mdi:valve-closed", + "closing": "mdi:valve", + "opening": "mdi:valve" } }, "gas": { @@ -20,7 +22,9 @@ "water": { "default": "mdi:valve-open", "state": { - "closed": "mdi:valve-closed" + "closed": "mdi:valve-closed", + "closing": "mdi:valve", + "opening": "mdi:valve" } } }, diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 3775f38fb18..f433e87b02b 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is closed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is open" @@ -46,21 +54,6 @@ "name": "Water" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "close_valve": { "description": "Closes a valve.", @@ -96,6 +89,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::trigger_for_name%]" } }, "name": "Valve closed" @@ -105,6 +101,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::trigger_for_name%]" } }, "name": "Valve opened" diff --git a/homeassistant/components/valve/triggers.yaml b/homeassistant/components/valve/triggers.yaml index aaf09598d65..cbafc5af59d 100644 --- a/homeassistant/components/valve/triggers.yaml +++ b/homeassistant/components/valve/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: closed: *trigger_common opened: *trigger_common diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 7059eb2f438..e29698033cc 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,5 @@ """Support for Västtrafik public transport.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/vegehub/coordinator.py b/homeassistant/components/vegehub/coordinator.py index 43fb1c40274..b06d71a26c1 100644 --- a/homeassistant/components/vegehub/coordinator.py +++ b/homeassistant/components/vegehub/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Vegetronix VegeHub.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 6805e932768..03d8c4fc063 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -1,7 +1,5 @@ """Support for Velbus devices.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import logging @@ -143,7 +141,8 @@ async def async_migrate_entry( "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) - # This is the config entry migration for swapping the usb unique id to the serial number + # This is the config entry migration for swapping the + # usb unique id to the serial number # migrate from 2.1 to 2.2 if ( config_entry.version < 3 diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index 8f736dcd35b..dd8853ccd7d 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -1,7 +1,5 @@ """Support for Velbus Buttons.""" -from __future__ import annotations - from velbusaio.channels import ( Button as VelbusaioButton, ButtonCounter as VelbusaioButtonCounter, diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index 4eb9db94ec7..c6f3ce560dd 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -1,7 +1,5 @@ """Support for Velbus thermostat.""" -from __future__ import annotations - from typing import Any from velbusaio.channels import Temperature as VelbusTemp diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index e43ad364e84..deeff93eea5 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,17 +1,15 @@ """Config flow for the Velbus platform.""" -from __future__ import annotations - from pathlib import Path import shutil from typing import Any, Final -import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed from velbusaio.vlp_reader import VlpFile import voluptuous as vol +from homeassistant.components import usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ( SOURCE_RECONFIGURE, @@ -84,10 +82,28 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "": self._device += f"{user_input[CONF_PASSWORD]}@" self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - self._async_abort_entries_match({CONF_PORT: self._device}) + if self.source != SOURCE_RECONFIGURE: + self._async_abort_entries_match({CONF_PORT: self._device}) if await self._test_connection(): return await self.async_step_vlp() step_errors[CONF_HOST] = "cannot_connect" + elif self.source == SOURCE_RECONFIGURE: + current = self._get_reconfigure_entry().data.get(CONF_PORT, "") + tls = current.startswith("tls://") + current = current.removeprefix("tls://") + if "@" in current: + password, host_port = current.split("@", 1) + else: + password = "" + host_port = current + host, _, port = host_port.rpartition(":") + user_input = { + CONF_TLS: tls, + CONF_HOST: host, + CONF_PORT: int(port) if port.isdigit() else 27015, + } + if password: + user_input[CONF_PASSWORD] = password else: user_input = { CONF_TLS: True, @@ -115,9 +131,10 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle usb select step.""" step_errors: dict[str, str] = {} - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = [ - f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + f"{p.device} - {p.description or 'n/a'}" + f"{', s/n: ' + p.serial_number if p.serial_number else ''}" + (f" - {p.manufacturer}" if p.manufacturer else "") for p in ports ] @@ -198,7 +215,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): old_entry, data={ CONF_VLP_FILE: self._vlp_file, - CONF_PORT: old_entry.data.get(CONF_PORT), + CONF_PORT: self._device, }, ) if not step_errors: @@ -223,7 +240,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration.""" - return await self.async_step_vlp() + return await self.async_step_network() def save_uploaded_vlp_file(hass: HomeAssistant, uploaded_file_id: str) -> str: diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 995b7e9d59c..d6a55c3ff76 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -1,7 +1,5 @@ """Support for Velbus covers.""" -from __future__ import annotations - from typing import Any from velbusaio.channels import Blind as VelbusBlind diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index 5001ac80ab3..04f240e3cf3 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Velbus.""" -from __future__ import annotations - from typing import Any from velbusaio.channels import Channel as VelbusChannel diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index e259f99462e..7e5c9b7bbbc 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -1,7 +1,5 @@ """Support for Velbus devices.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 483aa37110b..14e49cf2120 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -1,7 +1,5 @@ """Support for Velbus light.""" -from __future__ import annotations - from typing import Any from velbusaio.channels import ( diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 237323dd481..b01c5bb48e1 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "silver", - "requirements": ["velbus-aio==2026.2.0"], + "requirements": ["velbus-aio==2026.4.1"], "usb": [ { "pid": "0B1B", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index d4592159d59..1ebaec8a1b1 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -55,9 +55,9 @@ rules: entity-device-class: todo entity-disabled-by-default: done entity-translations: todo - exception-translations: done + exception-translations: todo icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 229377355e4..4f291fe70a2 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -1,7 +1,5 @@ """Support for Velbus sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 3a4fce1071e..6d84a24f148 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -1,7 +1,5 @@ """Support for Velbus devices.""" -from __future__ import annotations - import os import shutil from typing import TYPE_CHECKING @@ -88,22 +86,24 @@ def async_setup_services(hass: HomeAssistant) -> None: entry: VelbusConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[CONF_CONFIG_ENTRY] ) - try: + + def _clear_cache() -> None: if call.data.get(CONF_ADDRESS): - await hass.async_add_executor_job( - os.unlink, - hass.config.path( - STORAGE_DIR, - f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", - ), + cache_path = hass.config.path( + STORAGE_DIR, + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", ) + if os.path.exists(cache_path): + os.unlink(cache_path) else: - await hass.async_add_executor_job( - shutil.rmtree, - hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), + cache_path = hass.config.path( + STORAGE_DIR, f"velbuscache-{entry.entry_id}/" ) - except FileNotFoundError: - pass # It's okay if the file doesn't exist + if os.path.isdir(cache_path): + shutil.rmtree(cache_path) + + try: + await hass.async_add_executor_job(_clear_cache) except OSError as exc: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 3d672a574d6..c82da383a7c 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,86 +1,40 @@ """Support for VELUX KLF 200 devices.""" -from __future__ import annotations - from pyvlx import PyVLX, PyVLXException -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr -from .const import DOMAIN, LOGGER, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS, PYVLX_FROM_CONFIG_FLOW type VeluxConfigEntry = ConfigEntry[PyVLX] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Velux component.""" - - async def async_reboot_gateway(service_call: ServiceCall) -> None: - """Reboot the gateway (deprecated - use button entity instead).""" - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_reboot_service", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_reboot_service", - breaks_in_ha_version="2026.6.0", - ) - - # Find a loaded config entry to get the PyVLX instance - # We assume only one gateway is set up or we just reboot the first one found - # (this is no change to the previous behavior, the alternative would be to reboot all) - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state is ConfigEntryState.LOADED: - try: - await entry.runtime_data.reboot_gateway() - except (OSError, PyVLXException) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="reboot_failed", - ) from err - else: - return - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_gateway_loaded", - ) - - hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool: """Set up the velux component.""" host = entry.data[CONF_HOST] password = entry.data[CONF_PASSWORD] - pyvlx = PyVLX(host=host, password=password) - LOGGER.debug("Setting up Velux gateway %s", host) + # Prefer the already-connected instance passed from the config flow so that + # we do not force a disconnect/reboot between connection validation and setup. + # Falls back to creating a fresh instance on HA restart or reload. + pyvlx: PyVLX | None = hass.data.get(PYVLX_FROM_CONFIG_FLOW, {}).pop(host, None) + if pyvlx is None: + pyvlx = PyVLX(host=host, password=password) + try: + LOGGER.debug("Ensuring connection to Velux gateway %s", host) + await pyvlx.ensure_connected() LOGGER.debug("Retrieving scenes from %s", host) await pyvlx.load_scenes() LOGGER.debug("Retrieving nodes from %s", host) @@ -89,7 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo # Since pyvlx raises the same exception for auth and connection errors, # we need to check the exception message to distinguish them. # Ultimately this should be fixed in pyvlx to raise specialized exceptions, - # right now it's been a while since the last pyvlx release, so we do this workaround here. + # right now it's been a while since the last pyvlx + # release, so we do this workaround here. if ( isinstance(ex, PyVLXException) and ex.description == "Login to KLF 200 failed, check credentials" @@ -145,7 +100,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bo """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): # Disconnect from gateway only after platforms are successfully unloaded. - # Disconnecting will reboot the gateway in the pyvlx library, which is needed to allow new + # Disconnecting will reboot the gateway in the pyvlx + # library, which is needed to allow new # connections to be made later. await entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 1b87633c9cd..d48cb055103 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -1,7 +1,5 @@ """Support for rain sensors built into some Velux windows.""" -from __future__ import annotations - from datetime import timedelta from pyvlx import OpeningDevice, Position, PyVLXException, Window @@ -40,7 +38,7 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity): """Representation of a Velux rain sensor.""" node: Window - _attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices + _attr_should_poll = True _attr_entity_registry_enabled_default = False _attr_device_class = BinarySensorDeviceClass.MOISTURE _attr_translation_key = "rain_sensor" @@ -73,8 +71,10 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity): self._attr_available = True - # Velux windows with rain sensors report an opening limitation when rain is detected. - # So far we've seen 89, 91, 93 (most cases) or 100 (Velux GPU). It probably makes sense to + # Velux windows with rain sensors report an opening + # limitation when rain is detected. So far we've + # seen 89, 91, 93 (most cases) or 100 (Velux GPU). + # It probably makes sense to # assume that any large enough limitation (we use >=89) means rain is detected. # Documentation on this is non-existent AFAIK. self._attr_is_on = limitation.position_percent >= 89 diff --git a/homeassistant/components/velux/button.py b/homeassistant/components/velux/button.py index da7ff89435f..6e7cd7e9a5b 100644 --- a/homeassistant/components/velux/button.py +++ b/homeassistant/components/velux/button.py @@ -1,7 +1,5 @@ """Support for VELUX KLF 200 gateway button.""" -from __future__ import annotations - from pyvlx import Node, PyVLX, PyVLXException from homeassistant.components.button import ButtonDeviceClass, ButtonEntity diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index eb6805fc65e..b2de1d1957b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, PYVLX_FROM_CONFIG_FLOW USER_SCHEMA = vol.Schema( { @@ -22,12 +22,18 @@ USER_SCHEMA = vol.Schema( ) -async def _check_connection(host: str, password: str) -> dict[str, Any]: - """Check if we can connect to the Velux bridge.""" +async def _check_connection( + host: str, password: str +) -> tuple[PyVLX | None, dict[str, str]]: + """Connect to the Velux bridge and return the live instance. + + The caller is responsible for storing the returned instance so that + async_setup_entry can reuse it, avoiding a disconnect/reboot cycle. + Returns (None, errors) on failure, (pyvlx, {}) on success. + """ pyvlx = PyVLX(host=host, password=password) try: await pyvlx.connect() - await pyvlx.disconnect() except (PyVLXException, ConnectionError) as err: # since pyvlx raises the same exception for auth and connection errors, # we need to check the exception message to distinguish them @@ -36,15 +42,15 @@ async def _check_connection(host: str, password: str) -> dict[str, Any]: and err.description == "Login to KLF 200 failed, check credentials" ): LOGGER.debug("Invalid password") - return {"base": "invalid_auth"} + return None, {"base": "invalid_auth"} LOGGER.debug("Cannot connect: %s", err) - return {"base": "cannot_connect"} + return None, {"base": "cannot_connect"} except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception: %s", err) - return {"base": "unknown"} + return None, {"base": "unknown"} - return {} + return pyvlx, {} class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): @@ -64,12 +70,15 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - errors = await _check_connection( - user_input[CONF_HOST], user_input[CONF_PASSWORD] - ) + host = user_input[CONF_HOST] + pyvlx, errors = await _check_connection(host, user_input[CONF_PASSWORD]) if not errors: + assert pyvlx is not None + # Keep the live connection so async_setup_entry can reuse it + # without triggering a disconnect/reboot cycle. + self.hass.data.setdefault(PYVLX_FROM_CONFIG_FLOW, {})[host] = pyvlx return self.async_create_entry( - title=user_input[CONF_HOST], + title=host, data=user_input, ) @@ -93,10 +102,13 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - errors = await _check_connection( - reauth_entry.data[CONF_HOST], user_input[CONF_PASSWORD] - ) + host = reauth_entry.data[CONF_HOST] + pyvlx, errors = await _check_connection(host, user_input[CONF_PASSWORD]) if not errors: + assert pyvlx is not None + # Keep the live connection so async_setup_entry can reuse it + # without triggering a disconnect/reboot cycle. + self.hass.data.setdefault(PYVLX_FROM_CONFIG_FLOW, {})[host] = pyvlx return self.async_update_reload_and_abort( reauth_entry, data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, @@ -153,10 +165,13 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): """Prepare configuration for a discovered Velux device.""" errors: dict[str, str] = {} if user_input is not None: - errors = await _check_connection( - self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD] - ) + host = self.discovery_data[CONF_HOST] + pyvlx, errors = await _check_connection(host, user_input[CONF_PASSWORD]) if not errors: + assert pyvlx is not None + # Keep the live connection so async_setup_entry can reuse it + # without triggering a disconnect/reboot cycle. + self.hass.data.setdefault(PYVLX_FROM_CONFIG_FLOW, {})[host] = pyvlx return self.async_create_entry( title=self.discovery_data[CONF_NAME], data={**self.discovery_data, **user_input}, diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index 9e008a59a59..8e3d962010a 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -2,7 +2,10 @@ from logging import getLogger +from pyvlx import PyVLX + from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "velux" PLATFORMS = [ @@ -15,3 +18,7 @@ PLATFORMS = [ Platform.SWITCH, ] LOGGER = getLogger(__package__) + +PYVLX_FROM_CONFIG_FLOW: HassKey[dict[str, PyVLX]] = HassKey( + "velux_pyvlx_from_config_flow" +) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea..fc737074443 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,7 +1,5 @@ """Support for Velux covers.""" -from __future__ import annotations - from enum import StrEnum from typing import Any @@ -97,13 +95,17 @@ class VeluxCover(VeluxEntity, CoverEntity): self._attr_device_class = CoverDeviceClass.SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" + if not self.node.position.known: + return None return 100 - self.node.position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if not self.node.position.known: + return None return self.node.position.closed @property @@ -168,22 +170,29 @@ class VeluxDualRollerShutter(VeluxCover): self.part = part @property - def current_cover_position(self) -> int: - """Return the current position of the cover.""" + def _part_position(self) -> Position: + """Return the pyvlx Position for this part of the shutter.""" if self.part == VeluxDualRollerPart.UPPER: - return 100 - self.node.position_upper_curtain.position_percent + return self.node.position_upper_curtain if self.part == VeluxDualRollerPart.LOWER: - return 100 - self.node.position_lower_curtain.position_percent - return 100 - self.node.position.position_percent + return self.node.position_lower_curtain + return self.node.position @property - def is_closed(self) -> bool: + def current_cover_position(self) -> int | None: + """Return the current position of the cover.""" + position = self._part_position + if not position.known: + return None + return 100 - position.position_percent + + @property + def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self.part == VeluxDualRollerPart.UPPER: - return self.node.position_upper_curtain.closed - if self.part == VeluxDualRollerPart.LOWER: - return self.node.position_lower_curtain.closed - return self.node.position.closed + position = self._part_position + if not position.known: + return None + return position.closed @wrap_pyvlx_call_exceptions async def async_close_cover(self, **kwargs: Any) -> None: @@ -227,6 +236,8 @@ class VeluxBlind(VeluxCover): @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" + if not self.node.orientation.known: + return None return 100 - self.node.orientation.position_percent @wrap_pyvlx_call_exceptions diff --git a/homeassistant/components/velux/diagnostics.py b/homeassistant/components/velux/diagnostics.py index 8422a4996a8..8107a2a6887 100644 --- a/homeassistant/components/velux/diagnostics.py +++ b/homeassistant/components/velux/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Velux.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index a43eba6cb7b..7ed7d54e0cf 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps import logging -from typing import Any, ParamSpec +from typing import Any from pyvlx import Node, PyVLXException @@ -15,10 +15,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -P = ParamSpec("P") - -def wrap_pyvlx_call_exceptions( +def wrap_pyvlx_call_exceptions[**P]( func: Callable[P, Coroutine[Any, Any, None]], ) -> Callable[P, Coroutine[Any, Any, None]]: """Decorate pyvlx calls to handle exceptions. @@ -56,7 +54,6 @@ class VeluxEntity(Entity): self.node = node unique_id = node.serial_number or f"{config_entry_id}_{node.node_id}" self._attr_unique_id = unique_id - self.unsubscribe = None self._attr_device_info = DeviceInfo( identifiers={ diff --git a/homeassistant/components/velux/icons.json b/homeassistant/components/velux/icons.json deleted file mode 100644 index 78cb5b14838..00000000000 --- a/homeassistant/components/velux/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "reboot_gateway": { - "service": "mdi:restart" - } - } -} diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index 163403ddf9d..3f5cac86ecd 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -1,7 +1,5 @@ """Support for Velux lights.""" -from __future__ import annotations - from typing import Any from pyvlx import DimmableDevice, Intensity, Light, OnOffLight diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 9ebe6ff6062..428a47fb82d 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.32"] + "requirements": ["pyvlx==0.2.35"] } diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py index c4f68a3eb56..64930570817 100644 --- a/homeassistant/components/velux/number.py +++ b/homeassistant/components/velux/number.py @@ -1,7 +1,5 @@ """Support for Velux exterior heating number entities.""" -from __future__ import annotations - from pyvlx import ExteriorHeating, Intensity from homeassistant.components.number import NumberEntity diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index c2c5250517b..b12c1a3bff1 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -1,7 +1,5 @@ """Support for VELUX scenes.""" -from __future__ import annotations - from typing import Any from pyvlx import Scene as PyVLXScene diff --git a/homeassistant/components/velux/services.yaml b/homeassistant/components/velux/services.yaml deleted file mode 100644 index 7aee1694061..00000000000 --- a/homeassistant/components/velux/services.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Velux Integration services - -reboot_gateway: diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index a52fb0a245c..f833503aaac 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -59,23 +59,8 @@ "device_communication_error": { "message": "Failed to communicate with Velux device: {error}" }, - "no_gateway_loaded": { - "message": "No loaded Velux gateway found" - }, "reboot_failed": { "message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually" } - }, - "issues": { - "deprecated_reboot_service": { - "description": "The `velux.reboot_gateway` action is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", - "title": "Velux 'Reboot gateway' action deprecated" - } - }, - "services": { - "reboot_gateway": { - "description": "Reboots the KLF200 Gateway", - "name": "Reboot gateway" - } } } diff --git a/homeassistant/components/velux/switch.py b/homeassistant/components/velux/switch.py index d7f5dfd6803..c6aa7148061 100644 --- a/homeassistant/components/velux/switch.py +++ b/homeassistant/components/velux/switch.py @@ -1,7 +1,5 @@ """Support for Velux switches.""" -from __future__ import annotations - from typing import Any from pyvlx import OnOffSwitch diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index faa47bfc8e4..ab10ce7ea0b 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -1,10 +1,7 @@ """The venstar component.""" -from __future__ import annotations - from venstarcolortouch import VenstarColorTouch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,13 +12,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN, VENSTAR_TIMEOUT -from .coordinator import VenstarDataUpdateCoordinator +from .const import VENSTAR_TIMEOUT +from .coordinator import VenstarConfigEntry, VenstarDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: VenstarConfigEntry +) -> bool: """Set up the Venstar thermostat.""" username = config_entry.data.get(CONF_USERNAME) password = config_entry.data.get(CONF_PASSWORD) @@ -46,17 +45,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) await venstar_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = venstar_data_coordinator + config_entry.runtime_data = venstar_data_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: VenstarConfigEntry +) -> bool: """Unload the config and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 18c7abdc8cc..415310e9f14 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -4,21 +4,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import VenstarConfigEntry from .entity import VenstarEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VenstarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vensar device binary_sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data if coordinator.client.alerts is None: return diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 67fa08fcc12..cfb431395b8 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -1,7 +1,5 @@ """Support for Venstar WiFi Thermostats.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -20,7 +18,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, @@ -50,7 +48,7 @@ from .const import ( DOMAIN, HOLD_MODE_TEMPERATURE, ) -from .coordinator import VenstarDataUpdateCoordinator +from .coordinator import VenstarConfigEntry, VenstarDataUpdateCoordinator from .entity import VenstarEntity PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( @@ -70,11 +68,11 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VenstarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Venstar thermostat.""" - venstar_data_coordinator = hass.data[DOMAIN][config_entry.entry_id] + venstar_data_coordinator = config_entry.runtime_data async_add_entities( [ VenstarThermostat( @@ -101,11 +99,11 @@ async def async_setup_platform( "Loading venstar via platform config is deprecated; The configuration" " has been migrated to a config entry and can be safely removed" ) - # No config entry exists and configuration.yaml config exists, trigger the import flow. - if not hass.config_entries.async_entries(DOMAIN): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) + # Trigger the import flow for this YAML entry; duplicates by host are + # aborted in the import step so each configured device is imported. + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) class VenstarThermostat(VenstarEntity, ClimateEntity): @@ -122,7 +120,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): def __init__( self, venstar_data_coordinator: VenstarDataUpdateCoordinator, - config: ConfigEntry, + config: VenstarConfigEntry, ) -> None: """Initialize the thermostat.""" super().__init__(venstar_data_coordinator, config) diff --git a/homeassistant/components/venstar/coordinator.py b/homeassistant/components/venstar/coordinator.py index 2c5a51425ad..5399dcf55c2 100644 --- a/homeassistant/components/venstar/coordinator.py +++ b/homeassistant/components/venstar/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the venstar component.""" -from __future__ import annotations - import asyncio from datetime import timedelta @@ -14,16 +12,18 @@ from homeassistant.helpers import update_coordinator from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP +type VenstarConfigEntry = ConfigEntry[VenstarDataUpdateCoordinator] + class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): """Class to manage fetching Venstar data.""" - config_entry: ConfigEntry + config_entry: VenstarConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VenstarConfigEntry, venstar_connection: VenstarColorTouch, ) -> None: """Initialize global Venstar data updater.""" diff --git a/homeassistant/components/venstar/entity.py b/homeassistant/components/venstar/entity.py index b8a4b971a7f..d592cc7836c 100644 --- a/homeassistant/components/venstar/entity.py +++ b/homeassistant/components/venstar/entity.py @@ -1,14 +1,11 @@ """The venstar component.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import VenstarDataUpdateCoordinator +from .coordinator import VenstarConfigEntry, VenstarDataUpdateCoordinator class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): @@ -19,7 +16,7 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): def __init__( self, venstar_data_coordinator: VenstarDataUpdateCoordinator, - config: ConfigEntry, + config: VenstarConfigEntry, ) -> None: """Initialize the data object.""" super().__init__(venstar_data_coordinator) diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 14e7103a83f..2f3f5ef3ccc 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -1,7 +1,5 @@ """Representation of Venstar sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -12,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -23,8 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import VenstarDataUpdateCoordinator +from .coordinator import VenstarConfigEntry, VenstarDataUpdateCoordinator from .entity import VenstarEntity RUNTIME_HEAT1 = "heat1" @@ -80,11 +76,11 @@ class VenstarSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VenstarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Venstar device sensors based on a config entry.""" - coordinator: VenstarDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities: list[Entity] = [] if sensors := coordinator.client.get_sensor_list(): @@ -142,7 +138,7 @@ class VenstarSensor(VenstarEntity, SensorEntity): def __init__( self, coordinator: VenstarDataUpdateCoordinator, - config: ConfigEntry, + config: VenstarConfigEntry, entity_description: VenstarSensorEntityDescription, sensor_name: str, ) -> None: @@ -157,7 +153,11 @@ class VenstarSensor(VenstarEntity, SensorEntity): @property def unique_id(self): """Return the unique id.""" - return f"{self._config.entry_id}_{self.sensor_name.replace(' ', '_')}_{self.entity_description.key}" + return ( + f"{self._config.entry_id}" + f"_{self.sensor_name.replace(' ', '_')}" + f"_{self.entity_description.key}" + ) @property def native_value(self) -> int: diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 8e4b7e35f43..722dcb20d88 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -1,7 +1,5 @@ """Support for Vera devices.""" -from __future__ import annotations - import asyncio from collections import defaultdict import logging @@ -9,7 +7,6 @@ import logging import pyvera as veraApi from requests.exceptions import RequestException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EXCLUDE, CONF_LIGHTS, @@ -23,9 +20,8 @@ from homeassistant.helpers import config_validation as cv from .common import ( ControllerData, SubscriptionRegistry, + VeraConfigEntry, get_configured_platforms, - get_controller_data, - set_controller_data, ) from .config_flow import fix_device_id_list, new_options from .const import CONF_CONTROLLER, DOMAIN @@ -35,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VeraConfigEntry) -> bool: """Do setup of vera.""" # Use options entered during initial config flow or provided from configuration.yml if entry.data.get(CONF_LIGHTS) or entry.data.get(CONF_EXCLUDE): @@ -68,6 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: all_devices = await hass.async_add_executor_job(controller.get_devices) + # pylint: disable-next=home-assistant-sequential-executor-jobs all_scenes = await hass.async_add_executor_job(controller.get_scenes) except RequestException as exception: # There was a network related error connecting to the Vera controller. @@ -90,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry=entry, ) - set_controller_data(hass, entry, controller_data) + entry.runtime_data = controller_data # Forward the config data to the necessary platforms. await hass.config_entries.async_forward_entry_setups( @@ -109,9 +106,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: VeraConfigEntry +) -> bool: """Unload vera config entry.""" - controller_data: ControllerData = get_controller_data(hass, config_entry) + controller_data = config_entry.runtime_data await asyncio.gather( *( hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 00780fec8ce..0167099e84a 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -1,26 +1,23 @@ """Support for Vera binary sensors.""" -from __future__ import annotations - import pyvera as veraApi from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraBinarySensor(device, controller_data) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 084725f484e..7edbc4d385e 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -1,7 +1,5 @@ """Support for Vera thermostats.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi @@ -14,12 +12,11 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity FAN_OPERATION_LIST = [FAN_ON, FAN_AUTO] @@ -29,11 +26,11 @@ SUPPORT_HVAC = [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraThermostat(device, controller_data) diff --git a/homeassistant/components/vera/common.py b/homeassistant/components/vera/common.py index a6e6e097b4a..38af5bfda5a 100644 --- a/homeassistant/components/vera/common.py +++ b/homeassistant/components/vera/common.py @@ -1,7 +1,5 @@ """Common vera code.""" -from __future__ import annotations - from collections import defaultdict from datetime import datetime from typing import NamedTuple @@ -13,7 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.event import call_later -from .const import DOMAIN +type VeraConfigEntry = ConfigEntry[ControllerData] class ControllerData(NamedTuple): @@ -22,7 +20,7 @@ class ControllerData(NamedTuple): controller: pv.VeraController devices: defaultdict[Platform, list[pv.VeraDevice]] scenes: list[pv.VeraScene] - config_entry: ConfigEntry + config_entry: VeraConfigEntry def get_configured_platforms(controller_data: ControllerData) -> set[Platform]: @@ -35,20 +33,6 @@ def get_configured_platforms(controller_data: ControllerData) -> set[Platform]: return set(platforms) -def get_controller_data( - hass: HomeAssistant, config_entry: ConfigEntry -) -> ControllerData: - """Get controller data from hass data.""" - return hass.data[DOMAIN][config_entry.entry_id] - - -def set_controller_data( - hass: HomeAssistant, config_entry: ConfigEntry, data: ControllerData -) -> None: - """Set controller data in hass data.""" - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data - - class SubscriptionRegistry(pv.AbstractSubscriptionRegistry): """Manages polling for data from vera.""" @@ -76,7 +60,8 @@ class SubscriptionRegistry(pv.AbstractSubscriptionRegistry): delay = 1 # Long poll for changes. The downstream API instructs the endpoint to wait a - # a minimum of 200ms before returning data and a maximum of 9s before timing out. + # a minimum of 200ms before returning data and a + # maximum of 9s before timing out. if not self.poll_server_once(): # If an error was encountered, wait a bit longer before trying again. delay = 60 diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index 7879d103595..5698561953f 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Vera.""" -from __future__ import annotations - from collections.abc import Mapping import logging import re @@ -13,7 +11,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_USER, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -22,6 +19,7 @@ from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback from homeassistant.helpers.typing import VolDictType +from .common import VeraConfigEntry from .const import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN LIST_REGEX = re.compile("[^0-9]+") @@ -100,7 +98,7 @@ class VeraFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: VeraConfigEntry) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index 8256804b8a3..5e5159e1cb2 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -1,28 +1,25 @@ """Support for Vera cover - curtains, rollershutters etc.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi from homeassistant.components.cover import ATTR_POSITION, ENTITY_ID_FORMAT, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraCover(device, controller_data) diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index 985761f2e63..0639d850fe4 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -1,7 +1,5 @@ """Support for Vera devices.""" -from __future__ import annotations - import logging from typing import Any @@ -42,7 +40,10 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): if controller_data.config_entry.data.get(CONF_LEGACY_UNIQUE_ID): self._unique_id = str(self.vera_device.vera_device_id) else: - self._unique_id = f"vera_{controller_data.config_entry.unique_id}_{self.vera_device.vera_device_id}" + self._unique_id = ( + f"vera_{controller_data.config_entry.unique_id}" + f"_{self.vera_device.vera_device_id}" + ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index f573fcd94ea..1883dd090ed 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -1,7 +1,5 @@ """Support for Vera lights.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi @@ -13,23 +11,22 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraLight(device, controller_data) diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 3f76f3a6106..b178a9a343a 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -1,18 +1,15 @@ """Support for Vera locks.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity ATTR_LAST_USER_NAME = "changed_by_name" @@ -21,11 +18,11 @@ ATTR_LOW_BATTERY = "low_battery" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraLock(device, controller_data) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index 0e504b12303..c39b21de2ec 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -1,28 +1,25 @@ """Support for Vera scenes.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .const import VERA_ID_FORMAT async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [VeraScene(device, controller_data) for device in controller_data.scenes], True ) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index f69025d3ec6..3f69a492e57 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -1,7 +1,5 @@ """Support for Vera sensors.""" -from __future__ import annotations - from datetime import timedelta from typing import cast @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -25,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity SCAN_INTERVAL = timedelta(seconds=5) @@ -33,11 +30,11 @@ SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data entities: list[SensorEntity] = [ VeraSensor(device, controller_data) diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index 67be4a7849a..f116a021d75 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -1,28 +1,25 @@ """Support for Vera switches.""" -from __future__ import annotations - from typing import Any import pyvera as veraApi from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import ControllerData, get_controller_data +from .common import ControllerData, VeraConfigEntry from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VeraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - controller_data = get_controller_data(hass, entry) + controller_data = entry.runtime_data async_add_entities( [ VeraSwitch(device, controller_data) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index e635ab712be..5afec3f9f6e 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -1,7 +1,5 @@ """Support for Verisure devices.""" -from __future__ import annotations - from contextlib import suppress import os from pathlib import Path @@ -14,8 +12,8 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR -from .const import CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER -from .coordinator import VerisureDataUpdateCoordinator +from .const import CONF_LOCK_DEFAULT_CODE, LOGGER +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -27,7 +25,7 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VerisureConfigEntry) -> bool: """Set up Verisure from a config entry.""" await hass.async_add_executor_job(migrate_cookie_files, hass, entry) @@ -38,8 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator # Migrate lock default code from config entry to lock entity @@ -52,28 +49,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def update_listener(hass: HomeAssistant, entry: VerisureConfigEntry) -> None: """Handle options update.""" # Propagate configuration change. - coordinator = hass.data[DOMAIN][entry.entry_id] - coordinator.async_update_listeners() + entry.runtime_data.async_update_listeners() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VerisureConfigEntry) -> bool: """Unload Verisure config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False - cookie_file = hass.config.path(STORAGE_DIR, f"verisure_{entry.entry_id}") + cookie_file = hass.config.path(STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}") with suppress(FileNotFoundError): await hass.async_add_executor_job(os.unlink, cookie_file) - del hass.data[DOMAIN][entry.entry_id] - - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return True diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index db199b180f4..82e955f0c3a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Verisure alarm control panels.""" -from __future__ import annotations - import asyncio from homeassistant.components.alarm_control_panel import ( @@ -10,23 +8,23 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ALARM_STATE_TO_HA, CONF_GIID, DOMAIN, LOGGER -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" - async_add_entities([VerisureAlarm(coordinator=hass.data[DOMAIN][entry.entry_id])]) + async_add_entities([VerisureAlarm(coordinator=entry.runtime_data)]) class VerisureAlarm( @@ -66,6 +64,12 @@ class VerisureAlarm( self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) + if arm_state is None or "data" not in arm_state: + await self.coordinator.async_refresh() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="arm_state_failed", + ) result = None attempts = 0 while result is None: @@ -80,6 +84,8 @@ class VerisureAlarm( list(arm_state["data"].values())[0], state ), ) + if transaction is None: + continue result = ( transaction.get("data", {}) .get("installation", {}) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index c42454b380a..d131c13d2e8 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -1,14 +1,11 @@ """Support for Verisure binary sensors.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -18,16 +15,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import CONF_GIID, DOMAIN -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure binary sensors based on a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: list[Entity] = [VerisureEthernetStatus(coordinator)] diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 1f5d48ea197..9816e6c4c85 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -1,14 +1,11 @@ """Support for Verisure cameras.""" -from __future__ import annotations - import errno import os from verisure import Error as VerisureError from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -19,16 +16,16 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DOMAIN, LOGGER, SERVICE_CAPTURE_SMARTCAM -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data platform = async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 0f1088ccb80..61d9d1c7376 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Verisure integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -13,12 +11,7 @@ from verisure import ( ) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.storage import STORAGE_DIR @@ -30,6 +23,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import VerisureConfigEntry class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -44,7 +38,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: VerisureConfigEntry, ) -> VerisureOptionsFlowHandler: """Get the options flow for this handler.""" return VerisureOptionsFlowHandler() @@ -256,10 +250,12 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await self.hass.async_add_executor_job( - self.verisure.validate_mfa, user_input[CONF_CODE] - ) - await self.hass.async_add_executor_job(self.verisure.login) + + def _validate_mfa_and_login() -> None: + self.verisure.validate_mfa(user_input[CONF_CODE]) + self.verisure.login() + + await self.hass.async_add_executor_job(_validate_mfa_and_login) except VerisureLoginError as ex: LOGGER.debug("Could not log in to Verisure, %s", ex) errors["base"] = "invalid_auth" diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 5165ddc6d3d..3f17e04ee34 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Verisure integration.""" -from __future__ import annotations - from datetime import timedelta from time import sleep @@ -21,13 +19,15 @@ from homeassistant.util import Throttle from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER +type VerisureConfigEntry = ConfigEntry[VerisureDataUpdateCoordinator] + class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """A Verisure Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: VerisureConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: VerisureConfigEntry) -> None: """Initialize the Verisure hub.""" self.imageseries: list[dict[str, str]] = [] self._overview: list[dict] = [] diff --git a/homeassistant/components/verisure/diagnostics.py b/homeassistant/components/verisure/diagnostics.py index a14e6e00b98..ad296b36b25 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -1,15 +1,11 @@ """Diagnostics support for Verisure.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry TO_REDACT = { "date", @@ -23,8 +19,7 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VerisureConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(coordinator.data, TO_REDACT) + return async_redact_data(entry.runtime_data.data, TO_REDACT) diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 4d2229967a0..76b3b5851d4 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -1,14 +1,11 @@ """Support for Verisure locks.""" -from __future__ import annotations - import asyncio from typing import Any from verisure import Error as VerisureError from homeassistant.components.lock import LockEntity, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -27,16 +24,16 @@ from .const import ( SERVICE_DISABLE_AUTOLOCK, SERVICE_ENABLE_AUTOLOCK, ) -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data platform = async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 153b2ba4006..17379df01a1 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.6.7"] + "requirements": ["vsure==2.7.0"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 6ed4784bffb..747f803a03a 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -1,13 +1,10 @@ """Support for Verisure sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -16,16 +13,16 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DEVICE_TYPE_NAME, DOMAIN -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: list[Entity] = [ VerisureThermometer(coordinator, serial_number) diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 364f2690e78..797a455d69f 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -51,6 +51,11 @@ } } }, + "exceptions": { + "arm_state_failed": { + "message": "Failed to change alarm state. Verify your code is correct and that your account is not temporarily locked." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index bdd933c753b..5ab7ca8ae08 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -1,28 +1,25 @@ """Support for Verisure Smartplugs.""" -from __future__ import annotations - from time import monotonic from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DOMAIN -from .coordinator import VerisureDataUpdateCoordinator +from .coordinator import VerisureConfigEntry, VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VerisureConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" - coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( VerisureSmartplug(coordinator, serial_number) for serial_number in coordinator.data["smart_plugs"] diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index cbd69ba0a81..baef32e7b76 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -83,7 +83,11 @@ def _add_entity_info(peripheral, device, entity_dict) -> None: KEY_PARENT_MAC: device.mac, } - key = f"{entity_info[KEY_PARENT_MAC]}/{entity_info[KEY_IDENTIFIER]}/{entity_info[KEY_MEASUREMENT]}" + key = ( + f"{entity_info[KEY_PARENT_MAC]}" + f"/{entity_info[KEY_IDENTIFIER]}" + f"/{entity_info[KEY_MEASUREMENT]}" + ) entity_dict[key] = entity_info return entity_dict diff --git a/homeassistant/components/versasense/sensor.py b/homeassistant/components/versasense/sensor.py index 3956bd21fea..3b82852b386 100644 --- a/homeassistant/components/versasense/sensor.py +++ b/homeassistant/components/versasense/sensor.py @@ -1,7 +1,5 @@ """Support for VersaSense sensor peripheral.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import SensorEntity diff --git a/homeassistant/components/versasense/switch.py b/homeassistant/components/versasense/switch.py index 00c94d04045..7e785b2e3bc 100644 --- a/homeassistant/components/versasense/switch.py +++ b/homeassistant/components/versasense/switch.py @@ -1,7 +1,5 @@ """Support for VersaSense actuator peripheral.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index 6fabf97c8dd..54a40b2b9aa 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -1,22 +1,14 @@ """The Version integration.""" -from __future__ import annotations - import logging from pyhaversion import HaVersion +from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - BOARD_MAP, - CONF_BOARD, - CONF_CHANNEL, - CONF_IMAGE, - CONF_SOURCE, - PLATFORMS, -) +from .const import BOARD_MAP, CONF_BOARD, CONF_CHANNEL, CONF_IMAGE, PLATFORMS from .coordinator import VersionConfigEntry, VersionDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py index 900daa7aba1..7cf7e33a138 100644 --- a/homeassistant/components/version/binary_sensor.py +++ b/homeassistant/components/version/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for Version.""" -from __future__ import annotations - from awesomeversion import AwesomeVersion from homeassistant.components.binary_sensor import ( @@ -9,11 +7,16 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import CONF_NAME, EntityCategory, __version__ as HA_VERSION +from homeassistant.const import ( + CONF_NAME, + CONF_SOURCE, + EntityCategory, + __version__ as HA_VERSION, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_SOURCE, DEFAULT_NAME +from .const import DEFAULT_NAME from .coordinator import VersionConfigEntry from .entity import VersionEntity diff --git a/homeassistant/components/version/config_flow.py b/homeassistant/components/version/config_flow.py index 17cd07aac6f..2b608a5555f 100644 --- a/homeassistant/components/version/config_flow.py +++ b/homeassistant/components/version/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Version integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -64,10 +62,7 @@ class VersionConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_SOURCE] = VERSION_SOURCE_MAP[user_input[CONF_VERSION_SOURCE]] self._entry_data.update(user_input) - if not self.show_advanced_options or user_input[CONF_SOURCE] in ( - "local", - "haio", - ): + if user_input[CONF_SOURCE] in ("local", "haio"): return self.async_create_entry( title=self._config_entry_name, data=self._entry_data, diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index c0a5062bedb..2865eacc57b 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -1,14 +1,12 @@ """Constants for the Version integration.""" -from __future__ import annotations - from datetime import timedelta from logging import Logger, getLogger from typing import Any, Final from pyhaversion.consts import HaVersionChannel, HaVersionSource -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import CONF_NAME, CONF_SOURCE, Platform DOMAIN: Final = "version" LOGGER: Final[Logger] = getLogger(__package__) @@ -25,7 +23,6 @@ CONF_BOARD: Final = "board" CONF_CHANNEL: Final = "channel" CONF_IMAGE: Final = "image" CONF_VERSION_SOURCE: Final = "version_source" -CONF_SOURCE: Final = "source" ATTR_CHANNEL: Final = CONF_CHANNEL ATTR_VERSION_SOURCE: Final = CONF_VERSION_SOURCE diff --git a/homeassistant/components/version/coordinator.py b/homeassistant/components/version/coordinator.py index 349ede53d33..c97cff0c7fd 100644 --- a/homeassistant/components/version/coordinator.py +++ b/homeassistant/components/version/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for Version entities.""" -from __future__ import annotations - from typing import Any from awesomeversion import AwesomeVersion diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index 1174c5ad4d3..b8f5a119540 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Version.""" -from __future__ import annotations - from typing import Any from attr import asdict diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 7e173b46d36..52200b07e48 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -1,16 +1,14 @@ """Sensor that can display the current Home Assistant versions.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import CONF_SOURCE, DEFAULT_NAME +from .const import DEFAULT_NAME from .coordinator import VersionConfigEntry from .entity import VersionEntity diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 0dd13677ed5..aa79df43e91 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -64,14 +64,17 @@ async def async_setup_entry( try: await manager.login() except VeSyncLoginError as err: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth" ) from err except VeSyncServerError as err: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="server_error" ) from err except VeSyncAPIResponseError as err: + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="api_response_error" ) from err @@ -120,7 +123,8 @@ async def async_migrate_entry( Platform.SWITCH ): _LOGGER.debug( - "Migrating switch/outlet entity from unique_id: %s to unique_id: %s", + "Migrating switch/outlet entity" + " from unique_id: %s to unique_id: %s", reg_entry.unique_id, reg_entry.unique_id + "-device_status", ) diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index e18755f995f..e613884f538 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor for VeSync.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 08f55a6561d..cde5cf8af18 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) def rgetattr(obj: object, attr: str) -> object | str | None: - """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well.""" + """Return nested attribute from a dotted path string.""" _this_func = rgetattr sp = attr.split(".", 1) if len(sp) == 1: @@ -56,7 +56,7 @@ def is_outlet(device: VeSyncBaseDevice) -> TypeGuard[VeSyncOutlet]: def is_wall_switch(device: VeSyncBaseDevice) -> TypeGuard[VeSyncWallSwitch]: - """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + """Check if the device represents a wall switch.""" if device.product_type != ProductTypes.SWITCH: return False diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index 0c76bd09d9e..12145dfaef9 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -1,7 +1,5 @@ """Class to manage VeSync data updates.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py index dbab9460fa4..c5dc0d069b3 100644 --- a/homeassistant/components/vesync/diagnostics.py +++ b/homeassistant/components/vesync/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for VeSync.""" -from __future__ import annotations - from typing import Any, cast from pyvesync import VeSync diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 062ef5a21d8..47c17f67ac6 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,7 +1,5 @@ """Support for VeSync fans.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index dafaefc2c9c..ab77cca77ac 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -95,7 +95,8 @@ def _get_ha_mode(vs_mode: str) -> str | None: class VeSyncHumidifierHA(VeSyncBaseEntity[VeSyncHumidifier], HumidifierEntity): """Representation of a VeSync humidifier.""" - # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name + # The base VeSyncBaseEntity has _attr_has_entity_name + # and this is to follow the device name _attr_name = None _attr_supported_features = HumidifierEntityFeature.MODES @@ -181,7 +182,8 @@ class VeSyncHumidifierHA(VeSyncBaseEntity[VeSyncHumidifier], HumidifierEntity): raise HomeAssistantError("Failed to set mode.") if mode == MODE_SLEEP: - # We successfully changed the mode. Consider it a success even if display operation fails. + # We successfully changed the mode. Consider it + # a success even if display operation fails. await self.device.toggle_display(False) self.async_write_ha_state() diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e10dde26709..54b2bbf27f9 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -90,7 +90,8 @@ class VeSyncBaseLightHA(VeSyncBaseEntity[VeSyncSwitch | VeSyncBulb], LightEntity """Get light brightness.""" if self.device.state.brightness is None: _LOGGER.debug( - "VeSync - received unexpected 'brightness' value from pyvesync api of None" + "VeSync - received unexpected 'brightness'" + " value from pyvesync api of None" ) return 0 @@ -122,7 +123,8 @@ class VeSyncBaseLightHA(VeSyncBaseEntity[VeSyncSwitch | VeSyncBulb], LightEntity color_temp = max(0, min(color_temp, 100)) # call pyvesync library api method to set color_temp await self.device.set_color_temp(color_temp) - # flag attribute_adjustment_only, so it doesn't turn_on the device redundantly + # flag attribute_adjustment_only, so it doesn't + # turn_on the device redundantly attribute_adjustment_only = True # set brightness level if ( @@ -177,10 +179,12 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): if hasattr(self.device.state, "color_temp") is False: return None - # pyvesync v3 provides BulbState.color_temp_kelvin() - possible to use that instead? + # pyvesync v3 provides BulbState.color_temp_kelvin() + # - possible to use that instead? if self.device.state.color_temp is None: _LOGGER.debug( - "VeSync - received unexpected 'color_temp' value from pyvesync api of None" + "VeSync - received unexpected 'color_temp'" + " value from pyvesync api of None" ) return 0 diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 195a3315341..99603dc32d2 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_polling", "loggers": ["pyvesync"], "quality_scale": "bronze", - "requirements": ["pyvesync==3.4.1"] + "requirements": ["pyvesync==3.4.2"] } diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 0ee25371948..4f07bc6212c 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,7 +1,5 @@ """Support for voltage, power & energy sensors for VeSync outlets.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index d990f5d8845..82fce6d69ef 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -1,7 +1,5 @@ """Support for the Italian train system using ViaggiaTreno API.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -131,7 +129,8 @@ class ViaggiaTrenoSensor(SensorEntity): self._tstatus = self._viaggiatreno.get_line_status(self._line) if self._tstatus is None: _LOGGER.error( - "Received status for line %s: None. Check the train and station IDs", + "Received status for line %s: None." + " Check the train and station IDs", self._line, ) return diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 8b4a83855e0..d72ae600d15 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,32 +1,52 @@ """The ViCare integration.""" -from __future__ import annotations - from contextlib import suppress import logging import os +from aiohttp import ClientError +from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareOAuthManager import obtain_token_via_basic_auth_pkce from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, ) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) +from homeassistant.helpers import ( + config_entry_oauth2_flow, + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.config_entry_oauth2_flow import MY_AUTH_CALLBACK_PATH from homeassistant.helpers.storage import STORAGE_DIR +from .api import ConfigEntryAuth from .const import ( DEFAULT_CACHE_DURATION, DOMAIN, PLATFORMS, UNSUPPORTED_DEVICES, VICARE_TOKEN_FILENAME, + VIESSMANN_DEVELOPER_PORTAL, ) from .types import ViCareConfigEntry, ViCareData, ViCareDevice -from .utils import get_device_serial, login +from .utils import get_device_serial _LOGGER = logging.getLogger(__name__) @@ -35,7 +55,7 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry ) -> bool: """Migrate old entry.""" - if config_entry.version > 1: + if config_entry.version > 2: return False if config_entry.version == 1 and config_entry.minor_version < 2: @@ -45,17 +65,110 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2) _LOGGER.debug("Migration to version 1.2 successful") + if config_entry.version == 1 and config_entry.minor_version < 3: + _LOGGER.debug("Migrating ViCare config entry from version 1.2 to 2.1") + data = {**config_entry.data} + + client_id = data[CONF_CLIENT_ID] + username = data[CONF_USERNAME] + password = data[CONF_PASSWORD] + + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(client_id, "", username), + ) + _LOGGER.debug("Imported configured client_id as application credential") + + token = await hass.async_add_executor_job( + obtain_token_via_basic_auth_pkce, client_id, username, password + ) + + data.pop(CONF_USERNAME) + data.pop(CONF_PASSWORD) + data.pop(CONF_CLIENT_ID) + + data["auth_implementation"] = DOMAIN + data[CONF_TOKEN] = token + + token_path = hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME) + await hass.async_add_executor_job(_remove_token_file, token_path) + + hass.config_entries.async_update_entry( + config_entry, data=data, version=2, minor_version=1 + ) + if token: + _LOGGER.debug("Migration to version 2.1 successful (token obtained)") + else: + _LOGGER.warning( + "Migration to version 2.1 complete but token could not be " + "obtained — re-authentication will be required" + ) + + ir.async_create_issue( + hass, + DOMAIN, + "update_redirect_uri", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="update_redirect_uri", + translation_placeholders={ + "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL, + "redirect_url": MY_AUTH_CALLBACK_PATH, + }, + ) + + if config_entry.version == 1 and config_entry.minor_version == 3: + # Pre-merge testers were on transient v1.3; promote to v2.1 without re-running. + hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1) + _LOGGER.debug("Promoted pre-merge ViCare config entry from 1.3 to 2.1") + return True async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool: """Set up from config entry.""" _LOGGER.debug("Setting up ViCare component") + try: - entry.runtime_data = await hass.async_add_executor_job( - setup_vicare_api, hass, entry + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err: + except ( + config_entry_oauth2_flow.ImplementationUnavailableError, + ValueError, + ) as err: + # Application Credentials missing or removed — user must re-authenticate + _LOGGER.debug("OAuth2 implementation unavailable: %s", err) + raise ConfigEntryAuthFailed( + "OAuth2 implementation unavailable, please re-authenticate" + ) from err + + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestTransientError as err: + _LOGGER.debug("OAuth2 token refresh failed (transient): %s", err) + raise ConfigEntryNotReady("Transient error refreshing OAuth2 token") from err + except (OAuth2TokenRequestReauthError, OAuth2TokenRequestError, KeyError) as err: + _LOGGER.debug("OAuth2 token validation failed (auth): %s", err) + raise ConfigEntryAuthFailed( + "OAuth2 token is invalid, please re-authenticate" + ) from err + except ClientError as err: + _LOGGER.debug("OAuth2 token validation failed (transient): %s", err) + raise ConfigEntryNotReady("Unable to reach Viessmann auth server") from err + + auth = ConfigEntryAuth(hass, oauth_session) + + try: + entry.runtime_data = await hass.async_add_executor_job(_setup_vicare_api, auth) + except ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, + ) as err: raise ConfigEntryAuthFailed("Authentication failed") from err for device in entry.runtime_data.devices: @@ -67,9 +180,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bo return True -def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> ViCareData: +def _remove_token_file(token_path: str) -> None: + """Remove legacy token file if it exists.""" + with suppress(FileNotFoundError): + os.remove(token_path) + + +def _setup_vicare_api( + auth: ConfigEntryAuth, + cache_duration: int = DEFAULT_CACHE_DURATION, +) -> ViCareData: """Set up PyVicare API.""" - client = login(hass, entry.data) + client = PyViCare() + client.setCacheDuration(cache_duration) + client.initWithExternalOAuth(auth) device_config_list = get_supported_devices(client.devices) @@ -81,12 +205,16 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> ViCareDat number_of_devices, cache_duration, ) - client = login(hass, entry.data, cache_duration) + client = PyViCare() + client.setCacheDuration(cache_duration) + client.initWithExternalOAuth(auth) device_config_list = get_supported_devices(client.devices) for device in device_config_list: _LOGGER.debug( - "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) + "Found device: %s (online: %s)", + device.getModel(), + str(device.isOnline()), ) devices = [ @@ -99,14 +227,7 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> ViCareDat async def async_unload_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool: """Unload ViCare config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - with suppress(FileNotFoundError): - await hass.async_add_executor_job( - os.remove, hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME) - ) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_devices_and_entities( @@ -174,7 +295,8 @@ async def async_migrate_devices_and_entities( entity_new_unique_id, ) entity_registry.async_update_entity( - entity_id=entity_entry.entity_id, new_unique_id=entity_new_unique_id + entity_id=entity_entry.entity_id, + new_unique_id=entity_new_unique_id, ) diff --git a/homeassistant/components/vicare/api.py b/homeassistant/components/vicare/api.py new file mode 100644 index 00000000000..19ab370a834 --- /dev/null +++ b/homeassistant/components/vicare/api.py @@ -0,0 +1,57 @@ +"""API for Viessmann ViCare bound to Home Assistant OAuth.""" + +from asyncio import run_coroutine_threadsafe +import logging +from typing import Any + +from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager +import requests + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +_LOGGER = logging.getLogger(__name__) +_DEFAULT_TIMEOUT = 31 + + +class _TimeoutSession(requests.Session): + """requests.Session that applies a default timeout when callers omit one.""" + + def request( # type: ignore[override] + self, method: str, url: str, **kwargs: Any + ) -> requests.Response: + """Forward to Session.request with a default timeout.""" + kwargs.setdefault("timeout", _DEFAULT_TIMEOUT) + return super().request(method, url, **kwargs) + + +class ConfigEntryAuth(AbstractViCareOAuthManager): + """Provide Viessmann ViCare authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Viessmann ViCare Auth.""" + self._hass = hass + self._ha_session = oauth_session + # Header mutation in renewToken() races with concurrent reads from + # PyViCare's executor calls; self-healing via PyViCare's EXPIRED TOKEN + # retry, so no lock. + session = _TimeoutSession() + session.headers["Authorization"] = self._bearer_header() + super().__init__(session) + + def renewToken(self) -> None: + """Refresh OAuth2 token via HA and update the bearer header.""" + _LOGGER.debug("Renewing OAuth2 token") + run_coroutine_threadsafe( + self._ha_session.async_ensure_token_valid(), self._hass.loop + ).result() + self.oauth_session.headers["Authorization"] = self._bearer_header() + _LOGGER.debug("OAuth2 token renewed successfully") + + def _bearer_header(self) -> str: + token: dict[str, Any] = self._ha_session.token + return f"Bearer {token['access_token']}" diff --git a/homeassistant/components/vicare/application_credentials.py b/homeassistant/components/vicare/application_credentials.py new file mode 100644 index 00000000000..19b29f1a01b --- /dev/null +++ b/homeassistant/components/vicare/application_credentials.py @@ -0,0 +1,40 @@ +"""Application credentials platform for Viessmann ViCare.""" + +from PyViCare.PyViCareAbstractOAuthManager import ( + AUTHORIZE_URL, + SCOPE_IOT, + SCOPE_OFFLINE_ACCESS, + TOKEN_URL, +) + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + +VICARE_SCOPES = [SCOPE_IOT, SCOPE_OFFLINE_ACCESS] + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> ViCareOAuth2Implementation: + """Return auth implementation with PKCE support.""" + return ViCareOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + AUTHORIZE_URL, + TOKEN_URL, + ) + + +class ViCareOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """ViCare OAuth2 implementation with PKCE.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(VICARE_SCOPES), + } diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index c5c1f6fbf94..cf6279d1f67 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,7 +1,5 @@ """Viessmann ViCare sensor device.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 852cf2a9062..964b64871c9 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -1,7 +1,5 @@ """Viessmann ViCare button device.""" -from __future__ import annotations - from contextlib import suppress from dataclasses import dataclass import logging diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 9f23c60085e..0111e28fa9e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -1,7 +1,5 @@ """Viessmann ViCare climate device.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any @@ -129,7 +127,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_max_temp = VICARE_TEMP_HEATING_MAX _attr_target_temperature_step = PRECISION_WHOLE _attr_translation_key = "heating" - _current_action: bool | None = None + _current_action: HVACAction | None = None _current_mode: str | None = None _current_program: str | None = None @@ -199,17 +197,41 @@ class ViCareClimate(ViCareEntity, ClimateEntity): with suppress(PyViCareNotSupportedFeatureError): self._attributes["vicare_modes"] = self._api.getModes() - self._current_action = False - # Update the specific device attributes + # Resolve the current hvac action from the underlying heat + # source. Burners (boilers) only heat; compressors (heat pumps) + # expose a `phase` ("heating" / "cooling" / "off" / ...) on top + # of the active flag. Collect per-source flags first, then map + # to a single HVACAction so the result is independent of + # iteration order: cooling takes precedence over heating, which + # takes precedence over idle. + heating_active = False + cooling_active = False with suppress(PyViCareNotSupportedFeatureError): for burner in get_burners(self._device): - self._current_action = self._current_action or burner.getActive() + if burner.getActive(): + heating_active = True with suppress(PyViCareNotSupportedFeatureError): for compressor in get_compressors(self._device): - self._current_action = ( - self._current_action or compressor.getActive() - ) + if not compressor.getActive(): + continue + phase = None + with suppress(PyViCareNotSupportedFeatureError): + phase = compressor.getPhase() + if phase == "cooling": + cooling_active = True + elif phase == "heating" or phase is None: + # Phase is unset on hybrid devices that do not + # expose it: fall back to HEATING to match the + # pre-cooling-support behaviour. + heating_active = True + + if cooling_active: + self._current_action = HVACAction.COOLING + elif heating_active: + self._current_action = HVACAction.HEATING + else: + self._current_action = HVACAction.IDLE @property def hvac_mode(self) -> HVACMode | None: @@ -259,9 +281,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return the current hvac action.""" - if self._current_action: - return HVACAction.HEATING - return HVACAction.IDLE + return self._current_action or HVACAction.IDLE def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 73ce51a2b8e..9609f468c8a 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -1,110 +1,70 @@ """Config flow for ViCare integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any -from PyViCare.PyViCareUtils import ( - PyViCareInvalidConfigurationError, - PyViCareInvalidCredentialsError, -) -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import config_validation as cv +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DOMAIN, VICARE_NAME, VIESSMANN_DEVELOPER_PORTAL -from .utils import login +from .const import DOMAIN, VICARE_NAME _LOGGER = logging.getLogger(__name__) -REAUTH_SCHEMA = vol.Schema( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - } -) -USER_SCHEMA = REAUTH_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - } -) +class ViCareFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Handle a config flow for ViCare using OAuth2.""" + DOMAIN = DOMAIN + VERSION = 2 + MINOR_VERSION = 1 -class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for ViCare.""" - - VERSION = 1 - MINOR_VERSION = 2 + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - if self._async_current_entries(): + """Handle a flow initiated by the user.""" + if self.source != SOURCE_REAUTH and self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - errors: dict[str, str] = {} + return await super().async_step_user(user_input) - if user_input is not None: - try: - await self.hass.async_add_executor_job(login, self.hass, user_input) - except PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError: - errors["base"] = "invalid_auth" - else: - return self.async_create_entry(title=VICARE_NAME, data=user_input) + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry after OAuth or update existing for reauth.""" + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, + data={**reauth_entry.data, **data}, + ) - return self.async_show_form( - step_id="user", - description_placeholders={ - "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL - }, - data_schema=USER_SCHEMA, - errors=errors, + return self.async_create_entry( + title=VICARE_NAME, + data=data, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication with ViCare.""" + """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm re-authentication with ViCare.""" - errors: dict[str, str] = {} + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") - reauth_entry = self._get_reauth_entry() - if user_input: - data = { - **reauth_entry.data, - **user_input, - } - - try: - await self.hass.async_add_executor_job(login, self.hass, data) - except PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError: - errors["base"] = "invalid_auth" - else: - return self.async_update_reload_and_abort(reauth_entry, data=data) - - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders={ - "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL - }, - data_schema=self.add_suggested_values_to_schema( - REAUTH_SCHEMA, reauth_entry.data - ), - errors=errors, - ) + return await self.async_step_user() async def async_step_dhcp( self, discovery_info: DhcpServiceInfo diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index aeb52bd28ae..bf147950b92 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -18,6 +18,7 @@ PLATFORMS = [ UNSUPPORTED_DEVICES = [ "Heatbox1", "Heatbox2_SRC", + "Heatbox3", "E3_TCU10_x07", "E3_TCU41_x04", "E3_RoomControl_One_522", diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index 7695c304451..008c533b430 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -1,17 +1,29 @@ """Diagnostics support for ViCare.""" -from __future__ import annotations - import json from typing import Any +from PyViCare.PyViCareUtils import PyViCareDeviceCommunicationError + from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from .types import ViCareConfigEntry -TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} +TO_REDACT = { + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +} async def async_get_config_entry_diagnostics( @@ -20,11 +32,26 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" def dump_devices() -> list[dict[str, Any]]: - """Dump devices.""" - return [ - json.loads(device.dump_secure()) - for device in entry.runtime_data.client.devices - ] + """Dump devices, tolerating per-device communication failures.""" + devices: list[dict[str, Any]] = [] + for device in entry.runtime_data.client.all_devices: + try: + devices.append(json.loads(device.dump_secure())) + except PyViCareDeviceCommunicationError as err: + # One offline gateway must not abort the whole diagnostics dump. + devices.append( + { + "device": { + "id": device.device_id, + "modelId": device.device_model, + "type": device.device_type, + "status": device.status, + "roles": device.roles, + }, + "error": f"{type(err).__name__}: {err}", + } + ) + return devices return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 87fca8d6cf6..ac6ec94f7da 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -1,7 +1,5 @@ """Viessmann ViCare ventilation device.""" -from __future__ import annotations - from contextlib import suppress import enum import logging diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 4491ed9501a..55ba5564256 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,8 +1,9 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": ["@CFenner"], + "codeowners": ["@CFenner", "@lackas"], "config_flow": true, + "dependencies": ["application_credentials"], "dhcp": [ { "macaddress": "B87424*" @@ -12,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.58.1"] + "requirements": ["PyViCare==2.60.2"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index de43b5a1797..3b0d05568f5 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -1,7 +1,5 @@ """Number for ViCare.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass diff --git a/homeassistant/components/vicare/select.py b/homeassistant/components/vicare/select.py index d94d3c606e3..4e27ad512e1 100644 --- a/homeassistant/components/vicare/select.py +++ b/homeassistant/components/vicare/select.py @@ -1,7 +1,5 @@ """Viessmann ViCare select device.""" -from __future__ import annotations - from contextlib import suppress import logging diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index c981d94de31..b370c0d3c54 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,7 +1,5 @@ """Viessmann ViCare sensor device.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass @@ -986,6 +984,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_getter=lambda api: api.getTemperature(), ), + ViCareSensorEntityDescription( + key="target_temperature", + translation_key="target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getTargetTemperature(), + ), ViCareSensorEntityDescription( key="room_humidity", device_class=SensorDeviceClass.HUMIDITY, diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index f974580f9af..314dfd44e23 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,38 +1,30 @@ { "config": { "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" }, - "flow_title": "{name}", "step": { - "reauth_confirm": { - "data": { - "client_id": "[%key:component::vicare::config::step::user::data::client_id%]", - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "client_id": "[%key:component::vicare::config::step::user::data_description::client_id%]", - "password": "[%key:component::vicare::config::step::user::data_description::password%]" - }, - "description": "Please verify credentials." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "user": { - "data": { - "client_id": "Client ID", - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::email%]" - }, - "data_description": { - "client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).", - "password": "The password to log in to your ViCare account.", - "username": "The email address to log in to your ViCare account." - }, - "description": "Set up ViCare integration." + "reauth_confirm": { + "description": "The ViCare integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" } } }, @@ -612,6 +604,9 @@ "supply_temperature": { "name": "Supply temperature" }, + "target_temperature": { + "name": "Target temperature" + }, "valve_position": { "name": "Valve position" }, @@ -666,6 +661,12 @@ "message": "Cannot translate preset {preset} into a valid ViCare program" } }, + "issues": { + "update_redirect_uri": { + "description": "ViCare has been migrated to OAuth2 authentication. Your existing session will continue to work, but if re-authentication is ever needed, you must first add a redirect URI to your API client.\n\nGo to the [Viessmann Developer Portal]({viessmann_developer_portal}) and add the following redirect URI to your API client:\n\n`{redirect_url}`\n\nThis is not urgent, but we recommend doing it soon.", + "title": "Update your API client in the Viessmann Developer Portal" + } + }, "services": { "set_vicare_mode": { "description": "Sets the mode of the climate device as defined by Viessmann.", diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index bf1ff9277fe..3056e9b9d84 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,7 +1,5 @@ """ViCare helpers functions.""" -from __future__ import annotations - from collections.abc import Callable, Mapping import logging from typing import Any diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 7693f63b3ae..0fb1ded359c 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,7 +1,5 @@ """Viessmann ViCare water_heater device.""" -from __future__ import annotations - from contextlib import suppress import logging from typing import Any diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py index 7eff058b7b2..750258ed031 100644 --- a/homeassistant/components/victron_ble/__init__.py +++ b/homeassistant/components/victron_ble/__init__.py @@ -1,11 +1,11 @@ """The Victron Bluetooth Low Energy integration.""" -from __future__ import annotations - import logging +from struct import error as struct_error from sensor_state_data import SensorUpdate from victron_ble_ha_parser import VictronBluetoothDeviceData +from victron_ble_ha_parser.parser import detect_device_type from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import REAUTH_AFTER_FAILURES +from .const import REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER _LOGGER = logging.getLogger(__name__) @@ -38,19 +38,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nonlocal consecutive_failures update = data.update(service_info) - # If the device type was recognized (devices dict populated) but - # only signal strength came back, decryption likely failed. - # Unsupported devices have an empty devices dict and won't trigger this. - if update.devices and len(update.entity_values) <= 1: - consecutive_failures += 1 - if consecutive_failures >= REAUTH_AFTER_FAILURES: - _LOGGER.debug( - "Triggering reauth for %s after %d consecutive failures", - address, - consecutive_failures, + # Only assess key validity for instant-readout advertisements + # (0x10 prefix) whose device type the parser actually recognizes. + # Unrecognized mode bytes or non-instant-readout packets are neutral: + # they say nothing about whether the encryption key is correct, so + # they must not increment or reset the failure counter. + raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER) + if update.devices and raw_data is not None: + try: + is_recognizable = ( + raw_data[:1] == b"\x10" and detect_device_type(raw_data) is not None ) - entry.async_start_reauth(hass) - consecutive_failures = 0 + except struct_error, IndexError: + is_recognizable = False + + if is_recognizable: + if not data.validate_advertisement_key(raw_data): + consecutive_failures += 1 + if consecutive_failures >= REAUTH_AFTER_FAILURES: + _LOGGER.debug( + "Triggering reauth for %s after %d consecutive failures", + address, + consecutive_failures, + ) + entry.async_start_reauth(hass) + consecutive_failures = 0 + else: + consecutive_failures = 0 else: consecutive_failures = 0 diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py index f003f162375..01de1292eb5 100644 --- a/homeassistant/components/victron_ble/config_flow.py +++ b/homeassistant/components/victron_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Victron Bluetooth Low Energy integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -9,6 +7,7 @@ from typing import Any from victron_ble_ha_parser import VictronBluetoothDeviceData import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -103,6 +102,7 @@ class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={"title": title}, ) + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids() for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 3a5ea6222a2..c1969f5db37 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.6.3"] + "requirements": ["victron-ble-ha-parser==0.7.0"] } diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 18a112ab700..943b1e5a2e4 100644 --- a/homeassistant/components/victron_ble/sensor.py +++ b/homeassistant/components/victron_ble/sensor.py @@ -1,6 +1,5 @@ """Sensor platform for Victron BLE.""" -from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any @@ -182,10 +181,6 @@ PARALLEL_UPDATES = 0 class VictronBLESensorEntityDescription(SensorEntityDescription): """Describes Victron BLE sensor entity.""" - value_fn: Callable[[float | int | str | None], float | int | str | None] = ( - lambda x: x - ) - SENSOR_DESCRIPTIONS = { Keys.AC_IN_POWER: VictronBLESensorEntityDescription( @@ -258,7 +253,6 @@ SENSOR_DESCRIPTIONS = { device_class=SensorDeviceClass.ENUM, translation_key="charger_error", options=CHARGER_ERROR_OPTIONS, - value_fn=error_to_state, ), Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription( key=Keys.CONSUMED_AMPERE_HOURS, @@ -525,7 +519,11 @@ async def async_setup_entry( VictronBLESensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor( + processor, VictronBLESensorEntityDescription + ) + ) class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity): @@ -538,4 +536,6 @@ class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity): """Return the state of the sensor.""" value = self.processor.entity_data.get(self.entity_key) - return self.entity_description.value_fn(value) + if self.entity_description.key == Keys.CHARGER_ERROR: + return error_to_state(value) + return value diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index 96183fb56e4..d4b33a081c2 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -1,18 +1,24 @@ """The victron_gx integration.""" -from __future__ import annotations - import logging from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr from .hub import Hub, VictronGxConfigEntry _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, ] @@ -52,10 +58,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug("async_unload_entry called for entry: %s", entry.entry_id) - hub: Hub | None = getattr(entry, "runtime_data", None) + hub = entry.runtime_data unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok and hub is not None: + if unload_ok: await hub.stop() hub.unregister_all_new_metric_callbacks() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a device from the config entry if the device is no longer known.""" + hub: Hub = config_entry.runtime_data + return not hub.is_device_connected(device_entry.identifiers) diff --git a/homeassistant/components/victron_gx/binary_sensor.py b/homeassistant/components/victron_gx/binary_sensor.py new file mode 100644 index 00000000000..42caed422f4 --- /dev/null +++ b/homeassistant/components/victron_gx/binary_sensor.py @@ -0,0 +1,85 @@ +"""Support for Victron GX binary sensors.""" + +from typing import Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + MetricType, + VictronEnum, +) + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BINARY_SENSOR_OFF_ID, BINARY_SENSOR_ON_ID +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + +METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, BinarySensorDeviceClass] = { + MetricType.POWER: BinarySensorDeviceClass.POWER, + MetricType.PROBLEM: BinarySensorDeviceClass.PROBLEM, + MetricType.CONNECTIVITY: BinarySensorDeviceClass.CONNECTIVITY, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX binary sensors from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new binary sensor metric discovery.""" + async_add_entities( + [VictronBinarySensor(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.BINARY_SENSOR, on_new_metric) + + +class VictronBinarySensor(VictronBaseEntity, BinarySensorEntity): + """Implementation of a Victron GX binary sensor.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) + self._attr_is_on = self.convert_metric_value_to_is_on(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_is_on = self.convert_metric_value_to_is_on(value) + self.async_write_ha_state() + + @staticmethod + def convert_metric_value_to_is_on(value: Any) -> bool | None: + """Convert a Victron on/off enum value to a boolean.""" + if value is None or not isinstance(value, VictronEnum): + return None + if value.id == BINARY_SENSOR_ON_ID: + return True + if value.id == BINARY_SENSOR_OFF_ID: + return False + return None diff --git a/homeassistant/components/victron_gx/button.py b/homeassistant/components/victron_gx/button.py new file mode 100644 index 00000000000..c439ead0674 --- /dev/null +++ b/homeassistant/components/victron_gx/button.py @@ -0,0 +1,65 @@ +"""Support for Victron GX button entities.""" + +import logging +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + GenericOnOff, + Metric as VictronVenusMetric, + MetricKind, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX button entities from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new button metric discovery.""" + if TYPE_CHECKING: + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronButton(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.BUTTON, on_new_metric) + + +class VictronButton(VictronBaseEntity, ButtonEntity): + """Implementation of a Victron GX button entity.""" + + @callback + def _on_update_cb(self, _value: Any) -> None: + # Buttons are stateless in HA; incoming metric + # updates are intentionally ignored. + pass + + async def async_press(self) -> None: + """Press the button.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + _LOGGER.debug("Pressing button: %s", self.unique_id) + self._metric.set(GenericOnOff.ON) diff --git a/homeassistant/components/victron_gx/config_flow.py b/homeassistant/components/victron_gx/config_flow.py index 04437e676a0..8881d2b7b7b 100644 --- a/homeassistant/components/victron_gx/config_flow.py +++ b/homeassistant/components/victron_gx/config_flow.py @@ -1,7 +1,6 @@ """Config flow for the Victron GX integration.""" -from __future__ import annotations - +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -12,6 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, + CONF_MODEL, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -21,7 +21,7 @@ from homeassistant.helpers import selector from homeassistant.helpers.redact import async_redact_data from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -from .const import CONF_INSTALLATION_ID, CONF_MODEL, CONF_SERIAL, DOMAIN +from .const import CONF_INSTALLATION_ID, CONF_SERIAL, DOMAIN DEFAULT_HOST = "venus.local" DEFAULT_PORT = 1883 @@ -54,6 +54,16 @@ STEP_SSDP_AUTH_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME, default=""): selector.TextSelector(), + vol.Optional(CONF_PASSWORD, default=""): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL): selector.BooleanSelector(), + } +) + async def validate_input(data: dict[str, Any]) -> str: """Validate the user input allows us to connect. @@ -270,5 +280,108 @@ class VictronGXConfigFlow(ConfigFlow, domain=DOMAIN): STEP_SSDP_AUTH_DATA_SCHEMA, user_input ), errors=errors, - description_placeholders={"host": self.hostname}, + description_placeholders={CONF_HOST: self.hostname}, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a Victron GX device.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + data = { + **reconfigure_entry.data, + **user_input, + } + if CONF_USERNAME in user_input: + data[CONF_USERNAME] = user_input[CONF_USERNAME] or None + if CONF_PASSWORD in user_input: + data[CONF_PASSWORD] = user_input[CONF_PASSWORD] or None + try: + installation_id = await validate_input(data) + except AuthenticationError: + errors["base"] = "invalid_auth" + except CannotConnectError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reconfiguration") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(installation_id) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + reconfigure_entry, + title=ENTRY_TITLE_FORMAT.format( + installation_id=installation_id, + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + ), + data_updates=data, + ) + + suggested_values = { + CONF_HOST: reconfigure_entry.data[CONF_HOST], + CONF_PORT: reconfigure_entry.data[CONF_PORT], + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_SSL: reconfigure_entry.data.get(CONF_SSL, False), + } + if user_input is not None: + suggested_values.update(user_input) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + updates = { + CONF_USERNAME: user_input.get(CONF_USERNAME) or None, + CONF_PASSWORD: user_input.get(CONF_PASSWORD) or None, + CONF_SSL: user_input.get( + CONF_SSL, reauth_entry.data.get(CONF_SSL, False) + ), + } + try: + await validate_input({**reauth_entry.data, **updates}) + except AuthenticationError: + errors["base"] = "invalid_auth" + except CannotConnectError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reauthentication") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=updates, + ) + + suggested_values = { + CONF_USERNAME: reauth_entry.data.get(CONF_USERNAME, None), + CONF_SSL: reauth_entry.data.get(CONF_SSL, False), + } + if user_input is not None: + suggested_values.update(user_input) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_REAUTH_DATA_SCHEMA, suggested_values + ), + description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]}, + errors=errors, ) diff --git a/homeassistant/components/victron_gx/const.py b/homeassistant/components/victron_gx/const.py index bd63f86410f..b97e67e0587 100644 --- a/homeassistant/components/victron_gx/const.py +++ b/homeassistant/components/victron_gx/const.py @@ -3,5 +3,8 @@ DOMAIN = "victron_gx" CONF_INSTALLATION_ID = "installation_id" -CONF_MODEL = "model" CONF_SERIAL = "serial" + +# Binary sensor enum ids must be "on" for on and "off" for off. +BINARY_SENSOR_ON_ID = "on" +BINARY_SENSOR_OFF_ID = "off" diff --git a/homeassistant/components/victron_gx/device_tracker.py b/homeassistant/components/victron_gx/device_tracker.py new file mode 100644 index 00000000000..5cd3372e48d --- /dev/null +++ b/homeassistant/components/victron_gx/device_tracker.py @@ -0,0 +1,96 @@ +"""Support for Victron GX device tracker.""" + +from typing import Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + GpsLocation, + Metric as VictronVenusMetric, + MetricKind, +) + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 + +ATTR_ALTITUDE = "altitude" +ATTR_COURSE = "course" +ATTR_SPEED = "speed" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX device trackers from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new device tracker metric discovery.""" + async_add_entities( + [VictronDeviceTracker(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.DEVICE_TRACKER, on_new_metric) + + +class VictronDeviceTracker(VictronBaseEntity, TrackerEntity): + """Implementation of a Victron GX device tracker.""" + + _altitude: float | None = None + _course: float | None = None + _speed: float | None = None + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the device tracker.""" + super().__init__(device, metric, device_info, installation_id) + self._update_from_location(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._update_from_location(value) + self.async_write_ha_state() + + def _update_from_location(self, value: GpsLocation | None) -> None: + """Update entity attributes from a GpsLocation value.""" + if not isinstance(value, GpsLocation): + self._attr_latitude = None + self._attr_longitude = None + self._altitude = None + self._course = None + self._speed = None + return + + self._attr_latitude = value.latitude + self._attr_longitude = value.longitude + self._altitude = value.altitude + self._course = value.course + self._speed = value.speed + + @property + def extra_state_attributes(self) -> dict[str, StateType]: + """Return extra state attributes for altitude, course, and speed.""" + attrs: dict[str, StateType] = {} + attrs[ATTR_ALTITUDE] = self._altitude + attrs[ATTR_COURSE] = self._course + attrs[ATTR_SPEED] = self._speed + return attrs diff --git a/homeassistant/components/victron_gx/diagnostics.py b/homeassistant/components/victron_gx/diagnostics.py new file mode 100644 index 00000000000..574d102ed23 --- /dev/null +++ b/homeassistant/components/victron_gx/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics support for victron_gx.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CONF_INSTALLATION_ID, CONF_SERIAL +from .hub import VictronGxConfigEntry + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_SERIAL, CONF_INSTALLATION_ID} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: VictronGxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data + merged_config = {**entry.data, **entry.options} + return { + "entry_data": async_redact_data(merged_config, TO_REDACT), + "devices": hub.get_diagnostics_data(), + } diff --git a/homeassistant/components/victron_gx/entity.py b/homeassistant/components/victron_gx/entity.py index 059eca0256f..e717f170bff 100644 --- a/homeassistant/components/victron_gx/entity.py +++ b/homeassistant/components/victron_gx/entity.py @@ -3,7 +3,11 @@ from abc import abstractmethod from typing import Any -from victron_mqtt import Device as VictronVenusDevice, Metric as VictronVenusMetric +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricType, +) from homeassistant.const import EntityCategory from homeassistant.core import callback @@ -11,9 +15,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity # Entities that should be marked as diagnostic -ENTITIES_CATEGORY_DIAGNOSTIC = ["system_heartbeat"] +ENTITIES_CATEGORY_DIAGNOSTIC = ["system_heartbeat", "platform_device_reboot"] # Entities that should be disabled by default -ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat"] +ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat", "platform_device_reboot"] +# Units that must be provided directly instead of via localization. +SPECIAL_NATIVE_UNITS = {"%", "Ah"} class VictronBaseEntity(Entity): @@ -35,14 +41,16 @@ class VictronBaseEntity(Entity): self._attr_device_info = device_info self._attr_unique_id = f"{installation_id}_{metric.unique_id}" self._attr_suggested_display_precision = metric.precision - # When main_topic is set, omit translation_key/name so HA uses the device name (via _attr_has_entity_name). + # Always set translation_key so HA can resolve + # state/option translations (e.g. select options). + self._attr_translation_key = metric.generic_short_id.replace("{", "").replace( + "}", "" + ) + self._attr_translation_placeholders = metric.key_values + # When main_topic is set, override name to None so + # HA uses the device name (via _attr_has_entity_name). if metric.main_topic: self._attr_name = None - else: - self._attr_translation_key = metric.generic_short_id.replace( - "{", "" - ).replace("}", "") - self._attr_translation_placeholders = metric.key_values self._attr_entity_category = ( EntityCategory.DIAGNOSTIC @@ -53,6 +61,26 @@ class VictronBaseEntity(Entity): metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT ) + def _native_unit_of_measurement(self) -> str | None: + unit_of_measurement = self._metric.unit_of_measurement + # We need to provide a native unit in three cases: + if ( + # 1. Special units which will never need a translation and therefore will not be included in the translation file. + unit_of_measurement in SPECIAL_NATIVE_UNITS + # 2. When there is known device class which support multiple units. In this case + # we publish what we have and HA will allow conversion to other supported units. + # We specifically don't put those cases in the translation file by the merge script + # not to waste translation resources so it has to come from here. + or self._attr_device_class is not None + # 3. Dynamic units come from user-configured MQTT topics (e.g. + # SwitchableOutput Settings/Unit) and have no translation file + # entry, so we must set the unit programmatically. + or self._metric.metric_type == MetricType.DYNAMIC + ): + return unit_of_measurement + + return None + @callback @abstractmethod def _on_update_cb(self, value: Any) -> None: diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py index 3fbabcb5094..8620b1b4d50 100644 --- a/homeassistant/components/victron_gx/hub.py +++ b/homeassistant/components/victron_gx/hub.py @@ -1,10 +1,8 @@ """Main Hub class.""" -from __future__ import annotations - from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from victron_mqtt import ( AuthenticationError, @@ -13,12 +11,15 @@ from victron_mqtt import ( Hub as VictronVenusHub, Metric as VictronVenusMetric, MetricKind, + MetricType, OperationMode, + VictronEnum, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, + CONF_MODEL, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -29,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.redact import async_redact_data -from .const import CONF_INSTALLATION_ID, CONF_MODEL, CONF_SERIAL, DOMAIN +from .const import CONF_INSTALLATION_ID, CONF_SERIAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,7 +40,7 @@ TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} type VictronGxConfigEntry = ConfigEntry[Hub] -NewMetricCallback = Callable[ +type NewMetricCallback = Callable[ [VictronVenusDevice, VictronVenusMetric, DeviceInfo, str], None ] @@ -74,7 +75,7 @@ class Hub: installation_id=config.get(CONF_INSTALLATION_ID) or None, model_name=config.get(CONF_MODEL) or None, serial=config.get(CONF_SERIAL) or None, - operation_mode=OperationMode.READ_ONLY, + operation_mode=OperationMode.FULL, update_frequency_seconds=UPDATE_INTERVAL_SECONDS, ) self._hub.on_new_metric = self._on_new_metric @@ -87,11 +88,15 @@ class Hub: await self._hub.connect() except AuthenticationError as auth_error: raise ConfigEntryAuthFailed( - f"Authentication failed for {self.host}: {auth_error}" + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={"host": self.host}, ) from auth_error except CannotConnectError as connect_error: raise ConfigEntryNotReady( - f"Cannot connect to the hub at {self.host}: {connect_error}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": self.host}, ) from connect_error async def stop(self) -> None: @@ -135,11 +140,50 @@ class Hub: model=device.model, serial_number=device.serial_number, ) - # Don't set via_device for the GX device itself - if device.unique_id != "system_0": - device_info["via_device"] = (DOMAIN, f"{installation_id}_system_0") + # Set via_device based on parent_device relationship + if device.parent_device is not None: + device_info["via_device"] = ( + DOMAIN, + f"{installation_id}_{device.parent_device.unique_id}", + ) return device_info + def is_device_connected(self, device_identifiers: set[tuple[str, str]]) -> bool: + """Check if a device is currently known to the hub.""" + known_devices = self._hub.devices + return any( + identifier[1].removeprefix(f"{self._hub.installation_id}_") in known_devices + for identifier in device_identifiers + if identifier[0] == DOMAIN + ) + + def get_diagnostics_data(self) -> dict[str, Any]: + """Return diagnostics data for the hub's device and entity tree.""" + return { + device_id: { + "name": device.name, + "model": device.model, + "manufacturer": device.manufacturer, + "firmware_version": device.firmware_version, + "device_type": device.device_type.string, + "metrics": { + metric.short_id: { + "name": metric.name, + "value": "**REDACTED**" + if metric.metric_type is MetricType.LOCATION + else metric.value + if not isinstance(metric.value, VictronEnum) + else metric.value.id, + "unit": metric.unit_of_measurement, + "kind": metric.metric_kind.name, + "type": metric.metric_type.name, + } + for metric in device.metrics + }, + } + for device_id, device in self._hub.devices.items() + } + def register_new_metric_callback( self, kind: MetricKind, new_metric_callback: NewMetricCallback ) -> None: diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 4c09621e1f3..72f1aa74495 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -6,8 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/victron_gx", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "bronze", - "requirements": ["victron-mqtt==2026.4.2"], + "quality_scale": "platinum", + "requirements": ["victron-mqtt==2026.6.1"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/number.py b/homeassistant/components/victron_gx/number.py new file mode 100644 index 00000000000..e9b18e09d57 --- /dev/null +++ b/homeassistant/components/victron_gx/number.py @@ -0,0 +1,93 @@ +"""Support for Victron GX number entities.""" + +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + MetricType, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 + +METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, NumberDeviceClass] = { + MetricType.POWER: NumberDeviceClass.POWER, + MetricType.APPARENT_POWER: NumberDeviceClass.APPARENT_POWER, + MetricType.ENERGY: NumberDeviceClass.ENERGY, + MetricType.VOLTAGE: NumberDeviceClass.VOLTAGE, + MetricType.CURRENT: NumberDeviceClass.CURRENT, + MetricType.FREQUENCY: NumberDeviceClass.FREQUENCY, + MetricType.ELECTRIC_STORAGE_PERCENTAGE: NumberDeviceClass.BATTERY, + MetricType.TEMPERATURE: NumberDeviceClass.TEMPERATURE, + MetricType.SPEED: NumberDeviceClass.SPEED, + MetricType.LIQUID_VOLUME: NumberDeviceClass.VOLUME_STORAGE, + MetricType.DURATION: NumberDeviceClass.DURATION, + MetricType.IRRADIANCE: NumberDeviceClass.IRRADIANCE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX number entities from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new number metric discovery.""" + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronNumber(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.NUMBER, on_new_metric) + + +class VictronNumber(VictronBaseEntity, NumberEntity): + """Implementation of a Victron GX number entity.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the number entity.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) + self._attr_native_unit_of_measurement = self._native_unit_of_measurement() + self._attr_native_value = metric.value + if metric.min_value is not None: + self._attr_native_min_value = metric.min_value + if metric.max_value is not None: + self._attr_native_max_value = metric.max_value + if metric.step is not None: + self._attr_native_step = metric.step + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_native_value = value + self.async_write_ha_state() + + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + self._metric.set(value) diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index af3275f1d21..2e5e179f42d 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -37,17 +37,17 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done @@ -57,19 +57,21 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | Not relevant. - reconfiguration-flow: todo - repair-issues: todo - stale-devices: todo - + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration has no user-actionable repair issues to raise. + stale-devices: done # Platinum async-dependency: done inject-websession: status: exempt comment: | Not relevant. - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/victron_gx/select.py b/homeassistant/components/victron_gx/select.py new file mode 100644 index 00000000000..2c0a426673c --- /dev/null +++ b/homeassistant/components/victron_gx/select.py @@ -0,0 +1,82 @@ +"""Support for Victron GX select entities.""" + +import logging +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + VictronEnum, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX select entities from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new select metric discovery.""" + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronSelect(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.SELECT, on_new_metric) + + +class VictronSelect(VictronBaseEntity, SelectEntity): + """Implementation of a Victron GX select entity.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the select entity.""" + super().__init__(device, metric, device_info, installation_id) + if TYPE_CHECKING: + assert metric.enum_values, "Select metric will always have enum values" + self._attr_options = metric.enum_values + self._attr_current_option = VictronSelect._normalize_value(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_current_option = VictronSelect._normalize_value(value) + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + _LOGGER.debug("Setting select %s to %s", self._attr_unique_id, option) + self._metric.set(option) + + @staticmethod + def _normalize_value(value: Any) -> Any: + """Normalize Victron enum values to their enum code.""" + return value.id if isinstance(value, VictronEnum) else value diff --git a/homeassistant/components/victron_gx/sensor.py b/homeassistant/components/victron_gx/sensor.py index 35a371fbe04..c8b65fc0a4c 100644 --- a/homeassistant/components/victron_gx/sensor.py +++ b/homeassistant/components/victron_gx/sensor.py @@ -38,6 +38,7 @@ METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, SensorDeviceClass] = { MetricType.LIQUID_VOLUME: SensorDeviceClass.VOLUME_STORAGE, MetricType.DURATION: SensorDeviceClass.DURATION, MetricType.ENUM: SensorDeviceClass.ENUM, + MetricType.IRRADIANCE: SensorDeviceClass.IRRADIANCE, } METRIC_NATURE_TO_STATE_CLASS: dict[MetricNature, SensorStateClass] = { @@ -96,11 +97,7 @@ class VictronSensor(VictronBaseEntity, SensorEntity): self._attr_state_class = METRIC_NATURE_TO_STATE_CLASS.get( metric.metric_nature ) - # Only set native_unit_of_measurement when a device_class is present. - # Entities without a device_class get their display unit from - # the translation files instead. - if self._attr_device_class is not None: - self._attr_native_unit_of_measurement = metric.unit_of_measurement + self._attr_native_unit_of_measurement = self._native_unit_of_measurement() self._attr_native_value = VictronSensor._normalize_value(metric.value) @callback diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index cb7eadda9c0..4cb27e94c5a 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -1,8 +1,113 @@ { + "common": { + "absorption": "Absorption", + "active_ac_input": "Active AC input", + "alarm": "Alarm", + "auto_equalize_recondition": "Auto equalize / recondition", + "battery_safe": "Battery Safe", + "battery_voltage_too_high": "Battery voltage too high", + "bms_connection_lost": "BMS connection lost", + "bulk": "Bulk", + "bulk_time_limit_exceeded": "Bulk time limit exceeded", + "charge_current_limit": "Charge current limit", + "charger_current_reversed": "Charger current reversed", + "charger_only": "Charger only", + "charger_over_current": "Charger over current", + "charger_temperature_too_high": "Charger temperature too high", + "consumption": "Consumption", + "consumption_on_phase": "Consumption on {phase}", + "converter_issue": "Converter issue", + "current": "Current", + "current_limit": "Current limit", + "current_on_phase": "Current on {phase}", + "current_phase": "Current {phase}", + "current_sensor_issue": "Current sensor issue", + "dc_output_current": "DC output current", + "dc_output_power": "DC output power", + "dc_output_voltage": "DC output voltage", + "dc_temperature": "DC temperature", + "equalize": "Equalize", + "error_code": "Error code", + "ess_mode": "ESS mode", + "external_control": "External control", + "factory_calibration_data_lost": "Factory calibration data lost", + "float": "Float", + "frequency": "Frequency", + "generator": "Generator", + "grid": "Grid", + "high_temperature_alarm": "High temperature alarm", + "input_current": "Input current", + "input_current_too_high_solar_panel": "Input current too high (solar panel)", + "input_power": "Input power", + "input_shutdown_battery_voltage_too_high": "Input shutdown (battery voltage too high)", + "input_shutdown_reverse_current": "Input shutdown (reverse current)", + "input_voltage": "Input voltage", + "input_voltage_too_high_solar_panel": "Input voltage too high (solar panel)", + "invalid_incompatible_firmware": "Invalid/incompatible firmware", + "inverter_only": "Inverter only", + "inverting": "Inverting", + "lost_communication_with_device": "Lost communication with device", + "low_battery_alarm": "Low battery alarm", + "low_power": "Low power", + "max_power_today": "Max power today", + "max_power_yesterday": "Max power yesterday", + "mppt_active": "MPPT active", + "network_misconfigured": "Network misconfigured", + "no_alarm": "No alarm", + "no_error": "No error", + "not_available": "Not available", + "not_connected": "Not connected", + "not_used": "Not used", + "ok": "Ok", + "output_apparent_power_phase": "Output apparent power {phase}", + "output_current_phase": "Output current {phase}", + "output_power_phase": "Output power {phase}", + "output_voltage_phase": "Output voltage {phase}", + "overload_alarm": "Overload alarm", + "passthrough": "Passthrough", + "power": "Power", + "power_assist": "Power Assist", + "power_on_phase": "Power on {phase}", + "power_phase": "Power {phase}", + "power_supply": "Power supply", + "pv_bus_voltage": "PV bus voltage", + "pv_current": "PV current", + "pv_power_total": "PV power total", + "recharging": "Recharging", + "repeated_absorption": "Repeated absorption", + "reserved": "Reserved", + "ripple_alarm": "Ripple alarm", + "scheduled_recharging": "Scheduled recharging", + "self_consumption": "Self-consumption", + "sensor_battery_voltage": "Sensor battery voltage", + "shore": "Shore", + "shore_power": "Shore power", + "starting_up": "Starting up", + "state": "State", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain alt", + "synchronized_charging_config_issue": "Synchronized charging config issue", + "temperature": "Temperature", + "terminals_overheated": "Terminals overheated", + "total_pv_yield_user": "Total PV yield user", + "total_yield": "Total yield", + "unknown": "Unknown", + "user_settings_invalid": "User settings invalid", + "voltage": "Voltage", + "voltage_current_limited": "Voltage/current limited", + "voltage_on_phase": "Voltage on {phase}", + "warning": "Warning", + "yield_today": "Yield today", + "yield_yesterday": "Yield yesterday" + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The device at this address is different from the originally configured device.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { @@ -11,6 +116,36 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", + "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + }, + "description": "Please re-authenticate with {host}.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::victron_gx::config::step::user::data_description::host%]", + "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", + "port": "[%key:component::victron_gx::config::step::user::data_description::port%]", + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", + "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + } + }, "ssdp_auth": { "data": { "password": "[%key:common::config_flow::data::password%]", @@ -47,89 +182,381 @@ } }, "entity": { + "binary_sensor": { + "evcharger_connected": { + "name": "[%key:common::state::connected%]" + }, + "gps_connected": { + "name": "[%key:common::state::connected%]" + }, + "inverter_alarm_high_temperature": { + "name": "[%key:component::victron_gx::common::high_temperature_alarm%]" + }, + "inverter_alarm_high_voltage": { + "name": "High voltage alarm" + }, + "inverter_alarm_high_voltage_ac_out": { + "name": "High voltage AC-out alarm" + }, + "inverter_alarm_low_temperature": { + "name": "Low temperature alarm" + }, + "inverter_alarm_low_voltage": { + "name": "Low voltage alarm" + }, + "inverter_alarm_low_voltage_ac_out": { + "name": "Low voltage AC-out alarm" + }, + "inverter_alarm_overload": { + "name": "[%key:component::victron_gx::common::overload_alarm%]" + }, + "inverter_alarm_ripple": { + "name": "[%key:component::victron_gx::common::ripple_alarm%]" + }, + "solarcharger_load_state": { + "name": "Load state" + }, + "switch_output_state": { + "name": "[%key:component::victron_gx::common::state%]" + }, + "system_dynamicess_active": { + "name": "Dynamic ESS active" + }, + "system_dynamicess_allow_gridfeedin": { + "name": "Dynamic ESS allow grid feed-in" + }, + "system_dynamicess_available": { + "name": "Dynamic ESS available" + }, + "vebus_inverter_connected": { + "name": "[%key:common::state::connected%]" + }, + "vebus_inverter_remote_generator_selected": { + "name": "Remote generator selected" + } + }, + "button": { + "platform_device_reboot": { + "name": "Device reboot" + } + }, + "device_tracker": { + "gps_location": { + "name": "[%key:common::config_flow::data::location%]" + } + }, + "number": { + "alternator_charge_current_limit": { + "name": "[%key:component::victron_gx::common::charge_current_limit%]" + }, + "evcharger_set_current": { + "name": "Charge current setpoint" + }, + "generator_gen_id_cool_down_timer": { + "name": "Generator cooldown timer" + }, + "generator_gen_id_qh_start_on_soc": { + "name": "Generator QH start on SoC" + }, + "generator_gen_id_qh_start_on_voltage": { + "name": "Generator QH start on voltage" + }, + "generator_gen_id_qh_stop_on_soc": { + "name": "Generator QH stop on SoC" + }, + "generator_gen_id_qh_stop_on_voltage": { + "name": "Generator QH stop on voltage" + }, + "generator_gen_id_service_interval": { + "name": "Generator service interval" + }, + "generator_gen_id_shut_down_timer": { + "name": "Generator shutdown timer" + }, + "generator_gen_id_start_on_soc": { + "name": "Generator start on SoC" + }, + "generator_gen_id_start_on_soc_timer": { + "name": "Generator start on SoC timer" + }, + "generator_gen_id_start_on_temp_timer": { + "name": "Generator start on temp timer" + }, + "generator_gen_id_start_on_voltage": { + "name": "Generator start on voltage" + }, + "generator_gen_id_start_on_voltage_timer": { + "name": "Generator start on voltage timer" + }, + "generator_gen_id_stop_on_soc": { + "name": "Generator stop on SoC" + }, + "generator_gen_id_stop_on_soc_timer": { + "name": "Generator stop on SoC timer" + }, + "generator_gen_id_stop_on_temp_timer": { + "name": "Generator stop on temp timer" + }, + "generator_gen_id_stop_on_voltage": { + "name": "Generator stop on voltage" + }, + "generator_gen_id_stop_on_voltage_timer": { + "name": "Generator stop on voltage timer" + }, + "generator_gen_id_warm_up_timer": { + "name": "Generator warm-up timer" + }, + "hub4_ac_grid_setpoint": { + "name": "AC grid setpoint" + }, + "multi_ess_ac_power_setpoint": { + "name": "ESS AC power setpoint" + }, + "multi_ess_min_soc_limit": { + "name": "ESS minimum SoC limit" + }, + "multi_shore_current_limit": { + "name": "Shore current limit" + }, + "multiplus_assist_current_boost_factor": { + "name": "Assist current boost factor" + }, + "solarcharger_charge_current_limit": { + "name": "[%key:component::victron_gx::common::charge_current_limit%]" + }, + "switch_output_dimming": { + "name": "Dimming" + }, + "switch_output_state": { + "name": "[%key:component::victron_gx::common::state%]" + }, + "system_ac_export_limit": { + "name": "AC export limit" + }, + "system_ac_input_limit": { + "name": "AC input limit" + }, + "system_ac_power_set_point": { + "name": "AC power setpoint" + }, + "system_ess_max_charge_current": { + "name": "ESS max charge current" + }, + "system_ess_max_charge_power": { + "name": "ESS max charge power limit" + }, + "system_ess_max_charge_voltage": { + "name": "ESS max charge voltage" + }, + "system_ess_max_feed_in_power": { + "name": "ESS max feed-in power" + }, + "system_ess_max_inverter_power_limit": { + "name": "ESS max inverter power limit" + }, + "system_ess_min_soc_limit": { + "name": "ESS min SoC limit" + }, + "system_ess_schedule_charge_slot_duration": { + "name": "ESS BatteryLife schedule charge {slot} duration" + }, + "system_ess_schedule_charge_slot_soc": { + "name": "ESS BatteryLife schedule charge {slot} SoC" + }, + "temperature_offset": { + "name": "Offset" + }, + "temperature_scale": { + "name": "Scale factor" + }, + "transfer_switch_generator_current_limit": { + "name": "Generator AC current limit" + }, + "vebus_ac_power_setpoint_phase": { + "name": "AC power setpoint {phase}" + }, + "vebus_inverter_current_limit": { + "name": "[%key:component::victron_gx::common::current_limit%]" + } + }, + "select": { + "acsystem_mode": { + "state": { + "charger_only": "[%key:component::victron_gx::common::charger_only%]", + "inverter_only": "[%key:component::victron_gx::common::inverter_only%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]" + } + }, + "evcharger_mode": { + "name": "[%key:common::config_flow::data::mode%]", + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "scheduled_charge": "Scheduled charge" + } + }, + "inverter_mode": { + "state": { + "eco": "Eco", + "inverter": "Inverter", + "off": "[%key:common::state::off%]" + } + }, + "switch_output_state": { + "name": "[%key:component::victron_gx::common::state%]" + }, + "system_ac_input_1_type": { + "name": "AC input 1 source type", + "state": { + "generator": "[%key:component::victron_gx::common::generator%]", + "grid": "[%key:component::victron_gx::common::grid%]", + "not_used": "[%key:component::victron_gx::common::not_used%]", + "shore": "[%key:component::victron_gx::common::shore%]" + } + }, + "system_ac_input_2_type": { + "name": "AC input 2 source type", + "state": { + "generator": "[%key:component::victron_gx::common::generator%]", + "grid": "[%key:component::victron_gx::common::grid%]", + "not_used": "[%key:component::victron_gx::common::not_used%]", + "shore": "[%key:component::victron_gx::common::shore%]" + } + }, + "system_ess_batterylife_state": { + "name": "[%key:component::victron_gx::common::ess_mode%]", + "state": { + "external_control": "[%key:component::victron_gx::common::external_control%]", + "keep_batteries_charged": "Keep batteries charged", + "optimized_battery_life": "Optimized (with BatteryLife)", + "optimized_no_battery_life": "Optimized (without BatteryLife)" + } + }, + "system_ess_mode": { + "name": "ESS mode (Hub4)", + "state": { + "external_control": "[%key:component::victron_gx::common::external_control%]", + "phase_compensation_disabled": "Optimized mode or 'keep batteries charged' and phase compensation disabled", + "phase_compensation_enabled": "Optimized mode or 'keep batteries charged' and phase compensation enabled" + } + }, + "system_ess_schedule_charge_slot_days": { + "name": "ESS BatteryLife schedule charge {slot} days", + "state": { + "disabled_every_day": "Disabled (Every day)", + "disabled_friday": "Disabled (Friday)", + "disabled_monday": "Disabled (Monday)", + "disabled_saturday": "Disabled (Saturday)", + "disabled_sunday": "Disabled (Sunday)", + "disabled_thursday": "Disabled (Thursday)", + "disabled_tuesday": "Disabled (Tuesday)", + "disabled_wednesday": "Disabled (Wednesday)", + "disabled_weekdays": "Disabled (Weekdays)", + "disabled_weekend": "Disabled (Weekends)", + "every_day": "Every day", + "friday": "[%key:common::time::friday%]", + "monday": "[%key:common::time::monday%]", + "saturday": "[%key:common::time::saturday%]", + "sunday": "[%key:common::time::sunday%]", + "thursday": "[%key:common::time::thursday%]", + "tuesday": "[%key:common::time::tuesday%]", + "wednesday": "[%key:common::time::wednesday%]", + "weekdays": "Weekdays", + "weekends": "Weekends" + } + }, + "system_settings_dess_mode": { + "name": "DESS mode", + "state": { + "auto_vrm": "Auto / VRM", + "buy": "Buy", + "node_red": "Node-RED", + "off": "[%key:common::state::off%]", + "sell": "Sell" + } + }, + "vebus_inverter_mode": { + "state": { + "charger_only": "[%key:component::victron_gx::common::charger_only%]", + "inverter_only": "[%key:component::victron_gx::common::inverter_only%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } + }, "sensor": { "acload_current": { "name": "Load current" }, "acload_current_phase": { - "name": "Current on {phase}" + "name": "[%key:component::victron_gx::common::current_on_phase%]" }, "acload_energy_forward": { - "name": "Consumption" + "name": "[%key:component::victron_gx::common::consumption%]" }, "acload_energy_forward_phase": { - "name": "Consumption on {phase}" + "name": "[%key:component::victron_gx::common::consumption_on_phase%]" }, "acload_frequency": { - "name": "Frequency" + "name": "[%key:component::victron_gx::common::frequency%]" }, "acload_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "acload_power_phase": { - "name": "Power on {phase}" + "name": "[%key:component::victron_gx::common::power_on_phase%]" }, "acload_voltage": { - "name": "Voltage" + "name": "[%key:component::victron_gx::common::voltage%]" }, "acload_voltage_phase": { - "name": "Voltage on {phase}" - }, - "acsystem_mode": { - "state": { - "charger_only": "Charger only", - "inverter_only": "Inverter only", - "off": "Off", - "on": "On", - "passthrough": "Passthrough" - } - }, - "alternator_charge_current_limit": { - "name": "Charge current limit" + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" }, "alternator_dc_current": { - "name": "DC output current" + "name": "[%key:component::victron_gx::common::dc_output_current%]" }, "alternator_dc_power": { - "name": "DC output power" + "name": "[%key:component::victron_gx::common::dc_output_power%]" }, "alternator_dc_voltage": { - "name": "DC output voltage" + "name": "[%key:component::victron_gx::common::dc_output_voltage%]" }, "alternator_input_current": { - "name": "Input current" + "name": "[%key:component::victron_gx::common::input_current%]" }, "alternator_input_power": { - "name": "Input power" + "name": "[%key:component::victron_gx::common::input_power%]" }, "alternator_input_voltage": { - "name": "Input voltage" + "name": "[%key:component::victron_gx::common::input_voltage%]" }, "alternator_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "auxiliary_battery_voltage": { @@ -143,8 +570,7 @@ "name": "Average discharge" }, "battery_capacity": { - "name": "Capacity", - "unit_of_measurement": "Ah" + "name": "Capacity" }, "battery_cell_cell_id_voltage": { "name": "Cell {cell_id} voltage" @@ -152,9 +578,9 @@ "battery_cell_imbalance": { "name": "Cell imbalance", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_cell_voltage_deviation": { @@ -164,12 +590,10 @@ "name": "Charged energy" }, "battery_consumed_amphours": { - "name": "Consumed amp-hours", - "unit_of_measurement": "Ah" + "name": "Consumed amp-hours" }, "battery_cumulative_ah_drawn": { - "name": "Cumulative Ah drawn", - "unit_of_measurement": "Ah" + "name": "Cumulative Ah drawn" }, "battery_current": { "name": "DC bus current" @@ -183,37 +607,36 @@ "battery_high_charge_current": { "name": "High charge current", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_high_charge_temperature": { "name": "High charge temperature", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_high_discharge_current": { "name": "High discharge current", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_installed_capacity": { - "name": "Installed capacity", - "unit_of_measurement": "Ah" + "name": "Installed capacity" }, "battery_internal_failure": { "name": "Internal failure", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_last_discharge": { @@ -222,17 +645,17 @@ "battery_low_cell_voltage": { "name": "Low cell voltage", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_low_charge_temperature": { "name": "Low charge temperature", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "battery_max_cell_temperature": { @@ -263,8 +686,7 @@ "name": "DC bus mid voltage" }, "battery_mid_voltage_deviation": { - "name": "DC bus mid voltage deviation", - "unit_of_measurement": "%" + "name": "DC bus mid voltage deviation" }, "battery_min_cell_temperature": { "name": "Minimum cell temperature" @@ -298,21 +720,19 @@ "unit_of_measurement": "modules" }, "battery_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "battery_soc": { "name": "Charge" }, "battery_soh": { - "name": "State of health", - "unit_of_measurement": "%" + "name": "State of health" }, "battery_temperature": { - "name": "Temperature" + "name": "[%key:component::victron_gx::common::temperature%]" }, "battery_time_since_last_full_charge": { - "name": "Time since last full charge", - "unit_of_measurement": "seconds" + "name": "Time since last full charge" }, "battery_time_to_go": { "name": "Time to go" @@ -327,35 +747,38 @@ "charge_mode": { "name": "Charge mode" }, - "charger_dc_current": { - "name": "DC output current" + "charger_ac_in_current_phase": { + "name": "AC input current {phase}" }, - "charger_dc_voltage": { - "name": "DC output voltage" + "charger_dc_current_output": { + "name": "DC output {output} current" + }, + "charger_dc_voltage_output": { + "name": "DC output {output} voltage" }, "charger_error_code": { - "name": "Error code", + "name": "[%key:component::victron_gx::common::error_code%]", "state": { - "battery_voltage_too_high": "Battery voltage too high", - "bms_connection_lost": "BMS connection lost", - "bulk_time_limit_exceeded": "Bulk time limit exceeded", - "charger_current_reversed": "Charger current reversed", - "charger_over_current": "Charger over current", - "charger_temperature_too_high": "Charger temperature too high", - "converter_issue": "Converter issue", - "current_sensor_issue": "Current sensor issue", - "factory_calibration_data_lost": "Factory calibration data lost", - "input_current_too_high": "Input current too high (solar panel)", - "input_shutdown_battery_voltage_too_high": "Input shutdown (battery voltage too high)", - "input_shutdown_reverse_current": "Input shutdown (reverse current)", - "input_voltage_too_high": "Input voltage too high (solar panel)", - "invalid_incompatible_firmware": "Invalid/incompatible firmware", - "lost_communication_with_device": "Lost communication with device", - "network_misconfigured": "Network misconfigured", - "no_error": "No error", - "synchronized_charging_config_issue": "Synchronized charging config issue", - "terminals_overheated": "Terminals overheated", - "user_settings_invalid": "User settings invalid" + "battery_voltage_too_high": "[%key:component::victron_gx::common::battery_voltage_too_high%]", + "bms_connection_lost": "[%key:component::victron_gx::common::bms_connection_lost%]", + "bulk_time_limit_exceeded": "[%key:component::victron_gx::common::bulk_time_limit_exceeded%]", + "charger_current_reversed": "[%key:component::victron_gx::common::charger_current_reversed%]", + "charger_over_current": "[%key:component::victron_gx::common::charger_over_current%]", + "charger_temperature_too_high": "[%key:component::victron_gx::common::charger_temperature_too_high%]", + "converter_issue": "[%key:component::victron_gx::common::converter_issue%]", + "current_sensor_issue": "[%key:component::victron_gx::common::current_sensor_issue%]", + "factory_calibration_data_lost": "[%key:component::victron_gx::common::factory_calibration_data_lost%]", + "input_current_too_high": "[%key:component::victron_gx::common::input_current_too_high_solar_panel%]", + "input_shutdown_battery_voltage_too_high": "[%key:component::victron_gx::common::input_shutdown_battery_voltage_too_high%]", + "input_shutdown_reverse_current": "[%key:component::victron_gx::common::input_shutdown_reverse_current%]", + "input_voltage_too_high": "[%key:component::victron_gx::common::input_voltage_too_high_solar_panel%]", + "invalid_incompatible_firmware": "[%key:component::victron_gx::common::invalid_incompatible_firmware%]", + "lost_communication_with_device": "[%key:component::victron_gx::common::lost_communication_with_device%]", + "network_misconfigured": "[%key:component::victron_gx::common::network_misconfigured%]", + "no_error": "[%key:component::victron_gx::common::no_error%]", + "synchronized_charging_config_issue": "[%key:component::victron_gx::common::synchronized_charging_config_issue%]", + "terminals_overheated": "[%key:component::victron_gx::common::terminals_overheated%]", + "user_settings_invalid": "[%key:component::victron_gx::common::user_settings_invalid%]" } }, "charger_nr_of_outputs": { @@ -363,128 +786,128 @@ "unit_of_measurement": "outputs" }, "charger_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "dcdc_dc_current": { - "name": "DC output current" + "name": "[%key:component::victron_gx::common::dc_output_current%]" }, "dcdc_dc_power": { - "name": "DC output power" + "name": "[%key:component::victron_gx::common::dc_output_power%]" }, "dcdc_dc_voltage": { - "name": "DC output voltage" + "name": "[%key:component::victron_gx::common::dc_output_voltage%]" }, "dcdc_input_current": { - "name": "Input current" + "name": "[%key:component::victron_gx::common::input_current%]" }, "dcdc_input_power": { - "name": "Input power" + "name": "[%key:component::victron_gx::common::input_power%]" }, "dcdc_input_voltage": { - "name": "Input voltage" + "name": "[%key:component::victron_gx::common::input_voltage%]" }, "dcdc_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "dcload_current": { - "name": "Current" + "name": "[%key:component::victron_gx::common::current%]" }, "dcload_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "dcload_voltage": { - "name": "Voltage" + "name": "[%key:component::victron_gx::common::voltage%]" }, "dcsystem_aux_voltage": { "name": "Auxiliary voltage" }, "dcsystem_current": { - "name": "Current" + "name": "[%key:component::victron_gx::common::current%]" }, "dcsystem_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "dcsystem_voltage": { - "name": "Voltage" + "name": "[%key:component::victron_gx::common::voltage%]" }, "digitalinput_alarm": { - "name": "Alarm", + "name": "[%key:component::victron_gx::common::alarm%]", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "digitalinput_input_state_raw": { "name": "Raw state", "state": { - "high_open": "High/Open", - "low_closed": "Low/Closed" + "high_open": "High/open", + "low_closed": "Low/closed" } }, "digitalinput_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "alarm": "Alarm", - "closed": "Closed", - "high": "High", - "low": "Low", - "no": "No", - "off": "Off", - "ok": "Ok", - "on": "On", - "open": "Open", + "alarm": "[%key:component::victron_gx::common::alarm%]", + "closed": "[%key:common::state::closed%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "no": "[%key:common::state::no%]", + "off": "[%key:common::state::off%]", + "ok": "[%key:component::victron_gx::common::ok%]", + "on": "[%key:common::state::on%]", + "open": "[%key:common::state::open%]", "running": "Running", - "stopped": "Stopped", - "yes": "Yes" + "stopped": "[%key:common::state::stopped%]", + "yes": "[%key:common::state::yes%]" } }, "digitalinput_type": { @@ -494,17 +917,17 @@ "bilge_pump": "Bilge pump", "burglar_alarm": "Burglar alarm", "co2_alarm": "CO2 alarm", - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "door_alarm": "Door alarm", "fire_alarm": "Fire alarm", - "generator": "Generator", + "generator": "[%key:component::victron_gx::common::generator%]", "pulse_meter": "Pulse meter", "smoke_alarm": "Smoke alarm", "touch_input_control": "Touch input control" } }, "evcharger_current": { - "name": "Current" + "name": "[%key:component::victron_gx::common::current%]" }, "evcharger_max_set_current": { "name": "Maximum set current" @@ -512,26 +935,18 @@ "evcharger_min_set_current": { "name": "Minimum set current" }, - "evcharger_mode": { - "name": "Mode", - "state": { - "auto": "Auto", - "manual": "Manual", - "scheduled_charge": "Scheduled Charge" - } - }, "evcharger_position": { "name": "Position", "state": { - "ac_input": "AC Input", - "ac_out": "AC Out" + "ac_input": "AC input", + "ac_out": "AC out" } }, "evcharger_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "evcharger_power_phase": { - "name": "Power {phase}" + "name": "[%key:component::victron_gx::common::power_phase%]" }, "evcharger_session_cost": { "name": "Last session cost", @@ -543,27 +958,24 @@ "evcharger_session_time": { "name": "Last session time" }, - "evcharger_set_current": { - "name": "Set current" - }, "evcharger_status": { "name": "Status", "state": { "charged": "Charged", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_limit": "Charging limit", - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "cp_input_test_error": "CP input test error", - "disconnected": "Disconnected", + "disconnected": "[%key:common::state::disconnected%]", "ground_test_error": "Ground test error", - "low_soc": "Low SOC", + "low_soc": "Low SoC", "overheating_detected": "Overheating detected", "overvoltage_detected": "Overvoltage detected", - "reserved15": "Reserved", - "reserved16": "Reserved", - "reserved17": "Reserved", - "reserved18": "Reserved", - "reserved19": "reserved", + "reserved15": "[%key:component::victron_gx::common::reserved%]", + "reserved16": "[%key:component::victron_gx::common::reserved%]", + "reserved17": "[%key:component::victron_gx::common::reserved%]", + "reserved18": "[%key:component::victron_gx::common::reserved%]", + "reserved19": "[%key:component::victron_gx::common::reserved%]", "residual_current_detected": "Residual current detected", "start_charging": "Start charging", "switching_to_1_phase": "Switching to 1 phase", @@ -578,74 +990,23 @@ "evcharger_total_energy": { "name": "Total energy" }, - "generator_gen_id_cool_down_timer": { - "name": "Generator cooldown timer" - }, - "generator_gen_id_qh_start_on_soc": { - "name": "Generator QH start on SOC" - }, - "generator_gen_id_qh_start_on_voltage": { - "name": "Generator QH start on voltage" - }, - "generator_gen_id_qh_stop_on_soc": { - "name": "Generator QH stop on SOC" - }, - "generator_gen_id_qh_stop_on_voltage": { - "name": "Generator QH stop on voltage" - }, - "generator_gen_id_service_interval": { - "name": "Generator service interval" - }, - "generator_gen_id_shut_down_timer": { - "name": "Generator shutdown timer" - }, - "generator_gen_id_start_on_soc": { - "name": "Generator start on SOC" - }, - "generator_gen_id_start_on_soc_timer": { - "name": "Generator start on SOC timer" - }, - "generator_gen_id_start_on_temp_timer": { - "name": "Generator start on temp timer" - }, - "generator_gen_id_start_on_voltage": { - "name": "Generator start on voltage" - }, - "generator_gen_id_start_on_voltage_timer": { - "name": "Generator start on voltage timer" - }, - "generator_gen_id_stop_on_soc": { - "name": "Generator stop on SOC" - }, - "generator_gen_id_stop_on_soc_timer": { - "name": "Generator stop on SOC timer" - }, - "generator_gen_id_stop_on_temp_timer": { - "name": "Generator stop on temp timer" - }, - "generator_gen_id_stop_on_voltage": { - "name": "Generator stop on voltage" - }, - "generator_gen_id_stop_on_voltage_timer": { - "name": "Generator stop on voltage timer" - }, - "generator_gen_id_warm_up_timer": { - "name": "Generator warm-up timer" + "generator_next_test_run": { + "name": "Next test run" }, "generator_run_state": { "name": "Run state", "state": { - "ac_load": "AC Load", - "battery_current": "Battery Current", - "battery_volts": "Battery Volts", - "inv_overload": "Inv Overload", - "inv_temp": "Inv Temp", - "lost_comms": "Lost Comms", - "manual": "Manual", - "soc": "SOC", - "stop_on_ac1": "Stop On AC1", - "stopped": "Stopped", - "test_run": "Test Run" + "ac_load": "AC load", + "battery_current": "Battery current", + "battery_volts": "Battery volts", + "inv_overload": "Inverter overload", + "inv_temp": "Inverter temperature", + "lost_comms": "Lost comms", + "manual": "[%key:common::state::manual%]", + "soc": "SoC", + "stop_on_ac1": "Stop on AC1", + "stopped": "[%key:common::state::stopped%]", + "test_run": "Test run" } }, "generator_service_counter": { @@ -657,40 +1018,21 @@ "generator_total_runtime": { "name": "Total runtime" }, - "gps_altitude": { - "name": "Altitude", - "unit_of_measurement": "m" - }, - "gps_course": { - "name": "Course", - "unit_of_measurement": "°" - }, - "gps_latitude": { - "name": "Latitude", - "unit_of_measurement": "lat" - }, - "gps_longitude": { - "name": "Longitude", - "unit_of_measurement": "long" - }, "gps_nrofsatellites": { "name": "Number of satellites", "unit_of_measurement": "satellites" }, - "gps_speed": { - "name": "Speed" - }, "grid_current": { - "name": "Current" + "name": "[%key:component::victron_gx::common::current%]" }, "grid_current_n": { "name": "Current on N" }, "grid_current_phase": { - "name": "Current on {phase}" + "name": "[%key:component::victron_gx::common::current_on_phase%]" }, "grid_energy_forward": { - "name": "Consumption" + "name": "[%key:component::victron_gx::common::consumption%]" }, "grid_energy_forward_phase": { "name": "Grid consumption on {phase}" @@ -702,121 +1044,135 @@ "name": "Feed-in on {phase}" }, "grid_frequency": { - "name": "Frequency" + "name": "[%key:component::victron_gx::common::frequency%]" }, "grid_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "grid_power_factor": { - "name": "Power factor", - "unit_of_measurement": "factor" + "name": "Power factor" }, "grid_power_factor_phase": { - "name": "Power factor on {phase}", - "unit_of_measurement": "factor" + "name": "Power factor on {phase}" }, "grid_power_phase": { - "name": "Power on {phase}" + "name": "[%key:component::victron_gx::common::power_on_phase%]" }, "grid_voltage": { - "name": "Voltage" + "name": "[%key:component::victron_gx::common::voltage%]" }, "grid_voltage_pen": { "name": "Voltage on PEN" }, "grid_voltage_phase": { - "name": "Voltage on {phase}" + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" }, "grid_voltage_phase_next_phase": { "name": "Voltage {phase} to {next_phase}" }, "heatpump_current": { - "name": "Current" + "name": "[%key:component::victron_gx::common::current%]" }, "heatpump_current_phase": { - "name": "Current on {phase}" + "name": "[%key:component::victron_gx::common::current_on_phase%]" }, "heatpump_energy_forward": { - "name": "Consumption" + "name": "[%key:component::victron_gx::common::consumption%]" }, "heatpump_energy_forward_phase": { - "name": "Consumption on {phase}" + "name": "[%key:component::victron_gx::common::consumption_on_phase%]" }, "heatpump_frequency": { - "name": "Frequency" + "name": "[%key:component::victron_gx::common::frequency%]" }, "heatpump_power": { - "name": "Power" + "name": "[%key:component::victron_gx::common::power%]" }, "heatpump_power_phase": { - "name": "Power on {phase}" + "name": "[%key:component::victron_gx::common::power_on_phase%]" }, "heatpump_voltage": { - "name": "Voltage" + "name": "[%key:component::victron_gx::common::voltage%]" }, "heatpump_voltage_phase": { - "name": "Voltage on {phase}" - }, - "hub4_ac_grid_setpoint": { - "name": "AC grid setpoint" - }, - "inverter_mode": { - "state": { - "eco": "Eco", - "inverter": "Inverter", - "off": "Off" - } + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" }, "inverter_output_apparent_power_phase": { - "name": "Output apparent power {phase}" + "name": "[%key:component::victron_gx::common::output_apparent_power_phase%]" }, "inverter_output_current_phase": { - "name": "Output current {phase}" + "name": "[%key:component::victron_gx::common::output_current_phase%]" }, "inverter_output_power_phase": { - "name": "Output power {phase}" + "name": "[%key:component::victron_gx::common::output_power_phase%]" }, "inverter_output_voltage_phase": { - "name": "Output voltage {phase}" + "name": "[%key:component::victron_gx::common::output_voltage_phase%]" }, "inverter_pv_power_total": { - "name": "PV power total" + "name": "[%key:component::victron_gx::common::pv_power_total%]" }, "inverter_pv_voltage": { - "name": "PV bus voltage" + "name": "[%key:component::victron_gx::common::pv_bus_voltage%]" }, "inverter_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "inverter_total_pv_yield_system": { "name": "Total PV yield system" }, "inverter_total_pv_yield_user": { - "name": "Total PV yield user" + "name": "[%key:component::victron_gx::common::total_pv_yield_user%]" + }, + "meteo_alarm_low_battery": { + "name": "[%key:component::victron_gx::common::low_battery_alarm%]", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "meteo_battery_voltage": { + "name": "Battery voltage" + }, + "meteo_cell_temperature": { + "name": "Cell temperature" + }, + "meteo_installation_power": { + "name": "Installation power" + }, + "meteo_irradiance": { + "name": "Irradiance" + }, + "meteo_time_since_last_sun": { + "name": "Time since last sun" + }, + "meteo_todays_yield": { + "name": "Today's yield" }, "multi_acin1_to_acout": { "name": "AC-in-1 to AC-out" @@ -825,13 +1181,16 @@ "name": "AC-in-1 to inverter" }, "multi_acin_current_phase": { - "name": "Current {phase}" + "name": "[%key:component::victron_gx::common::current_phase%]" }, "multi_acin_power_phase": { - "name": "Power on {phase}" + "name": "[%key:component::victron_gx::common::power_on_phase%]" }, "multi_acin_voltage_phase": { - "name": "Voltage on {phase}" + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + }, + "multi_acout_current_phase": { + "name": "Output current on {phase}" }, "multi_acout_output_current_phase": { "name": "AC-out-{output} current on {phase}" @@ -842,33 +1201,36 @@ "multi_acout_output_voltage_phase": { "name": "AC-out-{output} voltage on {phase}" }, + "multi_acout_power_phase": { + "name": "Output power on {phase}" + }, "multi_acout_to_acin1": { "name": "AC-out to AC-in-1" }, "multi_acout_to_inverter": { "name": "AC-out to inverter" }, + "multi_acout_voltage_phase": { + "name": "Output voltage on {phase}" + }, "multi_active_input": { - "name": "Active AC input", + "name": "[%key:component::victron_gx::common::active_ac_input%]", "state": { - "ac_input_1": "AC Input 1", - "ac_input_2": "AC Input 2", - "disconnected": "Disconnected" + "ac_input_1": "AC input 1", + "ac_input_2": "AC input 2", + "disconnected": "[%key:common::state::disconnected%]" } }, - "multi_ess_ac_power_setpoint": { - "name": "ESS AC power setpoint" - }, - "multi_ess_min_soc_limit": { - "name": "ESS minimum SOC limit" + "multi_dc_temperature": { + "name": "[%key:component::victron_gx::common::dc_temperature%]" }, "multi_ess_mode": { - "name": "ESS mode", + "name": "[%key:component::victron_gx::common::ess_mode%]", "state": { - "external_control": "External control", - "keep_charged": "keep charged", - "self_consumption": "self consumption", - "self_consumption_batterylife": "self consumption (batterylife)" + "external_control": "[%key:component::victron_gx::common::external_control%]", + "keep_charged": "Keep charged", + "self_consumption": "[%key:component::victron_gx::common::self_consumption%]", + "self_consumption_batterylife": "Self-consumption (BatteryLife)" } }, "multi_inverter_power_setpoint": { @@ -881,10 +1243,10 @@ "name": "Inverter to AC-out" }, "multi_max_power_today": { - "name": "Max power today" + "name": "[%key:component::victron_gx::common::max_power_today%]" }, "multi_max_power_yesterday": { - "name": "Max power yesterday" + "name": "[%key:component::victron_gx::common::max_power_yesterday%]" }, "multi_mppt_mppt_id_yield_today": { "name": "MPPT {mppt_id} yield today" @@ -892,15 +1254,18 @@ "multi_mppt_mppt_id_yield_yesterday": { "name": "MPPT {mppt_id} yield yesterday" }, + "multi_mppt_mpptnumber_current": { + "name": "MPPT {mpptnumber} current" + }, "multi_mppt_mpptnumber_power": { "name": "MPPT {mpptnumber} power" }, "multi_mppt_mpptnumber_state": { "state": { - "mppt_active": "MPPT active", - "not_available": "Not available", - "off": "Off", - "voltage_current_limited": "Voltage/current limited" + "mppt_active": "[%key:component::victron_gx::common::mppt_active%]", + "not_available": "[%key:component::victron_gx::common::not_available%]", + "off": "[%key:common::state::off%]", + "voltage_current_limited": "[%key:component::victron_gx::common::voltage_current_limited%]" } }, "multi_mppt_mpptnumber_voltage": { @@ -911,10 +1276,7 @@ "unit_of_measurement": "phases" }, "multi_pv_power_total": { - "name": "PV power total" - }, - "multi_shore_current_limit": { - "name": "Shore current limit" + "name": "[%key:component::victron_gx::common::pv_power_total%]" }, "multi_solar_to_acin1": { "name": "Solar to AC-in-1" @@ -926,44 +1288,40 @@ "name": "Solar to battery" }, "multi_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "multi_total_pv_yield": { - "name": "Total PV yield user" + "name": "[%key:component::victron_gx::common::total_pv_yield_user%]" }, "multi_yield_today": { - "name": "Yield today" + "name": "[%key:component::victron_gx::common::yield_today%]" }, "multi_yield_yesterday": { - "name": "Yield yesterday" - }, - "multiplus_assist_current_boost_factor": { - "name": "Assist current boost factor", - "unit_of_measurement": "factor" + "name": "[%key:component::victron_gx::common::yield_yesterday%]" }, "platform_venus_firmware_available_version": { "name": "Available version" @@ -972,10 +1330,10 @@ "name": "Installed version" }, "pvinverter_current_phase": { - "name": "Current {phase}" + "name": "[%key:component::victron_gx::common::current_phase%]" }, "pvinverter_power_phase": { - "name": "Power {phase}" + "name": "[%key:component::victron_gx::common::power_phase%]" }, "pvinverter_power_total": { "name": "Power total" @@ -987,7 +1345,7 @@ "name": "Yield {phase}" }, "pvinverter_yield_total": { - "name": "Total yield" + "name": "[%key:component::victron_gx::common::total_yield%]" }, "solarcharger_current": { "name": "PV bus current" @@ -1006,9 +1364,9 @@ "engine_shutdown": "Engine shutdown on low input voltage", "low_temperature": "Low temperature", "need_token": "Need token for operation", - "no_battery_power": "No/Low battery power", - "no_input_power": "No/Low input power", - "no_panel_power": "No/Low panel power", + "no_battery_power": "No/low battery power", + "no_input_power": "No/low input power", + "no_panel_power": "No/low panel power", "none": "-", "protective_action": "Protection active", "remote_input": "Remote input", @@ -1018,28 +1376,28 @@ } }, "solarcharger_error_code": { - "name": "Error code", + "name": "[%key:component::victron_gx::common::error_code%]", "state": { - "battery_voltage_too_high": "Battery voltage too high", - "bms_connection_lost": "BMS connection lost", - "bulk_time_limit_exceeded": "Bulk time limit exceeded", - "charger_current_reversed": "Charger current reversed", - "charger_over_current": "Charger over current", - "charger_temperature_too_high": "Charger temperature too high", - "converter_issue": "Converter issue", - "current_sensor_issue": "Current sensor issue", - "factory_calibration_data_lost": "Factory calibration data lost", - "input_current_too_high": "Input current too high (solar panel)", - "input_shutdown_battery_voltage_too_high": "Input shutdown (battery voltage too high)", - "input_shutdown_reverse_current": "Input shutdown (reverse current)", - "input_voltage_too_high": "Input voltage too high (solar panel)", - "invalid_incompatible_firmware": "Invalid/incompatible firmware", - "lost_communication_with_device": "Lost communication with device", - "network_misconfigured": "Network misconfigured", - "no_error": "No error", - "synchronized_charging_config_issue": "Synchronized charging config issue", - "terminals_overheated": "Terminals overheated", - "user_settings_invalid": "User settings invalid" + "battery_voltage_too_high": "[%key:component::victron_gx::common::battery_voltage_too_high%]", + "bms_connection_lost": "[%key:component::victron_gx::common::bms_connection_lost%]", + "bulk_time_limit_exceeded": "[%key:component::victron_gx::common::bulk_time_limit_exceeded%]", + "charger_current_reversed": "[%key:component::victron_gx::common::charger_current_reversed%]", + "charger_over_current": "[%key:component::victron_gx::common::charger_over_current%]", + "charger_temperature_too_high": "[%key:component::victron_gx::common::charger_temperature_too_high%]", + "converter_issue": "[%key:component::victron_gx::common::converter_issue%]", + "current_sensor_issue": "[%key:component::victron_gx::common::current_sensor_issue%]", + "factory_calibration_data_lost": "[%key:component::victron_gx::common::factory_calibration_data_lost%]", + "input_current_too_high": "[%key:component::victron_gx::common::input_current_too_high_solar_panel%]", + "input_shutdown_battery_voltage_too_high": "[%key:component::victron_gx::common::input_shutdown_battery_voltage_too_high%]", + "input_shutdown_reverse_current": "[%key:component::victron_gx::common::input_shutdown_reverse_current%]", + "input_voltage_too_high": "[%key:component::victron_gx::common::input_voltage_too_high_solar_panel%]", + "invalid_incompatible_firmware": "[%key:component::victron_gx::common::invalid_incompatible_firmware%]", + "lost_communication_with_device": "[%key:component::victron_gx::common::lost_communication_with_device%]", + "network_misconfigured": "[%key:component::victron_gx::common::network_misconfigured%]", + "no_error": "[%key:component::victron_gx::common::no_error%]", + "synchronized_charging_config_issue": "[%key:component::victron_gx::common::synchronized_charging_config_issue%]", + "terminals_overheated": "[%key:component::victron_gx::common::terminals_overheated%]", + "user_settings_invalid": "[%key:component::victron_gx::common::user_settings_invalid%]" } }, "solarcharger_load_current": { @@ -1049,10 +1407,10 @@ "name": "Max battery voltage today" }, "solarcharger_max_power_today": { - "name": "Max power today" + "name": "[%key:component::victron_gx::common::max_power_today%]" }, "solarcharger_max_power_yesterday": { - "name": "Max power yesterday" + "name": "[%key:component::victron_gx::common::max_power_yesterday%]" }, "solarcharger_min_battery_voltage_today": { "name": "Min battery voltage today" @@ -1060,37 +1418,40 @@ "solarcharger_mppt_operation_mode": { "name": "MPPT operation mode", "state": { - "mppt_active": "MPPT active", - "not_available": "Not available", - "off": "Off", - "voltage_current_limited": "Voltage/current limited" + "mppt_active": "[%key:component::victron_gx::common::mppt_active%]", + "not_available": "[%key:component::victron_gx::common::not_available%]", + "off": "[%key:common::state::off%]", + "voltage_current_limited": "[%key:component::victron_gx::common::voltage_current_limited%]" } }, + "solarcharger_pv_current": { + "name": "[%key:component::victron_gx::common::pv_current%]" + }, "solarcharger_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "solarcharger_time_in_absorption_today": { @@ -1102,6 +1463,9 @@ "solarcharger_time_in_float_today": { "name": "Time in float today" }, + "solarcharger_tracker_tracker_current": { + "name": "PV tracker {tracker} current" + }, "solarcharger_tracker_tracker_max_power_today": { "name": "Tracker {tracker} max power today" }, @@ -1114,10 +1478,10 @@ "solarcharger_tracker_tracker_operation_mode": { "name": "PV tracker {tracker} operation mode", "state": { - "mppt_active": "MPPT active", - "not_available": "Not available", - "off": "Off", - "voltage_current_limited": "Voltage/current limited" + "mppt_active": "[%key:component::victron_gx::common::mppt_active%]", + "not_available": "[%key:component::victron_gx::common::not_available%]", + "off": "[%key:common::state::off%]", + "voltage_current_limited": "[%key:component::victron_gx::common::voltage_current_limited%]" } }, "solarcharger_tracker_tracker_power": { @@ -1130,52 +1494,36 @@ "name": "Tracker {tracker} yield today" }, "solarcharger_voltage": { - "name": "PV bus voltage" + "name": "[%key:component::victron_gx::common::pv_bus_voltage%]" }, "solarcharger_yield_power": { "name": "PV yield power" }, "solarcharger_yield_today": { - "name": "Yield today" + "name": "[%key:component::victron_gx::common::yield_today%]" }, "solarcharger_yield_total": { - "name": "Total yield" + "name": "[%key:component::victron_gx::common::total_yield%]" }, "solarcharger_yield_yesterday": { - "name": "Yield yesterday" + "name": "[%key:component::victron_gx::common::yield_yesterday%]" }, - "switch_output_custom_name": { - "name": "{output} custom name" - }, - "switch_output_dimming": { - "name": "{output} dimming", - "unit_of_measurement": "%" - }, - "switchable_output_output_custom_name": { - "name": "Switchable output {output} custom name" + "switch_output_state": { + "name": "[%key:component::victron_gx::common::state%]" }, "system_ac_active_input_source": { "name": "AC active input source", "state": { - "generator": "Generator", - "grid": "Grid", - "not_connected": "Not connected", - "shore_power": "Shore power", - "unknown": "Unknown" + "generator": "[%key:component::victron_gx::common::generator%]", + "grid": "[%key:component::victron_gx::common::grid%]", + "not_connected": "[%key:component::victron_gx::common::not_connected%]", + "shore_power": "[%key:component::victron_gx::common::shore_power%]", + "unknown": "[%key:component::victron_gx::common::unknown%]" } }, - "system_ac_export_limit": { - "name": "AC export limit" - }, - "system_ac_input_limit": { - "name": "AC input limit" - }, "system_ac_loads_phase": { "name": "AC loads on {phase}" }, - "system_ac_power_set_point": { - "name": "AC power setpoint" - }, "system_consumption_current_phase": { "name": "Consumption current {phase}" }, @@ -1191,10 +1539,10 @@ "name": "Consumption power {phase}" }, "system_control_active_soc_limit": { - "name": "Active SOC limit" + "name": "Active SoC limit" }, "system_control_scheduled_soc": { - "name": "Scheduled SOC" + "name": "Scheduled SoC" }, "system_critical_loads_phase": { "name": "Critical loads on {phase}" @@ -1220,9 +1568,9 @@ "system_dc_battery_state": { "name": "DC battery state", "state": { - "charging": "Charging", - "discharging": "Discharging", - "idle": "Idle" + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "idle": "[%key:common::state::idle%]" } }, "system_dc_battery_voltage": { @@ -1232,7 +1580,7 @@ "name": "DC consumption" }, "system_dc_pv_current": { - "name": "PV current" + "name": "[%key:component::victron_gx::common::pv_current%]" }, "system_dc_pv_energy": { "name": "PV energy" @@ -1246,12 +1594,12 @@ "system_dynamicess_error": { "name": "Dynamic ESS error", "state": { - "battry_capacity_not_configured": "Battery Capacity Not Configured", - "ess_mode": "ESS Mode", - "no_error": "No Error", + "battry_capacity_not_configured": "Battery capacity not configured", + "ess_mode": "[%key:component::victron_gx::common::ess_mode%]", + "no_error": "[%key:component::victron_gx::common::no_error%]", "no_ess": "No ESS", - "no_schedule": "No Matching Schedule", - "soc_low": "SOC low" + "no_schedule": "No matching schedule", + "soc_low": "SoC low" } }, "system_dynamicess_last_scheduled_end": { @@ -1261,38 +1609,38 @@ "name": "Dynamic ESS last scheduled start" }, "system_dynamicess_minimum_soc": { - "name": "Dynamic ESS minimum SOC" + "name": "Dynamic ESS minimum SoC" }, "system_dynamicess_reactive_strategy": { "name": "Dynamic ESS reactive strategy", "state": { - "dess_disabled": "DESS Disabled", - "ess_low_soc": "ESS Low SOC", - "idle_maintain_surplus": "Idle Maintain Surplus", - "idle_maintain_targetsoc": "Idle Maintain Target SOC", - "idle_no_opportunity": "Idle No Opportunity", - "idle_scheduled_feedin": "Idle Scheduled Feed-In", - "keep_battery_charged": "Keep Battery Charged", - "no_window": "No Window", - "scheduled_charge_allow_grid": "Scheduled Charge Allow Grid", - "scheduled_charge_enhanced": "Scheduled Charge Enhanced", - "scheduled_charge_feedin": "Scheduled Charge Feed-In", - "scheduled_charge_no_grid": "Scheduled Charge No Grid", - "scheduled_charge_smooth_transition": "Scheduled Charge Smooth Transition", - "scheduled_discharge": "Scheduled Discharge", - "scheduled_discharge_smooth_transition": "Scheduled Discharge Smooth Transition", - "scheduled_minimum_discharge": "Scheduled Minimum Discharge", - "scheduled_selfconsume": "Scheduled Self-Consume", - "selfconsume_accept_charge": "Self-Consume Accept Charge", - "selfconsume_accept_discharge": "Self-Consume Accept Discharge", - "selfconsume_faulty_chargerate": "Self-Consume Faulty Charge Rate", - "selfconsume_increased_discharge": "Self-Consume Increased Discharge", - "selfconsume_no_grid": "Self-Consume No Grid", - "selfconsume_unexpected_exception": "Self-Consume Unexpected Exception", - "selfconsume_unmapped_state": "Self-Consume Unmapped State", - "selfconsume_unpredicted": "Self-Consume Unpredicted", - "unknown_operating_mode": "Unknown Operating Mode", - "unscheduled_charge_catchup_targetsoc": "Unscheduled Charge Catch-Up Target SOC" + "dess_disabled": "DESS disabled", + "ess_low_soc": "ESS low SoC", + "idle_maintain_surplus": "Idle maintain surplus", + "idle_maintain_targetsoc": "Idle maintain target SoC", + "idle_no_opportunity": "Idle no opportunity", + "idle_scheduled_feedin": "Idle scheduled feed-in", + "keep_battery_charged": "Keep battery charged", + "no_window": "No window", + "scheduled_charge_allow_grid": "Scheduled charge allow grid", + "scheduled_charge_enhanced": "Scheduled charge enhanced", + "scheduled_charge_feedin": "Scheduled charge feed-in", + "scheduled_charge_no_grid": "Scheduled charge no grid", + "scheduled_charge_smooth_transition": "Scheduled charge smooth transition", + "scheduled_discharge": "Scheduled discharge", + "scheduled_discharge_smooth_transition": "Scheduled discharge smooth transition", + "scheduled_minimum_discharge": "Scheduled minimum discharge", + "scheduled_selfconsume": "Scheduled self-consume", + "selfconsume_accept_charge": "Self-consume accept charge", + "selfconsume_accept_discharge": "Self-consume accept discharge", + "selfconsume_faulty_chargerate": "Self-consume faulty charge rate", + "selfconsume_increased_discharge": "Self-consume increased discharge", + "selfconsume_no_grid": "Self-consume no grid", + "selfconsume_unexpected_exception": "Self-consume unexpected exception", + "selfconsume_unmapped_state": "Self-consume unmapped state", + "selfconsume_unpredicted": "Self-consume unpredicted", + "unknown_operating_mode": "Unknown operating mode", + "unscheduled_charge_catchup_targetsoc": "Unscheduled charge catch-up target SoC" } }, "system_dynamicess_restrictions": { @@ -1301,7 +1649,7 @@ "battery_to_grid_restricted": "Battery to grid energy flow restricted", "grid_to_battery_restricted": "Grid to battery energy flow restricted", "no_flow": "No energy flow between battery and grid", - "no_restrictions": "No Restrictions between battery and the grid" + "no_restrictions": "No restrictions between battery and the grid" } }, "system_dynamicess_schedule_count": { @@ -1311,89 +1659,32 @@ "system_dynamicess_strategy": { "name": "Dynamic ESS strategy", "state": { - "probattery": "Pro Battery", - "progrid": "Pro Grid", - "selfconsume": "Self-Consume", - "targetsoc": "Target SOC" + "probattery": "Pro battery", + "progrid": "Pro grid", + "selfconsume": "Self-consume", + "targetsoc": "Target SoC" } }, "system_dynamicess_target_soc": { - "name": "Dynamic ESS target SOC" + "name": "Dynamic ESS target SoC" }, - "system_ess_batterylife_state": { + "system_ess_batterylife_state_sensor": { "name": "ESS BatteryLife state", "state": { "keep_batteries_charged": "'Keep batteries charged' mode enabled", - "recharge": "Recharge, SOC dropped 5% or more below MinSOC", - "recharge_no_battery_life": "Recharge, SOC dropped 5% or more below MinSOC (No BatteryLife)", - "self_consumption": "Self consumption", - "self_consumption_soc_above_min": "Self consumption, SoC at or above minimum SoC", - "self_consumption_soc_at_100": "Self consumption, SoC at 100%", - "self_consumption_soc_below_min": "Self consumption, SoC is below minimum SoC", - "self_consumption_soc_exceeds_85": "Self consumption, SoC exceeds 85%", + "recharge": "Recharge, SoC dropped 5% or more below minimum SoC", + "recharge_no_battery_life": "Recharge, SoC dropped 5% or more below minimum SoC (No BatteryLife)", + "self_consumption": "[%key:component::victron_gx::common::self_consumption%]", + "self_consumption_soc_above_min": "Self-consumption, SoC at or above minimum SoC", + "self_consumption_soc_at_100": "Self-consumption, SoC at 100%", + "self_consumption_soc_below_min": "Self-consumption, SoC is below minimum SoC", + "self_consumption_soc_exceeds_85": "Self-consumption, SoC exceeds 85%", "soc_below_battery_life_dynamic_soc_limit": "SoC below BatteryLife dynamic SoC limit", - "soc_below_soc_limit_24_hours": "SoC has been below SoC limit for more than 24 hours. Charging with battery with 5amps", + "soc_below_soc_limit_24_hours": "SoC has been below SoC limit for more than 24 hours. Charging battery with 5 amps", "sustain": "Multi/Quattro is in sustain", "with_battery_life": "Optimized mode with BatteryLife" } }, - "system_ess_max_charge_current": { - "name": "ESS max charge current" - }, - "system_ess_max_charge_power": { - "name": "ESS max charge power limit" - }, - "system_ess_max_charge_voltage": { - "name": "ESS max charge voltage" - }, - "system_ess_max_feed_in_power": { - "name": "ESS max feed-in power" - }, - "system_ess_max_inverter_power_limit": { - "name": "ESS max inverter power limit" - }, - "system_ess_min_soc_limit": { - "name": "ESS min SOC limit" - }, - "system_ess_mode": { - "name": "ESS mode (Hub4)", - "state": { - "external_control": "External control", - "phase_compensation_disabled": "Optimized mode or 'keep batteries charged' and phase compensation disabled", - "phase_compensation_enabled": "Optimized mode or 'keep batteries charged' and phase compensation enabled" - } - }, - "system_ess_schedule_charge_slot_days": { - "name": "ESS BatteryLife schedule charge {slot} days", - "state": { - "disabled_every_day": "Disabled (Every day)", - "disabled_friday": "Disabled (Friday)", - "disabled_monday": "Disabled (Monday)", - "disabled_saturday": "Disabled (Saturday)", - "disabled_sunday": "Disabled (Sunday)", - "disabled_thursday": "Disabled (Thursday)", - "disabled_tuesday": "Disabled (Tuesday)", - "disabled_wednesday": "Disabled (Wednesday)", - "disabled_weekdays": "Disabled (Weekdays)", - "disabled_weekend": "Disabled (Weekends)", - "every_day": "Every day", - "friday": "Friday", - "monday": "Monday", - "saturday": "Saturday", - "sunday": "Sunday", - "thursday": "Thursday", - "tuesday": "Tuesday", - "wednesday": "Wednesday", - "weekdays": "Weekdays", - "weekends": "Weekends" - } - }, - "system_ess_schedule_charge_slot_duration": { - "name": "ESS BatteryLife schedule charge {slot} duration" - }, - "system_ess_schedule_charge_slot_soc": { - "name": "ESS BatteryLife schedule charge {slot} SOC" - }, "system_generator_load_phase": { "name": "Genset load {phase}" }, @@ -1423,103 +1714,83 @@ "system_relay_relay_custom_name": { "name": "Relay {relay} custom name" }, - "system_settings_dess_mode": { - "name": "DESS mode", - "state": { - "auto_vrm": "Auto / VRM", - "buy": "Buy", - "node_red": "Node-RED", - "off": "Off", - "sell": "Sell" - } - }, "system_state": { "name": "System state", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, "tank_battery_voltage": { - "name": "Sensor battery voltage" + "name": "[%key:component::victron_gx::common::sensor_battery_voltage%]" }, "tank_fluid_type": { "name": "Fluid type", "state": { "black_water": "Black water (sewage)", "diesel": "Diesel", - "fresh_water": "Fresh Water", + "fresh_water": "Fresh water", "fuel": "Fuel", "gasoline": "Gasoline", "hydraulic_oil": "Hydraulic oil", - "live_well": "Live Well", - "lng": "Liquid Natural Gas (LNG)", - "lpg": "Liquid Petroleum Gas (LPG)", + "live_well": "Live well", + "lng": "Liquid natural gas (LNG)", + "lpg": "Liquid petroleum gas (LPG)", "oil": "Oil", "raw_water": "Raw water", - "waste_water": "Waste Water" + "waste_water": "Waste water" } }, "tank_level": { - "name": "Level", - "unit_of_measurement": "%" + "name": "Level" }, "tank_remaining": { "name": "Remaining" }, "tank_temperature": { - "name": "Temperature" + "name": "[%key:component::victron_gx::common::temperature%]" }, "temperature_battery_voltage": { - "name": "Sensor battery voltage" + "name": "[%key:component::victron_gx::common::sensor_battery_voltage%]" }, "temperature_humidity": { - "name": "Humidity", - "unit_of_measurement": "%" - }, - "temperature_offset": { - "name": "Offset" + "name": "Humidity" }, "temperature_pressure": { - "name": "Pressure", - "unit_of_measurement": "hPa" - }, - "temperature_scale": { - "name": "Scale factor", - "unit_of_measurement": "factor" + "name": "Pressure" }, "temperature_status": { "name": "Sensor status", "state": { - "disconnected": "Disconnected", - "ok": "Ok", + "disconnected": "[%key:common::state::disconnected%]", + "ok": "[%key:component::victron_gx::common::ok%]", "reverse_polarity": "Reverse polarity", "short_circuited": "Short circuited", - "unknown": "Unknown" + "unknown": "[%key:component::victron_gx::common::unknown%]" } }, "temperature_temperature": { - "name": "Temperature" + "name": "[%key:component::victron_gx::common::temperature%]" }, "temperature_type": { "name": "Sensor type", @@ -1530,15 +1801,9 @@ "generic": "Generic", "outdoor": "Outdoor", "room": "Room", - "water_heater": "Water Heater" + "water_heater": "Water heater" } }, - "transfer_switch_generator_current_limit": { - "name": "Generator AC current limit" - }, - "vebus_ac_power_setpoint_phase": { - "name": "AC power setpoint {phase}" - }, "vebus_device_device_number_input_power_l1": { "name": "{device_number} line 1 input power" }, @@ -1585,97 +1850,97 @@ "name": "Energy from out to inverter" }, "vebus_inverter_active_input": { - "name": "Active AC input", + "name": "[%key:component::victron_gx::common::active_ac_input%]", "state": { - "generator": "Generator", - "grid": "Grid", - "not_connected": "Not connected", - "shore_power": "Shore power", - "unknown": "Unknown" + "generator": "[%key:component::victron_gx::common::generator%]", + "grid": "[%key:component::victron_gx::common::grid%]", + "not_connected": "[%key:component::victron_gx::common::not_connected%]", + "shore_power": "[%key:component::victron_gx::common::shore_power%]", + "unknown": "[%key:component::victron_gx::common::unknown%]" } }, "vebus_inverter_alarm_grid_lost": { "name": "Grid lost alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_high_dc_current": { "name": "High DC current alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_high_dc_voltage": { "name": "High DC voltage alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_high_temperature": { - "name": "High temperature alarm", + "name": "[%key:component::victron_gx::common::high_temperature_alarm%]", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_low_battery": { - "name": "Low battery alarm", + "name": "[%key:component::victron_gx::common::low_battery_alarm%]", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_overload": { - "name": "Overload alarm", + "name": "[%key:component::victron_gx::common::overload_alarm%]", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_phase_rotation": { "name": "Phase rotation alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_ripple": { - "name": "Ripple alarm", + "name": "[%key:component::victron_gx::common::ripple_alarm%]", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_temperature_sensor": { "name": "Temperature sensor alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_alarm_voltage_sensor": { "name": "Voltage sensor alarm", "state": { - "alarm": "Alarm", - "no_alarm": "No Alarm", - "warning": "Warning" + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" } }, "vebus_inverter_current_limit": { - "name": "Current limit" + "name": "[%key:component::victron_gx::common::current_limit%]" }, "vebus_inverter_dc_current": { "name": "DC current" @@ -1684,7 +1949,7 @@ "name": "DC power" }, "vebus_inverter_dc_temperature": { - "name": "DC temperature" + "name": "[%key:component::victron_gx::common::dc_temperature%]" }, "vebus_inverter_dc_voltage": { "name": "DC voltage" @@ -1692,8 +1957,8 @@ "vebus_inverter_ignoreacin1_state": { "name": "State of ignore AC-in-1", "state": { - "off": "Off", - "on": "On" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } }, "vebus_inverter_input_apparent_power_phase": { @@ -1711,56 +1976,138 @@ "vebus_inverter_input_voltage_phase": { "name": "Input voltage {phase}" }, - "vebus_inverter_mode": { - "state": { - "charger_only": "Charger Only", - "inverter_only": "Inverter Only", - "off": "Off", - "on": "On" - } - }, "vebus_inverter_output_apparent_power_phase": { - "name": "Output apparent power {phase}" + "name": "[%key:component::victron_gx::common::output_apparent_power_phase%]" }, "vebus_inverter_output_current_phase": { - "name": "Output current {phase}" + "name": "[%key:component::victron_gx::common::output_current_phase%]" }, "vebus_inverter_output_frequency_phase": { "name": "Output frequency {phase}" }, "vebus_inverter_output_power_phase": { - "name": "Output power {phase}" + "name": "[%key:component::victron_gx::common::output_power_phase%]" }, "vebus_inverter_output_voltage_phase": { - "name": "Output voltage {phase}" + "name": "[%key:component::victron_gx::common::output_voltage_phase%]" }, "vebus_inverter_state": { - "name": "State", + "name": "[%key:component::victron_gx::common::state%]", "state": { - "absorption": "Absorption", - "auto_equalize": "Auto Equalize / Recondition", - "battery_safe": "Battery Safe", - "bulk": "Bulk", - "discharging": "Discharging", - "equalize": "Equalize", - "external_control": "External Control", - "fault": "Fault", - "float": "Float", - "inverting": "Inverting", - "low_power": "Low Power", - "off": "Off", - "passthrough": "Passthrough", - "power_assist": "Power Assist", - "power_supply": "Power Supply", - "recharging": "Recharging", - "repeated_absorption": "Repeated Absorption", - "scheduled_recharging": "Scheduled Recharging", - "starting_up": "Starting Up", - "storage": "Storage", - "sustain": "Sustain", - "sustain_alt": "Sustain Alt" + "absorption": "[%key:component::victron_gx::common::absorption%]", + "auto_equalize": "[%key:component::victron_gx::common::auto_equalize_recondition%]", + "battery_safe": "[%key:component::victron_gx::common::battery_safe%]", + "bulk": "[%key:component::victron_gx::common::bulk%]", + "discharging": "[%key:common::state::discharging%]", + "equalize": "[%key:component::victron_gx::common::equalize%]", + "external_control": "[%key:component::victron_gx::common::external_control%]", + "fault": "[%key:common::state::fault%]", + "float": "[%key:component::victron_gx::common::float%]", + "inverting": "[%key:component::victron_gx::common::inverting%]", + "low_power": "[%key:component::victron_gx::common::low_power%]", + "off": "[%key:common::state::off%]", + "passthrough": "[%key:component::victron_gx::common::passthrough%]", + "power_assist": "[%key:component::victron_gx::common::power_assist%]", + "power_supply": "[%key:component::victron_gx::common::power_supply%]", + "recharging": "[%key:component::victron_gx::common::recharging%]", + "repeated_absorption": "[%key:component::victron_gx::common::repeated_absorption%]", + "scheduled_recharging": "[%key:component::victron_gx::common::scheduled_recharging%]", + "starting_up": "[%key:component::victron_gx::common::starting_up%]", + "storage": "[%key:component::victron_gx::common::storage%]", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } } + }, + "switch": { + "digitalinput_settings_invert_translation": { + "name": "Invert digital input" + }, + "evcharger_auto_start": { + "name": "Auto start" + }, + "evcharger_charge": { + "name": "EV charging" + }, + "generator_autorun": { + "name": "Auto-start enabled" + }, + "generator_gen_id_quiet_hours_enabled": { + "name": "Generator quiet hours enabled" + }, + "generator_gen_id_start_on_soc_enabled": { + "name": "Generator start on SoC enabled" + }, + "generator_gen_id_start_on_temp_enabled": { + "name": "Generator start on high temp enabled" + }, + "generator_gen_id_start_on_voltage_enabled": { + "name": "Generator start on voltage enabled" + }, + "generator_manual_start": { + "name": "Manual start" + }, + "hub4_force_charge": { + "name": "Force charge" + }, + "multi_disable_charge": { + "name": "ESS disable charge" + }, + "multi_disable_feed_in": { + "name": "ESS disable feed-in" + }, + "multi_relay0_state": { + "name": "Relay on Multi RS state" + }, + "solarcharger_relay_state": { + "name": "Relay state" + }, + "switch_output_state": { + "name": "[%key:component::victron_gx::common::state%]" + }, + "switchable_output_output_state": { + "name": "[%key:component::victron_gx::common::state%]" + }, + "system_ess_battery_use": { + "name": "ESS only critical loads from battery" + }, + "system_ess_schedule_charge_slot_enabled": { + "name": "ESS BatteryLife schedule charge {slot} enabled" + }, + "system_relay_relay": { + "name": "Relay {relay} state" + }, + "system_settings_overvoltage_feedin": { + "name": "DC-coupled PV - feed in excess" + }, + "system_settings_prevent_ac_feedin": { + "name": "AC-coupled PV - feed in excess" + }, + "vebus_device_device_number_power_assist_enabled": { + "name": "{device_number} PowerAssist enabled" + }, + "vebus_inverter_ignoreacin1_onoff_control": { + "name": "Control ignore AC-in-1" + }, + "vebus_inverter_prefer_renewable_energy": { + "name": "Prefer renewable energy" + }, + "vebus_inverter_setting_alarm_grid_lost": { + "name": "Grid lost alarm setting" + } + }, + "time": { + "system_ess_schedule_charge_slot_start": { + "name": "ESS BatteryLife schedule charge {slot} start" + } + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed for {host}." + }, + "cannot_connect": { + "message": "Cannot connect to the GX device at {host}." } } } diff --git a/homeassistant/components/victron_gx/switch.py b/homeassistant/components/victron_gx/switch.py new file mode 100644 index 00000000000..5160e86a1ce --- /dev/null +++ b/homeassistant/components/victron_gx/switch.py @@ -0,0 +1,80 @@ +"""Support for Victron GX switches.""" + +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .binary_sensor import VictronBinarySensor +from .const import BINARY_SENSOR_OFF_ID, BINARY_SENSOR_ON_ID +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX switches from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new switch metric discovery.""" + if TYPE_CHECKING: + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronSwitch(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.SWITCH, on_new_metric) + + +class VictronSwitch(VictronBaseEntity, SwitchEntity): + """Implementation of a Victron GX switch.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the switch.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_is_on = VictronBinarySensor.convert_metric_value_to_is_on( + metric.value + ) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_is_on = VictronBinarySensor.convert_metric_value_to_is_on(value) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + self._metric.set(BINARY_SENSOR_ON_ID) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + self._metric.set(BINARY_SENSOR_OFF_ID) diff --git a/homeassistant/components/victron_gx/time.py b/homeassistant/components/victron_gx/time.py new file mode 100644 index 00000000000..771b54a5a24 --- /dev/null +++ b/homeassistant/components/victron_gx/time.py @@ -0,0 +1,94 @@ +"""Support for Victron GX time entities.""" + +from datetime import time +import logging +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.time import TimeEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX time entities from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new time metric discovery.""" + if TYPE_CHECKING: + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities([VictronTime(device, metric, device_info, installation_id)]) + + hub.register_new_metric_callback(MetricKind.TIME, on_new_metric) + + +class VictronTime(VictronBaseEntity, TimeEntity): + """Implementation of a Victron GX time entity.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the time entity.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_native_value = VictronTime.victron_time_to_time(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_native_value = VictronTime.victron_time_to_time(value) + self.async_write_ha_state() + + async def async_set_value(self, value: time) -> None: + """Set a new time value.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + total_minutes = VictronTime.time_to_victron_time(value) + _LOGGER.debug( + "Setting time %s (%d minutes) on entity: %s", + value, + total_minutes, + self._attr_unique_id, + ) + self._metric.set(total_minutes) + + @staticmethod + def victron_time_to_time(value: int | None) -> time | None: + """Convert minutes since midnight to time object.""" + if value is None: + return None + total_minutes = int(value) + hours = total_minutes // 60 + minutes = total_minutes % 60 + return time(hour=hours, minute=minutes) + + @staticmethod + def time_to_victron_time(value: time) -> int: + """Convert time object to minutes since midnight.""" + return value.hour * 60 + value.minute diff --git a/homeassistant/components/victron_remote_monitoring/__init__.py b/homeassistant/components/victron_remote_monitoring/__init__.py index 15cddedc4ed..cc97fff1ba2 100644 --- a/homeassistant/components/victron_remote_monitoring/__init__.py +++ b/homeassistant/components/victron_remote_monitoring/__init__.py @@ -1,7 +1,5 @@ """The Victron VRM Solar Forecast integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/victron_remote_monitoring/config_flow.py b/homeassistant/components/victron_remote_monitoring/config_flow.py index 53c33757e3c..ea87fddf1be 100644 --- a/homeassistant/components/victron_remote_monitoring/config_flow.py +++ b/homeassistant/components/victron_remote_monitoring/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Victron VRM Solar Forecast integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -12,6 +10,7 @@ from victron_vrm.models import Site import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -21,7 +20,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN +from .const import CONF_SITE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -235,7 +234,8 @@ class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except SiteNotFound: - # Site removed or no longer visible to the account; treat as cannot connect + # Site removed or no longer visible to the + # account; treat as cannot connect errors["base"] = "site_not_found" except Exception: # pragma: no cover - unexpected _LOGGER.exception("Unexpected exception during reauth") diff --git a/homeassistant/components/victron_remote_monitoring/const.py b/homeassistant/components/victron_remote_monitoring/const.py index 3de1dbcabb2..d601daad902 100644 --- a/homeassistant/components/victron_remote_monitoring/const.py +++ b/homeassistant/components/victron_remote_monitoring/const.py @@ -6,4 +6,3 @@ DOMAIN = "victron_remote_monitoring" LOGGER = logging.getLogger(__package__) CONF_SITE_ID = "site_id" -CONF_API_TOKEN = "api_token" diff --git a/homeassistant/components/victron_remote_monitoring/coordinator.py b/homeassistant/components/victron_remote_monitoring/coordinator.py index a7a58fbbe4a..bdfd865b372 100644 --- a/homeassistant/components/victron_remote_monitoring/coordinator.py +++ b/homeassistant/components/victron_remote_monitoring/coordinator.py @@ -9,12 +9,13 @@ from victron_vrm.models.aggregations import ForecastAggregations from victron_vrm.utils import dt_now from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, LOGGER +from .const import CONF_SITE_ID, DOMAIN, LOGGER type VictronRemoteMonitoringConfigEntry = ConfigEntry[ VictronRemoteMonitoringDataUpdateCoordinator diff --git a/homeassistant/components/victron_remote_monitoring/energy.py b/homeassistant/components/victron_remote_monitoring/energy.py index b3209703115..0f7bd3a4705 100644 --- a/homeassistant/components/victron_remote_monitoring/energy.py +++ b/homeassistant/components/victron_remote_monitoring/energy.py @@ -1,7 +1,5 @@ """Victron Remote Monitoring energy platform.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -12,7 +10,7 @@ async def async_get_solar_forecast( """Get solar forecast for a config entry ID.""" if ( entry := hass.config_entries.async_get_entry(config_entry_id) - ) is None or entry.state != ConfigEntryState.LOADED: + ) is None or entry.state is not ConfigEntryState.LOADED: return None data = entry.runtime_data.data.solar if data is None: diff --git a/homeassistant/components/victron_remote_monitoring/quality_scale.yaml b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml index 7e3f009b868..d884388a496 100644 --- a/homeassistant/components/victron_remote_monitoring/quality_scale.yaml +++ b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: todo reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/victron_remote_monitoring/sensor.py b/homeassistant/components/victron_remote_monitoring/sensor.py index 6d5e97c92cf..2338d3ece76 100644 --- a/homeassistant/components/victron_remote_monitoring/sensor.py +++ b/homeassistant/components/victron_remote_monitoring/sensor.py @@ -1,7 +1,5 @@ """Support for the VRM Solar Forecast sensor service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py index ca74e74f37a..95e6c8de89c 100644 --- a/homeassistant/components/vilfo/__init__.py +++ b/homeassistant/components/vilfo/__init__.py @@ -12,7 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import Throttle -from .const import ATTR_BOOT_TIME, ATTR_LOAD, DOMAIN, ROUTER_DEFAULT_HOST +from .const import ATTR_BOOT_TIME, ATTR_LOAD, ROUTER_DEFAULT_HOST + +type VilfoConfigEntry = ConfigEntry[VilfoRouterData] PLATFORMS = [Platform.SENSOR] @@ -21,7 +23,7 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VilfoConfigEntry) -> bool: """Set up Vilfo Router from a config entry.""" host = entry.data[CONF_HOST] access_token = entry.data[CONF_ACCESS_TOKEN] @@ -33,21 +35,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not vilfo_router.available: raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = vilfo_router + entry.runtime_data = vilfo_router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VilfoConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class VilfoRouterData: diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index 5612591c595..af6a046af1e 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -33,7 +33,7 @@ RESULT_INVALID_AUTH = "invalid_auth" def _try_connect_and_fetch_basic_info(host, token): - """Attempt to connect and call the ping endpoint and, if successful, fetch basic information.""" + """Connect, call ping endpoint, and fetch basic info.""" # Perform the ping. This doesn't validate authentication. controller = VilfoClient(host=host, token=token) diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py index e129437df7e..0d57a16cf38 100644 --- a/homeassistant/components/vilfo/const.py +++ b/homeassistant/components/vilfo/const.py @@ -1,7 +1,5 @@ """Constants for the Vilfo Router integration.""" -from __future__ import annotations - DOMAIN = "vilfo" ATTR_API_DATA_FIELD_LOAD = "load" diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index fa2d5cae196..7755f55a7ea 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -7,12 +7,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import VilfoConfigEntry from .const import ( ATTR_API_DATA_FIELD_BOOT_TIME, ATTR_API_DATA_FIELD_LOAD, @@ -50,11 +50,11 @@ SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VilfoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Vilfo Router entities from a config_entry.""" - vilfo = hass.data[DOMAIN][config_entry.entry_id] + vilfo = config_entry.runtime_data entities = [VilfoRouterSensor(vilfo, description) for description in SENSOR_TYPES] diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py new file mode 100644 index 00000000000..0f21964ffa6 --- /dev/null +++ b/homeassistant/components/vistapool/__init__.py @@ -0,0 +1,99 @@ +"""The Vistapool integration.""" + +from dataclasses import dataclass, field +import logging + +from aioaquarite import AquariteAuth, AquariteClient, AquariteError, AuthenticationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, +] + + +@dataclass +class VistapoolData: + """Runtime data for a Vistapool account (holds one coordinator per pool).""" + + auth: AquariteAuth + api: AquariteClient + coordinators: dict[str, VistapoolDataUpdateCoordinator] = field( + default_factory=dict + ) + + +type VistapoolConfigEntry = ConfigEntry[VistapoolData] + + +async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> bool: + """Set up Vistapool from a config entry. + + One config entry represents a Hayward account; the account can contain + multiple pools, each exposed as a separate device. + """ + user_config = entry.data + session = async_get_clientsession(hass) + + auth = AquariteAuth(session, user_config[CONF_USERNAME], user_config[CONF_PASSWORD]) + try: + await auth.authenticate() + except AuthenticationError as exc: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from exc + except AquariteError as exc: + raise ConfigEntryNotReady from exc + + api = AquariteClient(auth) + try: + pools = await api.get_pools() + except AquariteError as exc: + raise ConfigEntryNotReady from exc + + if not pools: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_pools", + ) + + data = VistapoolData(auth=auth, api=api) + + try: + for pool_id, pool_name in pools.items(): + coordinator = VistapoolDataUpdateCoordinator( + hass, entry, auth, api, pool_id, pool_name + ) + data.coordinators[pool_id] = coordinator + await coordinator.async_config_entry_first_refresh() + try: + await coordinator.subscribe() + except AquariteError as exc: + raise ConfigEntryNotReady from exc + entry.async_on_unload(coordinator.async_shutdown) + except Exception: + for coordinator in data.coordinators.values(): + await coordinator.async_shutdown() + raise + + entry.runtime_data = data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> bool: + """Unload Vistapool config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py new file mode 100644 index 00000000000..2432dc505ad --- /dev/null +++ b/homeassistant/components/vistapool/button.py @@ -0,0 +1,73 @@ +"""Vistapool Button entities.""" + +import asyncio + +from aioaquarite import AquariteError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_HASLED_PATH = "main.hasLED" +_LIGHT_STATUS_PATH = "light.status" +_LED_PULSE_DELAY_SECONDS = 1.0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool buttons for every pool that has an LED fixture.""" + async_add_entities( + VistapoolLEDPulseButton(coordinator) + for coordinator in entry.runtime_data.coordinators.values() + if coordinator.get_value(_HASLED_PATH) + ) + + +class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity): + """Power-cycle the pool light to advance the LED fixture's color. + + Mirrors the "Next" button under LED Color in the Vistapool app's + Illumination screen. If the light is on, sends light.status=0, waits a + moment, then light.status=1; the physical LED fixture advances to the + next color on power-on. If the light is off, just turns it on. + """ + + _attr_translation_key = "led_pulse" + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the LED pulse button.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("led_pulse") + + async def async_press(self) -> None: + """Send a color-advance pulse to the pool LED fixture.""" + try: + if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"): + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0 + ) + await asyncio.sleep(_LED_PULSE_DELAY_SECONDS) + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1 + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err + # Optimistically reflect the just-written value so a rapid second press + # doesn't read the stale off-state before the Firestore push round-trips. + self.coordinator.data.setdefault("light", {})["status"] = 1 + self.coordinator.async_set_updated_data(self.coordinator.data) diff --git a/homeassistant/components/vistapool/config_flow.py b/homeassistant/components/vistapool/config_flow.py new file mode 100644 index 00000000000..760a115bc5c --- /dev/null +++ b/homeassistant/components/vistapool/config_flow.py @@ -0,0 +1,113 @@ +"""Config Flow for the Vistapool integration.""" + +import logging +from typing import Any + +from aioaquarite import AquariteAuth, AquariteClient, AquariteError, AuthenticationError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + +RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): cv.string}) + + +class VistapoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Vistapool config flow (one entry per Hayward account).""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + session = async_get_clientsession(self.hass) + try: + auth = AquariteAuth(session, username, password) + await auth.authenticate() + except AuthenticationError: + errors["base"] = "invalid_auth" + except AquariteError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during authentication") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(auth.user_id) + self._abort_if_unique_id_configured() + + api = AquariteClient(auth) + try: + pools = await api.get_pools() + except AquariteError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error fetching pools") + errors["base"] = "unknown" + else: + if not pools: + errors["base"] = "no_pools" + else: + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=AUTH_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user proactively update the stored Vistapool password.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + username = entry.data[CONF_USERNAME] + + if user_input is not None: + password = user_input[CONF_PASSWORD] + session = async_get_clientsession(self.hass) + auth = AquariteAuth(session, username, password) + try: + await auth.authenticate() + except AuthenticationError: + errors["base"] = "invalid_auth" + except AquariteError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reconfiguration") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(auth.user_id) + self._abort_if_unique_id_mismatch(reason="account_mismatch") + return self.async_update_reload_and_abort( + entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=RECONFIGURE_SCHEMA, + description_placeholders={"username": username}, + errors=errors, + ) diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py new file mode 100644 index 00000000000..0bba81f1993 --- /dev/null +++ b/homeassistant/components/vistapool/const.py @@ -0,0 +1,13 @@ +"""Shared constants for the Vistapool integration.""" + +DOMAIN = "vistapool" +BRAND = "Sugar Valley" +MODEL = "Vistapool" + +PATH_PREFIX = "main." +PATH_HASCD = f"{PATH_PREFIX}hasCD" +PATH_HASCL = f"{PATH_PREFIX}hasCL" +PATH_HASPH = f"{PATH_PREFIX}hasPH" +PATH_HASRX = f"{PATH_PREFIX}hasRX" +PATH_HASUV = f"{PATH_PREFIX}hasUV" +PATH_HASHIDRO = f"{PATH_PREFIX}hasHidro" diff --git a/homeassistant/components/vistapool/coordinator.py b/homeassistant/components/vistapool/coordinator.py new file mode 100644 index 00000000000..512fc2cf872 --- /dev/null +++ b/homeassistant/components/vistapool/coordinator.py @@ -0,0 +1,83 @@ +"""Data coordinator for the Vistapool integration.""" + +import logging +from typing import TYPE_CHECKING, Any + +from aioaquarite import ( + AquariteAuth, + AquariteClient, + AquariteError, + ResilientPoolSubscription, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import VistapoolConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Vistapool coordinator for a single pool's Firestore subscription.""" + + config_entry: VistapoolConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: VistapoolConfigEntry, + auth: AquariteAuth, + api: AquariteClient, + pool_id: str, + pool_name: str, + ) -> None: + """Initialize the coordinator.""" + self.auth = auth + self.api = api + self.pool_id: str = pool_id + self.pool_name: str = pool_name + self.subscription: ResilientPoolSubscription | None = None + + super().__init__( + hass, + logger=_LOGGER, + name=f"Vistapool {pool_name}", + update_interval=None, + config_entry=entry, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch latest pool data (fallback for manual refresh).""" + try: + return await self.api.fetch_pool_data(self.pool_id) + except AquariteError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + async def subscribe(self) -> None: + """Subscribe to Firestore real-time updates via the library.""" + + def _on_data(data: dict[str, Any]) -> None: + """Callback from the Firestore thread; push data to the HA loop.""" + self.hass.loop.call_soon_threadsafe(self.async_set_updated_data, data) + + self.subscription = await self.api.subscribe_pool_resilient( + self.pool_id, _on_data + ) + + async def async_shutdown(self) -> None: + """Cleanly close the resilient subscription.""" + if self.subscription is not None: + await self.subscription.aclose() + self.subscription = None + await super().async_shutdown() + + def get_value(self, path: str, default: Any = None) -> Any: + """Get nested data using dot-notation path.""" + return AquariteClient.get_value(self.data, path, default) diff --git a/homeassistant/components/vistapool/diagnostics.py b/homeassistant/components/vistapool/diagnostics.py new file mode 100644 index 00000000000..244022dfa73 --- /dev/null +++ b/homeassistant/components/vistapool/diagnostics.py @@ -0,0 +1,36 @@ +"""Diagnostics support for Vistapool.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import VistapoolConfigEntry + +TO_REDACT = { + CONF_PASSWORD, + CONF_USERNAME, + "city", + "lat", + "lng", + "street", + "title", + "unique_id", + "wifi", + "zipcode", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: VistapoolConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a Vistapool config entry.""" + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "pools": [ + async_redact_data(coordinator.data, TO_REDACT) + for coordinator in entry.runtime_data.coordinators.values() + ], + } diff --git a/homeassistant/components/vistapool/entity.py b/homeassistant/components/vistapool/entity.py new file mode 100644 index 00000000000..175019c901d --- /dev/null +++ b/homeassistant/components/vistapool/entity.py @@ -0,0 +1,39 @@ +"""Shared base entity helpers for Vistapool.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import BRAND, DOMAIN, MODEL +from .coordinator import VistapoolDataUpdateCoordinator + + +class VistapoolEntity(CoordinatorEntity[VistapoolDataUpdateCoordinator]): + """Base entity class for Vistapool platforms (one device per pool).""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the base entity.""" + super().__init__(coordinator) + sw_version = coordinator.get_value("main.version") + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.pool_id)}, + name=coordinator.pool_name, + manufacturer=BRAND, + model=MODEL, + sw_version=str(sw_version) if sw_version is not None else None, + ) + + @property + def pool_id(self) -> str: + """Return the pool ID for the entity.""" + return self.coordinator.pool_id + + @property + def pool_name(self) -> str: + """Return the friendly pool name for the entity.""" + return self.coordinator.pool_name + + def build_unique_id(self, suffix: str) -> str: + """Return a consistent unique ID for the entity.""" + return f"{self.coordinator.pool_id}-{suffix}" diff --git a/homeassistant/components/vistapool/icons.json b/homeassistant/components/vistapool/icons.json new file mode 100644 index 00000000000..1ae79f51647 --- /dev/null +++ b/homeassistant/components/vistapool/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "chlorine": { + "default": "mdi:gauge" + }, + "conductivity": { + "default": "mdi:gauge" + }, + "electrolysis": { + "default": "mdi:flash" + }, + "filtration_intel_time": { + "default": "mdi:timer-outline" + }, + "hydrolysis": { + "default": "mdi:flash" + }, + "redox_potential": { + "default": "mdi:gauge" + }, + "uv": { + "default": "mdi:weather-sunny-alert" + } + } + } +} diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py new file mode 100644 index 00000000000..66e44a1227f --- /dev/null +++ b/homeassistant/components/vistapool/light.py @@ -0,0 +1,73 @@ +"""Vistapool Light entities.""" + +from typing import Any + +from aioaquarite import AquariteError + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_VALUE_PATH = "light.status" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Vistapool light for every pool on the account.""" + async_add_entities( + VistapoolLight(coordinator) + for coordinator in entry.runtime_data.coordinators.values() + ) + + +class VistapoolLight(VistapoolEntity, LightEntity): + """Representation of a Vistapool pool light.""" + + _attr_translation_key = "pool_light" + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the light entity.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("pool_light") + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + value = self.coordinator.get_value(_VALUE_PATH) + if value is None: + return None + return value in (True, "1") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._async_set_value(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_set_value(0) + + async def _async_set_value(self, value: int) -> None: + """Send a value update via the Vistapool cloud API.""" + try: + await self.coordinator.api.set_value( + self.coordinator.pool_id, _VALUE_PATH, value + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json new file mode 100644 index 00000000000..bc07c75d668 --- /dev/null +++ b/homeassistant/components/vistapool/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "vistapool", + "name": "Vistapool", + "codeowners": ["@fdebrus"], + "config_flow": true, + "dhcp": [ + { + "hostname": "sugarwifi" + } + ], + "documentation": "https://www.home-assistant.io/integrations/vistapool", + "integration_type": "hub", + "iot_class": "cloud_push", + "loggers": ["aioaquarite"], + "quality_scale": "bronze", + "requirements": ["aioaquarite==0.5.1"] +} diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py new file mode 100644 index 00000000000..e42003a3379 --- /dev/null +++ b/homeassistant/components/vistapool/number.py @@ -0,0 +1,235 @@ +"""Vistapool Number entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aioaquarite import AquariteError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_TEMP_MIN = 5.0 +_TEMP_MAX = 40.0 + + +@dataclass(frozen=True, kw_only=True) +class VistapoolNumberEntityDescription(NumberEntityDescription): + """Describes a Vistapool number entity.""" + + value_path: str + scale: int = 1 + exists_path: str | tuple[str, ...] | None = None + max_value_fn: Callable[[VistapoolDataUpdateCoordinator], float] | None = None + + +def _max_electrolysis(coordinator: VistapoolDataUpdateCoordinator) -> float: + """Read the cell's hardware max, falling back to a safe default.""" + raw = coordinator.get_value("hidro.maxAllowedValue") + if raw is None: + return 50.0 + try: + return float(raw) / 10 + except TypeError, ValueError: + return 50.0 + + +NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = ( + VistapoolNumberEntityDescription( + key="redox_setpoint", + translation_key="redox_setpoint", + entity_category=EntityCategory.CONFIG, + native_min_value=500, + native_max_value=800, + native_step=1, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + value_path="modules.rx.status.value", + exists_path=PATH_HASRX, + ), + VistapoolNumberEntityDescription( + key="ph_minimum", + translation_key="ph_minimum", + device_class=NumberDeviceClass.PH, + entity_category=EntityCategory.CONFIG, + native_min_value=6, + native_max_value=8, + native_step=0.01, + value_path="modules.ph.status.low_value", + scale=100, + exists_path=PATH_HASPH, + ), + VistapoolNumberEntityDescription( + key="ph_maximum", + translation_key="ph_maximum", + device_class=NumberDeviceClass.PH, + entity_category=EntityCategory.CONFIG, + native_min_value=6, + native_max_value=8, + native_step=0.01, + value_path="modules.ph.status.high_value", + scale=100, + exists_path=PATH_HASPH, + ), + VistapoolNumberEntityDescription( + key="intel_temperature", + translation_key="intel_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_min_value=_TEMP_MIN, + native_max_value=_TEMP_MAX, + native_step=1, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_path="filtration.intel.temp", + ), + *( + VistapoolNumberEntityDescription( + key=key, + translation_key=key, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_min_value=_TEMP_MIN, + native_max_value=_TEMP_MAX, + native_step=1, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_path=value_path, + exists_path=exists_path, + ) + for key, value_path, exists_path in ( + ( + "heating_minimum_temperature", + "filtration.heating.temp", + "filtration.hasHeat", + ), + ( + "heating_maximum_temperature", + "filtration.heating.tempHi", + "filtration.hasHeat", + ), + ( + "smart_minimum_temperature", + "filtration.smart.tempMin", + "filtration.hasSmart", + ), + ( + "smart_maximum_temperature", + "filtration.smart.tempHigh", + "filtration.hasSmart", + ), + ) + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool number entities for every pool on the account.""" + entities: list[NumberEntity] = [] + + for coordinator in entry.runtime_data.coordinators.values(): + for description in NUMBER_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolNumber(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + key = ( + "hydrolysis_setpoint" + if coordinator.get_value("hidro.is_electrolysis") is False + else "electrolysis_setpoint" + ) + entities.append( + VistapoolNumber( + coordinator, + VistapoolNumberEntityDescription( + key=key, + translation_key=key, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=50.0, + native_step=0.1, + native_unit_of_measurement="g/h", + value_path="hidro.level", + scale=10, + max_value_fn=_max_electrolysis, + ), + ) + ) + + async_add_entities(entities) + + +class VistapoolNumber(VistapoolEntity, NumberEntity): + """Generic Vistapool number driven by an entity description.""" + + entity_description: VistapoolNumberEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + + @property + def native_max_value(self) -> float: + """Return the max value, recomputed from coordinator data when applicable.""" + if (fn := self.entity_description.max_value_fn) is not None: + return fn(self.coordinator) + return super().native_max_value + + @property + def native_value(self) -> float | None: + """Return the scaled current value.""" + raw = self.coordinator.get_value(self.entity_description.value_path) + if raw is None: + return None + try: + value = float(raw) + except TypeError, ValueError: + return None + return value / self.entity_description.scale + + async def async_set_native_value(self, value: float) -> None: + """Send the de-scaled value to the controller.""" + raw = round(value * self.entity_description.scale) + try: + await self.coordinator.api.set_value( + self.coordinator.pool_id, + self.entity_description.value_path, + raw, + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml new file mode 100644 index 00000000000..b3f3badeaa5 --- /dev/null +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -0,0 +1,67 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No integration-specific service actions; entities use platform-standard actions only + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No integration-specific service actions to document + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-troubleshooting: done + entity-category: done + entity-disabled-by-default: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: done + discovery-update-info: + status: exempt + comment: Integration is cloud-only; no local host info is stored on the config entry. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-use-cases: done + dynamic-devices: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No known repair scenarios + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/vistapool/sensor.py b/homeassistant/components/vistapool/sensor.py new file mode 100644 index 00000000000..7f4cab5c2b8 --- /dev/null +++ b/homeassistant/components/vistapool/sensor.py @@ -0,0 +1,187 @@ +"""Vistapool Sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import ( + PATH_HASCD, + PATH_HASCL, + PATH_HASHIDRO, + PATH_HASPH, + PATH_HASRX, + PATH_HASUV, +) +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + + +def _convert_hundredths(value: Any) -> float: + return float(value) / 100 + + +def _convert_tenths(value: Any) -> float: + return float(value) / 10 + + +@dataclass(frozen=True, kw_only=True) +class VistapoolSensorEntityDescription(SensorEntityDescription): + """Describes a Vistapool sensor entity.""" + + value_path: str + value_fn: Callable[[Any], float | int] = float + exists_path: str | None = None + + +SENSOR_DESCRIPTIONS: tuple[VistapoolSensorEntityDescription, ...] = ( + VistapoolSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_path="main.temperature", + ), + VistapoolSensorEntityDescription( + key="conductivity", + translation_key="conductivity", + state_class=SensorStateClass.MEASUREMENT, + value_path="modules.cd.current", + value_fn=_convert_hundredths, + exists_path=PATH_HASCD, + ), + VistapoolSensorEntityDescription( + key="chlorine", + translation_key="chlorine", + state_class=SensorStateClass.MEASUREMENT, + value_path="modules.cl.current", + value_fn=_convert_hundredths, + exists_path=PATH_HASCL, + ), + VistapoolSensorEntityDescription( + key="ph", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + value_path="modules.ph.current", + value_fn=_convert_hundredths, + exists_path=PATH_HASPH, + ), + VistapoolSensorEntityDescription( + key="redox_potential", + translation_key="redox_potential", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, + value_path="modules.rx.current", + value_fn=int, + exists_path=PATH_HASRX, + ), + VistapoolSensorEntityDescription( + key="uv", + translation_key="uv", + state_class=SensorStateClass.MEASUREMENT, + value_path="modules.uv.current", + value_fn=_convert_hundredths, + exists_path=PATH_HASUV, + ), + VistapoolSensorEntityDescription( + key="filtration_intel_time", + translation_key="filtration_intel_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + value_path="filtration.intel.time", + value_fn=int, + ), + VistapoolSensorEntityDescription( + key="rssi", + translation_key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_path="main.RSSI", + value_fn=int, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool sensors for every pool on the account.""" + entities: list[VistapoolSensorEntity] = [] + + for coordinator in entry.runtime_data.coordinators.values(): + for description in SENSOR_DESCRIPTIONS: + if description.exists_path is not None and not coordinator.get_value( + description.exists_path + ): + continue + entities.append(VistapoolSensorEntity(coordinator, description)) + + # Electrolysis/hydrolysis: dynamic key based on hardware type + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolSensorEntity( + coordinator, + VistapoolSensorEntityDescription( + key="electrolysis" if is_electrolysis else "hydrolysis", + translation_key=( + "electrolysis" if is_electrolysis else "hydrolysis" + ), + native_unit_of_measurement="g/h", + state_class=SensorStateClass.MEASUREMENT, + value_path="hidro.current", + value_fn=_convert_tenths, + ), + ) + ) + + async_add_entities(entities) + + +class VistapoolSensorEntity(VistapoolEntity, SensorEntity): + """Generic Vistapool sensor driven by an entity description.""" + + entity_description: VistapoolSensorEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + + @property + def native_value(self) -> float | int | None: + """Return the sensor value, transformed by the description's value_fn.""" + value = self.coordinator.get_value(self.entity_description.value_path) + if value is None: + return None + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json new file mode 100644 index 00000000000..3dc5b716b42 --- /dev/null +++ b/homeassistant/components/vistapool/strings.json @@ -0,0 +1,125 @@ +{ + "config": { + "abort": { + "account_mismatch": "The credentials entered are for a different Vistapool account than the one being reconfigured.", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_pools": "No pools were found on this account.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "Vistapool pool controller", + "step": { + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The new password for your Vistapool account." + }, + "description": "Update the stored password for {username}.", + "title": "Reconfigure Vistapool" + }, + "user": { + "data": { + "password": "Password", + "username": "Vistapool Username" + }, + "data_description": { + "password": "The password for your Vistapool account", + "username": "The email used for your Vistapool account" + }, + "description": "Enter your Vistapool credentials. All pools on the account will be added automatically.", + "title": "Authentication" + } + } + }, + "entity": { + "button": { + "led_pulse": { + "name": "LED next color" + } + }, + "light": { + "pool_light": { + "name": "[%key:component::light::title%]" + } + }, + "number": { + "electrolysis_setpoint": { + "name": "Electrolysis setpoint" + }, + "heating_maximum_temperature": { + "name": "Heating maximum temperature" + }, + "heating_minimum_temperature": { + "name": "Heating minimum temperature" + }, + "hydrolysis_setpoint": { + "name": "Hydrolysis setpoint" + }, + "intel_temperature": { + "name": "Intel temperature" + }, + "ph_maximum": { + "name": "pH maximum" + }, + "ph_minimum": { + "name": "pH minimum" + }, + "redox_setpoint": { + "name": "Redox setpoint" + }, + "smart_maximum_temperature": { + "name": "Smart maximum temperature" + }, + "smart_minimum_temperature": { + "name": "Smart minimum temperature" + } + }, + "sensor": { + "chlorine": { + "name": "Chlorine" + }, + "conductivity": { + "name": "Conductivity" + }, + "electrolysis": { + "name": "Electrolysis" + }, + "filtration_intel_time": { + "name": "Filtration intel time" + }, + "hydrolysis": { + "name": "Hydrolysis" + }, + "redox_potential": { + "name": "Redox potential" + }, + "rssi": { + "name": "Wi-Fi signal strength" + }, + "uv": { + "name": "UV" + } + } + }, + "exceptions": { + "invalid_credentials": { + "message": "Invalid Vistapool credentials." + }, + "no_pools": { + "message": "No pools were found on this account." + }, + "set_failed": { + "message": "Failed to set {entity}." + }, + "update_failed": { + "message": "Error fetching data from Vistapool." + } + } +} diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 5b22ba41349..9ac6420b04f 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,47 +1,18 @@ """Support for Vivotek IP Cameras.""" -from __future__ import annotations - import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from libpyvivotek.vivotek import VivotekCamera -import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, - Camera, - CameraEntityFeature, -) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_AUTHENTICATION, - CONF_IP_ADDRESS, - CONF_NAME, - CONF_PASSWORD, - CONF_SSL, - CONF_USERNAME, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VivotekConfigEntry -from .const import ( - CONF_FRAMERATE, - CONF_SECURITY_LEVEL, - CONF_STREAM_PATH, - DOMAIN, - INTEGRATION_TITLE, -) +from .const import CONF_FRAMERATE, CONF_STREAM_PATH, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -52,70 +23,7 @@ DEFAULT_FRAMERATE = 2 DEFAULT_SECURITY_LEVEL = "admin" DEFAULT_STREAM_SOURCE = "live.sdp" -PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_FRAMERATE, default=DEFAULT_FRAMERATE): cv.positive_int, - vol.Optional(CONF_SECURITY_LEVEL, default=DEFAULT_SECURITY_LEVEL): cv.string, - vol.Optional(CONF_STREAM_PATH, default=DEFAULT_STREAM_SOURCE): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Vivotek camera platform.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - if ( - result.get("type") is FlowResultType.ABORT - and result.get("reason") != "already_configured" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result.get('reason')}", - breaks_in_ha_version="2026.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2026.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) +PLATFORM_SCHEMA: Final = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry( diff --git a/homeassistant/components/vivotek/config_flow.py b/homeassistant/components/vivotek/config_flow.py index 7d54d22e160..c8097e383f0 100644 --- a/homeassistant/components/vivotek/config_flow.py +++ b/homeassistant/components/vivotek/config_flow.py @@ -10,7 +10,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl from homeassistant.const import ( CONF_AUTHENTICATION, CONF_IP_ADDRESS, - CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -144,38 +143,3 @@ class VivotekConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders=DESCRIPTION_PLACEHOLDERS, ) - - async def async_step_import( - self, import_data: (dict[str, Any]) - ) -> ConfigFlowResult: - """Import a Yaml config.""" - self._async_abort_entries_match({CONF_IP_ADDRESS: import_data[CONF_IP_ADDRESS]}) - port = 443 if import_data[CONF_SSL] else 80 - try: - cam_client = build_cam_client({**import_data, CONF_PORT: port}) - mac_address = await self.hass.async_add_executor_job(cam_client.get_mac) - except VivotekCameraError: - return self.async_abort(reason="cannot_connect") - except Exception: - _LOGGER.exception("Unexpected error during camera connection test") - return self.async_abort(reason="unknown") - await self.async_set_unique_id(format_mac(mac_address)) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=import_data.get(CONF_NAME, DEFAULT_NAME), - data={ - CONF_IP_ADDRESS: import_data[CONF_IP_ADDRESS], - CONF_PORT: port, - CONF_PASSWORD: import_data[CONF_PASSWORD], - CONF_USERNAME: import_data[CONF_USERNAME], - CONF_AUTHENTICATION: import_data[CONF_AUTHENTICATION], - CONF_SSL: import_data[CONF_SSL], - CONF_VERIFY_SSL: import_data[CONF_VERIFY_SSL], - CONF_SECURITY_LEVEL: import_data[CONF_SECURITY_LEVEL], - CONF_STREAM_PATH: import_data[CONF_STREAM_PATH], - }, - options={ - CONF_FRAMERATE: import_data[CONF_FRAMERATE], - }, - ) diff --git a/homeassistant/components/vivotek/strings.json b/homeassistant/components/vivotek/strings.json index 6aac6abde29..a0eb373b7ed 100644 --- a/homeassistant/components/vivotek/strings.json +++ b/homeassistant/components/vivotek/strings.json @@ -26,16 +26,6 @@ } } }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Configuring Vivotek using camera platform YAML configuration is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the device. Please review the configuration and the connection to the camera, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "Vivotek YAML configuration deprecated" - }, - "deprecated_yaml_import_issue_unknown": { - "description": "Configuring Vivotek using camera platform YAML configuration is deprecated.\n\nWhile importing your configuration, an unknown error occurred. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "[%key:component::vivotek::issues::deprecated_yaml_import_issue_cannot_connect::title%]" - } - }, "options": { "step": { "init": { diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index ecf0342ae2f..72dd2ed7f0e 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -1,7 +1,5 @@ """The vizio component.""" -from __future__ import annotations - from pyvizio import VizioAsync from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -31,7 +29,7 @@ from .services import async_setup_services DATA_APPS: HassKey[VizioAppsDataUpdateCoordinator] = HassKey(f"{DOMAIN}_apps") CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 95f649e7059..162ef08ff8c 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Vizio.""" -from __future__ import annotations - import copy import logging import socket @@ -61,6 +59,8 @@ def _get_config_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema: return vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) ): str, diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 64064a69300..fafccf6492d 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -56,7 +56,8 @@ VIZIO_VOLUME = "volume" VIZIO_MUTE = "mute" # Since Vizio component relies on device class, this dict will ensure that changes to -# the values of DEVICE_CLASS_SPEAKER or DEVICE_CLASS_TV don't require changes to pyvizio. +# the values of DEVICE_CLASS_SPEAKER or DEVICE_CLASS_TV +# don't require changes to pyvizio. VIZIO_DEVICE_CLASSES = { MediaPlayerDeviceClass.SPEAKER: VIZIO_DEVICE_CLASS_SPEAKER, MediaPlayerDeviceClass.TV: VIZIO_DEVICE_CLASS_TV, diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index ca8a64699c7..7bebc54d383 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the vizio component.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index d7a3e481fbc..7ba44ba22a0 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,7 +1,5 @@ """Vizio SmartCast Device support.""" -from __future__ import annotations - from typing import Any from pyvizio.api.apps import AppConfig, find_app_name diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py new file mode 100644 index 00000000000..8c8028b3d69 --- /dev/null +++ b/homeassistant/components/vizio/remote.py @@ -0,0 +1,87 @@ +"""Remote platform for Vizio SmartCast devices.""" + +import asyncio +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + RemoteEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import VizioConfigEntry, VizioDeviceCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VizioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a Vizio remote entity.""" + async_add_entities([VizioRemote(config_entry)]) + + +class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity): + """Remote entity for Vizio SmartCast devices.""" + + _attr_has_entity_name = True + + def __init__(self, config_entry: VizioConfigEntry) -> None: + """Initialize the remote entity.""" + coordinator = config_entry.runtime_data.device_coordinator + super().__init__(coordinator) + self._attr_unique_id = unique_id = config_entry.unique_id + # Guard against config entries missing unique_id, which should never happen + if TYPE_CHECKING: + assert unique_id is not None + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) + self._device = coordinator.device + valid_keys = set(self._device.get_remote_keys_list()) + self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.coordinator.data.is_on + + def _resolve_command(self, command: str) -> str: + """Resolve an lowercased command string to a pyvizio key name.""" + if resolved := self._command_map.get(command): + return resolved + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_command", + translation_placeholders={"command": command}, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self._device.pow_on(log_api_exception=False) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self._device.pow_off(log_api_exception=False) + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send remote commands to the device.""" + num_repeats: int = kwargs.get(ATTR_NUM_REPEATS, 1) + delay: float = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + resolved = [vol.All(vol.Lower, self._resolve_command)(cmd) for cmd in command] + + for i in range(num_repeats): + for cmd in resolved: + await self._device.remote(cmd, log_api_exception=False) + if i < num_repeats - 1: + await asyncio.sleep(delay) diff --git a/homeassistant/components/vizio/services.py b/homeassistant/components/vizio/services.py index 0e2b40e3ca3..d63ab627666 100644 --- a/homeassistant/components/vizio/services.py +++ b/homeassistant/components/vizio/services.py @@ -1,7 +1,5 @@ """Vizio SmartCast services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 04fb7e9863b..f305f4da410 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -40,6 +40,11 @@ } } }, + "exceptions": { + "unknown_command": { + "message": "Unknown remote command `{command}`. Valid commands for this device are listed in the integration documentation." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index 7c8bdcf8a6e..154ff81695e 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -1,7 +1,5 @@ """Provide functionality to interact with vlc devices on the network.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index 08564937959..dcf15c57bb0 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for VLC media player Telnet integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 6ae9fbb9f5a..a0370ceaf95 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -1,7 +1,5 @@ """Provide functionality to interact with the vlc telnet interface.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate, Literal @@ -21,6 +19,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -53,23 +52,48 @@ async def async_setup_entry( def catch_vlc_errors[_VlcDeviceT: VlcDevice, **_P]( - func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], -) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: + *, log: bool = False +) -> Callable[ + [Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]]], + Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]], +]: """Catch VLC errors.""" - @wraps(func) - async def wrapper(self: _VlcDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> None: - """Catch VLC errors and modify availability.""" - try: - await func(self, *args, **kwargs) - except CommandError as err: - LOGGER.error("Command error: %s", err) - except ConnectError as err: - if self._attr_available: - LOGGER.error("Connection error: %s", err) - self._attr_available = False + def decorator( + func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], + ) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: + @wraps(func) + async def wrapper( + self: _VlcDeviceT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except CommandError as err: + if log: + LOGGER.error("Command error: %s", err) + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"error": str(err)}, + ) from err + except ConnectError as err: + if log: + if self._attr_available: + LOGGER.error("Connection error: %s", err) + self._attr_available = False + else: + self._attr_available = False + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connect_error", + translation_placeholders={"error": str(err)}, + ) from err - return wrapper + return wrapper + + return decorator class VlcDevice(MediaPlayerEntity): @@ -112,7 +136,7 @@ class VlcDevice(MediaPlayerEntity): ) self._using_addon = config_entry.source == SOURCE_HASSIO - @catch_vlc_errors + @catch_vlc_errors(log=True) async def async_update(self) -> None: """Get the latest details from the device.""" if not self.available: @@ -166,7 +190,8 @@ class VlcDevice(MediaPlayerEntity): self._attr_media_title = _get_str(data.get("data", {}), "title") now_playing = _get_str(data.get("data", {}), "now_playing") - # Many radio streams put artist/title/album in now_playing and title is the station name. + # Many radio streams put artist/title/album in + # now_playing and title is the station name. if now_playing: if not self.media_artist: self._attr_media_artist = self._attr_media_title @@ -185,12 +210,12 @@ class VlcDevice(MediaPlayerEntity): else: self._attr_media_title = media_title - @catch_vlc_errors + @catch_vlc_errors() async def async_media_seek(self, position: float) -> None: """Seek the media to a specific location.""" await self._vlc.seek(round(position)) - @catch_vlc_errors + @catch_vlc_errors() async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" assert self._attr_volume_level is not None @@ -202,7 +227,7 @@ class VlcDevice(MediaPlayerEntity): self._attr_is_volume_muted = mute - @catch_vlc_errors + @catch_vlc_errors() async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._vlc.set_volume(round(volume * MAX_VOLUME)) @@ -212,7 +237,7 @@ class VlcDevice(MediaPlayerEntity): # This can happen if we were muted and then see a volume_up. self._attr_is_volume_muted = False - @catch_vlc_errors + @catch_vlc_errors() async def async_media_play(self) -> None: """Send play command.""" status = await self._vlc.status() @@ -223,7 +248,7 @@ class VlcDevice(MediaPlayerEntity): await self._vlc.play() self._attr_state = MediaPlayerState.PLAYING - @catch_vlc_errors + @catch_vlc_errors() async def async_media_pause(self) -> None: """Send pause command.""" status = await self._vlc.status() @@ -233,13 +258,13 @@ class VlcDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.PAUSED - @catch_vlc_errors + @catch_vlc_errors() async def async_media_stop(self) -> None: """Send stop command.""" await self._vlc.stop() self._attr_state = MediaPlayerState.IDLE - @catch_vlc_errors + @catch_vlc_errors() async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -259,22 +284,22 @@ class VlcDevice(MediaPlayerEntity): await self._vlc.add(media_id) self._attr_state = MediaPlayerState.PLAYING - @catch_vlc_errors + @catch_vlc_errors() async def async_media_previous_track(self) -> None: """Send previous track command.""" await self._vlc.prev() - @catch_vlc_errors + @catch_vlc_errors() async def async_media_next_track(self) -> None: """Send next track command.""" await self._vlc.next() - @catch_vlc_errors + @catch_vlc_errors() async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._vlc.clear() - @catch_vlc_errors + @catch_vlc_errors() async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" shuffle_command: Literal["on", "off"] = "on" if shuffle else "off" diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index ccba5894bb7..8d92e0560c6 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -35,5 +35,13 @@ } } } + }, + "exceptions": { + "command_error": { + "message": "Command error: {error}" + }, + "connect_error": { + "message": "Connection error: {error}" + } } } diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index b792e4904f2..3c8305e7fde 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -8,7 +8,6 @@ from homeassistant.core import HomeAssistant from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL from .coordinator import VodafoneConfigEntry, VodafoneStationRouter -from .utils import async_client_session PLATFORMS = [ Platform.BUTTON, @@ -21,13 +20,12 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Set up Vodafone Station platform.""" - session = await async_client_session(hass) + coordinator = VodafoneStationRouter( hass, entry, - session, ) - + await coordinator.initialize_api() await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -39,6 +37,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Migrate old entry.""" + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + if entry.version == 1 and entry.minor_version == 1: _LOGGER.debug( "Migrating from version %s.%s", entry.version, entry.minor_version diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 8dda4d49c7b..2087f8b684d 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -1,7 +1,5 @@ """Vodafone Station buttons.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from json.decoder import JSONDecodeError diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 2c4db8c48ab..49fc716c626 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Vodafone Station integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 94ac50d0332..b10c994918e 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -7,7 +7,7 @@ from typing import Any, cast from aiohttp import ClientSession from aiovodafone import exceptions -from aiovodafone.api import VodafoneStationDevice +from aiovodafone.api import VodafoneStationCommonApi, VodafoneStationDevice from aiovodafone.models import init_device_class from yarl import URL @@ -33,6 +33,7 @@ from .const import ( SCAN_INTERVAL, ) from .helpers import cleanup_device_tracker +from .utils import async_client_session CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -61,32 +62,23 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" config_entry: VodafoneConfigEntry + api: VodafoneStationCommonApi + _session: ClientSession def __init__( self, hass: HomeAssistant, config_entry: VodafoneConfigEntry, - session: ClientSession, ) -> None: """Initialize the scanner.""" - data = config_entry.data - - self.api = init_device_class( - URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]), - data[CONF_DEVICE_DETAILS][DEVICE_TYPE], - data, - session, - ) - self._session = session - # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id super().__init__( hass=hass, logger=_LOGGER, - name=f"{DOMAIN}-{data[CONF_HOST]}-coordinator", + name=f"{DOMAIN}-{config_entry.data[CONF_HOST]}-coordinator", update_interval=timedelta(seconds=SCAN_INTERVAL), config_entry=config_entry, ) @@ -157,6 +149,12 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.GenericLoginError, JSONDecodeError, ) as err: + if isinstance(err, JSONDecodeError): + # Plain html response (usually occurs after + # a firmware update), requiring session + # reinitialization + _LOGGER.info("Stale session detected, reinitializing API session") + await self.initialize_api() raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", @@ -211,3 +209,15 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): sw_version=sensors_data["sys_firmware_version"], serial_number=self.serial_number, ) + + async def initialize_api(self) -> None: + """Init API session.""" + data = self.config_entry.data + session = await async_client_session(self.hass) + self.api = init_device_class( + URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]), + data[CONF_DEVICE_DETAILS][DEVICE_TYPE], + data, + session, + ) + self._session = session diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 4efa26cda8c..af3c29e3151 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -1,7 +1,5 @@ """Support for Vodafone Station routers.""" -from __future__ import annotations - from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect diff --git a/homeassistant/components/vodafone_station/diagnostics.py b/homeassistant/components/vodafone_station/diagnostics.py index 4778e7d5a4e..fc613db7abd 100644 --- a/homeassistant/components/vodafone_station/diagnostics.py +++ b/homeassistant/components/vodafone_station/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Vodafone Station.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/vodafone_station/helpers.py b/homeassistant/components/vodafone_station/helpers.py index aa0fda3f6be..90a5e7cff98 100644 --- a/homeassistant/components/vodafone_station/helpers.py +++ b/homeassistant/components/vodafone_station/helpers.py @@ -31,7 +31,8 @@ async def cleanup_device_tracker( entry_host = entry_name.partition(" ")[0] if entry_name else None entry_mac = entry.unique_id.partition("_")[0] - # Some devices, mainly routers, allow to change the hostname of the connected devices. + # Some devices, mainly routers, allow to change the + # hostname of the connected devices. # This can lead to entities no longer aligned to the device UI if ( entry_host diff --git a/homeassistant/components/vodafone_station/image.py b/homeassistant/components/vodafone_station/image.py index be549df6418..46614c92c50 100644 --- a/homeassistant/components/vodafone_station/image.py +++ b/homeassistant/components/vodafone_station/image.py @@ -1,7 +1,5 @@ """Vodafone Station image.""" -from __future__ import annotations - from io import BytesIO from typing import Final, cast diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 3121b77049a..32097a0a849 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "platinum", - "requirements": ["aiovodafone==3.1.3"] + "requirements": ["aiovodafone==3.3.0"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 2573864330d..08a403d33a5 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -1,7 +1,5 @@ """Vodafone Station sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -24,7 +22,6 @@ from .coordinator import VodafoneConfigEntry, VodafoneStationRouter PARALLEL_UPDATES = 0 NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -UPTIME_DEVIATION = 60 @dataclass(frozen=True, kw_only=True) @@ -38,24 +35,6 @@ class VodafoneStationEntityDescription(SensorEntityDescription): is_suitable: Callable[[dict], bool] = lambda val: True -def _calculate_uptime( - coordinator: VodafoneStationRouter, - last_value: str | datetime | float | None, - key: str, -) -> datetime: - """Calculate device uptime.""" - - delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key]) - - if ( - not isinstance(last_value, datetime) - or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_value - - def _line_connection( coordinator: VodafoneStationRouter, last_value: str | datetime | float | None, @@ -135,10 +114,11 @@ SENSOR_TYPES: Final = ( ), VodafoneStationEntityDescription( key="sys_uptime", - translation_key="sys_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, - value=_calculate_uptime, + value=lambda coordinator, last_value, key: coordinator.api.convert_uptime( + coordinator.data.sensors[key] + ), ), VodafoneStationEntityDescription( key="sys_cpu_usage", diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 5a32f7ecc47..16186a36173 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -113,9 +113,6 @@ "sys_reboot_cause": { "name": "Reboot cause" }, - "sys_uptime": { - "name": "Uptime" - }, "up_stream": { "name": "WAN upload rate" } diff --git a/homeassistant/components/vodafone_station/switch.py b/homeassistant/components/vodafone_station/switch.py index fd547f446f7..da409ceec30 100644 --- a/homeassistant/components/vodafone_station/switch.py +++ b/homeassistant/components/vodafone_station/switch.py @@ -1,7 +1,5 @@ """Support for switches.""" -from __future__ import annotations - from dataclasses import dataclass from json.decoder import JSONDecodeError from typing import Any, Final diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index cfdaf4dc192..aaf9b7033df 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -1,7 +1,5 @@ """The Voice over IP integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -22,7 +20,6 @@ from .voip import HassVoipDatagramProtocol PLATFORMS = ( Platform.ASSIST_SATELLITE, - Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH, ) @@ -36,8 +33,6 @@ __all__ = [ "async_unload_entry", ] -type VoipConfigEntry = ConfigEntry[VoipStore] - @dataclass class DomainData: @@ -48,6 +43,17 @@ class DomainData: devices: VoIPDevices +@dataclass +class VoipData: + """Voip Runtime Data.""" + + store: VoipStore + domain_data: DomainData + + +type VoipConfigEntry = ConfigEntry[VoipData] + + async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool: """Set up VoIP integration from a config entry.""" # Make sure there is a valid user ID for VoIP in the config entry @@ -62,9 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool entry, data={**entry.data, "user": voip_user.id} ) - entry.runtime_data = VoipStore(hass, entry.entry_id) sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) - devices = VoIPDevices(hass, entry) + store = VoipStore(hass, entry.entry_id) + devices = VoIPDevices(hass, entry, store) await devices.async_setup() transport, protocol = await _create_sip_server( hass, @@ -72,8 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool sip_port, ) _LOGGER.debug("Listening for VoIP calls on port %s", sip_port) - - hass.data[DOMAIN] = DomainData(transport, protocol, devices) + entry.runtime_data = VoipData(store, DomainData(transport, protocol, devices)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -110,9 +115,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> boo """Unload VoIP.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): _LOGGER.debug("Shutting down VoIP server") - data = hass.data.pop(DOMAIN) - data.transport.close() - await data.protocol.wait_closed() + entry.runtime_data.domain_data.transport.close() + await entry.runtime_data.domain_data.protocol.wait_closed() _LOGGER.debug("VoIP server shut down successfully") return unload_ok @@ -132,4 +136,4 @@ async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> Non ): await hass.auth.async_remove_user(user) - await entry.runtime_data.async_remove() + await entry.runtime_data.store.async_remove() diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 14333c33be5..60120cbdab0 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -1,7 +1,5 @@ """Assist satellite entity for VoIP integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta from enum import IntFlag @@ -11,11 +9,11 @@ import logging from pathlib import Path import socket import time -from typing import TYPE_CHECKING, Any, Final +from typing import Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipEndpoint, get_sip_endpoint from homeassistant.components import intent, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -28,11 +26,11 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.components.intent import TimerEventType, TimerInfo from homeassistant.components.network import async_get_source_ip -from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import VoipConfigEntry from .const import ( CHANNELS, CONF_SIP_PORT, @@ -45,9 +43,6 @@ from .const import ( from .devices import VoIPDevice from .entity import VoIPEntity -if TYPE_CHECKING: - from . import DomainData - _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 @@ -74,11 +69,11 @@ _TONE_FILENAMES: dict[Tones, str] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VoipConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP Assist satellite entity.""" - domain_data: DomainData = hass.data[DOMAIN] + domain_data = config_entry.runtime_data.domain_data @callback def async_add_device(device: VoIPDevice) -> None: @@ -111,7 +106,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self, hass: HomeAssistant, voip_device: VoIPDevice, - config_entry: ConfigEntry, + config_entry: VoipConfigEntry, tones=Tones.LISTENING | Tones.PROCESSING | Tones.ERROR, ) -> None: """Initialize an Assist satellite.""" @@ -149,7 +144,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol @property def vad_sensitivity_entity_id(self) -> str | None: - """Return the entity ID of the VAD sensitivity to use for the next conversation.""" + """Return the VAD sensitivity entity ID for next conversation.""" return self.voip_device.get_vad_sensitivity_entity_id(self.hass) @property @@ -270,7 +265,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._announcement = announcement # Make the call - sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + sip_protocol = self.config_entry.runtime_data.domain_data.protocol _LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact) call_info = sip_protocol.outgoing_call( source=source_endpoint, @@ -299,7 +294,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol async def _check_announcement_pickup(self) -> None: """Continuously checks if an audio chunk was received within a time limit. - If not, the caller is presumed to have not picked up the phone and the announcement is ended. + If not, the caller is presumed to have not picked + up the phone and the announcement is ended. """ while True: current_time = time.monotonic() @@ -453,7 +449,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # length of the TTS audio. await self._tts_done.wait() except TimeoutError: - # This shouldn't happen anymore, we are detecting hang ups with a separate task + # This shouldn't happen anymore, we are detecting + # hang ups with a separate task _LOGGER.exception("Timeout error") self.disconnect() # caller hung up except asyncio.CancelledError: @@ -571,7 +568,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol or (sample_channels != CHANNELS) ): raise ValueError( - f"Expected rate/width/channels as {RATE}/{WIDTH}/{CHANNELS}," + "Expected rate/width/channels as" + f" {RATE}/{WIDTH}/{CHANNELS}," f" got {sample_rate}/{sample_width}/{sample_channels}" ) diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py deleted file mode 100644 index 34dac4b6068..00000000000 --- a/homeassistant/components/voip/binary_sensor.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Binary sensor for VoIP.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN -from .devices import VoIPDevice -from .entity import VoIPEntity - -if TYPE_CHECKING: - from . import DomainData - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up VoIP binary sensor entities.""" - domain_data: DomainData = hass.data[DOMAIN] - - @callback - def async_add_device(device: VoIPDevice) -> None: - """Add device.""" - async_add_entities([VoIPCallInProgress(device)]) - - domain_data.devices.async_add_new_device_listener(async_add_device) - - async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices]) - - -class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): - """Entity to represent voip call is in progress.""" - - entity_description = BinarySensorEntityDescription( - entity_registry_enabled_default=False, - key="call_in_progress", - translation_key="call_in_progress", - ) - _attr_is_on = False - - async def async_added_to_hass(self) -> None: - """Call when entity about to be added to hass.""" - await super().async_added_to_hass() - - self.async_on_remove( - self.voip_device.async_listen_update(self._is_active_changed) - ) - - await super().async_added_to_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_create_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - breaks_in_ha_version="2025.4", - data={ - "entity_id": self.entity_id, - "entity_uuid": self.registry_entry.id, - "integration_name": "VoIP", - }, - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="assist_in_progress_deprecated", - translation_placeholders={ - "integration_name": "VoIP", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Remove issue.""" - await super().async_will_remove_from_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_delete_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - ) - - @callback - def _is_active_changed(self, device: VoIPDevice) -> None: - """Call when active state changed.""" - self._attr_is_on = self.voip_device.is_active - self.async_write_ha_state() diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index 7ae603f0f6a..76786ac7285 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -1,7 +1,5 @@ """Config flow for VoIP integration.""" -from __future__ import annotations - from typing import Any from voip_utils import SIP_PORT diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index d8ac49a19df..d98d319917e 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -1,22 +1,22 @@ """Class to manage devices.""" -from __future__ import annotations - from collections.abc import Callable, Iterator from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING, Any from voip_utils import CallInfo, VoipDatagramProtocol from voip_utils.sip import SipEndpoint -from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN from .store import DeviceContact, DeviceContacts, VoipStore +if TYPE_CHECKING: + from . import VoipConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -80,13 +80,15 @@ class VoIPDevice: class VoIPDevices: """Class to store devices.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: VoipConfigEntry, store: VoipStore + ) -> None: """Initialize VoIP devices.""" self.hass = hass self.config_entry = config_entry self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] self.devices: dict[str, VoIPDevice] = {} - self.device_store: VoipStore = config_entry.runtime_data + self.device_store = store async def async_setup(self) -> None: """Set up devices.""" diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py index e96784bc218..105ba648c22 100644 --- a/homeassistant/components/voip/entity.py +++ b/homeassistant/components/voip/entity.py @@ -1,7 +1,5 @@ """VoIP entities.""" -from __future__ import annotations - from homeassistant.helpers import entity from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 4d6756c3419..eefc18dca88 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.5"] + "requirements": ["voip-utils==0.4.0"] } diff --git a/homeassistant/components/voip/repairs.py b/homeassistant/components/voip/repairs.py deleted file mode 100644 index 600ea4d66fb..00000000000 --- a/homeassistant/components/voip/repairs.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Repairs implementation for the VoIP integration.""" - -from __future__ import annotations - -from homeassistant.components.assist_pipeline.repair_flows import ( # pylint: disable=hass-component-root-import - AssistInProgressDeprecatedRepairFlow, -) -from homeassistant.components.repairs import RepairsFlow -from homeassistant.core import HomeAssistant - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None] | None, -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("assist_in_progress_deprecated"): - return AssistInProgressDeprecatedRepairFlow(data) - # If VoIP adds confirm-only repairs in the future, this should be changed - # to return a ConfirmRepairFlow instead of raising a ValueError - raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index 8c9668b09ef..b215daaa2cd 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -1,32 +1,25 @@ """Select entities for VoIP integration.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - from homeassistant.components.assist_pipeline import ( AssistPipelineSelect, VadSensitivitySelect, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import VoipConfigEntry from .const import DOMAIN from .devices import VoIPDevice from .entity import VoIPEntity -if TYPE_CHECKING: - from . import DomainData - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VoipConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" - domain_data: DomainData = hass.data[DOMAIN] + domain_data = config_entry.runtime_data.domain_data @callback def async_add_device(device: VoIPDevice) -> None: diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 489c16b28ea..d0502190b4b 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -10,11 +10,6 @@ } }, "entity": { - "binary_sensor": { - "call_in_progress": { - "name": "Call in progress" - } - }, "select": { "pipeline": { "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", @@ -48,18 +43,6 @@ "message": "VoIP does not currently support non-TTS announcements" } }, - "issues": { - "assist_in_progress_deprecated": { - "fix_flow": { - "step": { - "confirm_disable_entity": { - "description": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::fix_flow::step::confirm_disable_entity::description%]" - } - } - }, - "title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]" - } - }, "options": { "step": { "init": { diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py index 7690b8f125c..11004906023 100644 --- a/homeassistant/components/voip/switch.py +++ b/homeassistant/components/voip/switch.py @@ -1,31 +1,25 @@ """VoIP switch entities.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import VoipConfigEntry from .devices import VoIPDevice from .entity import VoIPEntity -if TYPE_CHECKING: - from . import DomainData - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VoipConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" - domain_data: DomainData = hass.data[DOMAIN] + domain_data = config_entry.runtime_data.domain_data @callback def async_add_device(device: VoIPDevice) -> None: diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 6f6cf989d3b..da223292218 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -1,7 +1,5 @@ """Voice over IP (VoIP) implementation.""" -from __future__ import annotations - import asyncio from functools import partial import logging @@ -150,11 +148,6 @@ class PreRecordMessageProtocol(RtpDatagramProtocol): if self.transport is None: return - if self._audio_bytes is None: - # 16Khz, 16-bit mono audio message - file_path = Path(__file__).parent / self.file_name - self._audio_bytes = file_path.read_bytes() - if self._audio_task is None: self._audio_task = self.hass.async_create_background_task( self._play_message(), @@ -162,6 +155,11 @@ class PreRecordMessageProtocol(RtpDatagramProtocol): ) async def _play_message(self) -> None: + if self._audio_bytes is None: + _LOGGER.debug("Loading audio from file %s", self.file_name) + self._audio_bytes = await self._load_audio() + _LOGGER.debug("Read %s bytes", len(self._audio_bytes)) + await self.hass.async_add_executor_job( partial( self.send_audio, @@ -175,3 +173,8 @@ class PreRecordMessageProtocol(RtpDatagramProtocol): # Allow message to play again self._audio_task = None + + async def _load_audio(self) -> bytes: + # 16Khz, 16-bit mono audio message + file_path = Path(__file__).parent / self.file_name + return await self.hass.async_add_executor_job(file_path.read_bytes) diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index 5bd4a63c923..84ffce7be8b 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -1,7 +1,5 @@ """Support for consuming values for the Volkszaehler API.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/volumio/__init__.py b/homeassistant/components/volumio/__init__.py index 77119b9a65e..8977acc87fe 100644 --- a/homeassistant/components/volumio/__init__.py +++ b/homeassistant/components/volumio/__init__.py @@ -1,5 +1,8 @@ """The Volumio integration.""" +from dataclasses import dataclass +from typing import Any + from pyvolumio import CannotConnectError, Volumio from homeassistant.config_entries import ConfigEntry @@ -8,12 +11,21 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass +class VolumioData: + """Volumio data class.""" + + volumio: Volumio + info: dict[str, Any] + + +type VolumioConfigEntry = ConfigEntry[VolumioData] + + +async def async_setup_entry(hass: HomeAssistant, entry: VolumioConfigEntry) -> bool: """Set up Volumio from a config entry.""" volumio = Volumio( @@ -24,20 +36,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except CannotConnectError as error: raise ConfigEntryNotReady from error - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - DATA_VOLUMIO: volumio, - DATA_INFO: info, - } + entry.runtime_data = VolumioData(volumio=volumio, info=info) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VolumioConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 00b3ab911ae..ace3e91e33b 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Volumio integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/volumio/const.py b/homeassistant/components/volumio/const.py index 608c029a85e..51080a09254 100644 --- a/homeassistant/components/volumio/const.py +++ b/homeassistant/components/volumio/const.py @@ -1,6 +1,3 @@ """Constants for the Volumio integration.""" DOMAIN = "volumio" - -DATA_INFO = "info" -DATA_VOLUMIO = "volumio" diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 773a125d483..46b549cd7c2 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -3,8 +3,6 @@ Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ -from __future__ import annotations - from datetime import timedelta import json from typing import Any @@ -17,29 +15,29 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle +from . import VolumioConfigEntry from .browse_media import browse_node, browse_top_level -from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN +from .const import DOMAIN PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VolumioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Volumio media player platform.""" - data = hass.data[DOMAIN][config_entry.entry_id] - volumio = data[DATA_VOLUMIO] - info = data[DATA_INFO] + data = config_entry.runtime_data + volumio = data.volumio + info = data.info uid = config_entry.data[CONF_ID] name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index a606ffae0e5..359a5494c04 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -1,7 +1,5 @@ """The Volvo integration.""" -from __future__ import annotations - import asyncio from volvocarsapi.api import VolvoCarsApi diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py index bfc48a1ee00..6f87d5ec3d8 100644 --- a/homeassistant/components/volvo/application_credentials.py +++ b/homeassistant/components/volvo/application_credentials.py @@ -1,7 +1,5 @@ """Application credentials platform for the Volvo integration.""" -from __future__ import annotations - from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL from volvocarsapi.scopes import ALL_SCOPES diff --git a/homeassistant/components/volvo/binary_sensor.py b/homeassistant/components/volvo/binary_sensor.py index ed71a515226..8016fa8787e 100644 --- a/homeassistant/components/volvo/binary_sensor.py +++ b/homeassistant/components/volvo/binary_sensor.py @@ -1,7 +1,5 @@ """Volvo binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass, field from volvocarsapi.models import VolvoCarsApiBaseModel, VolvoCarsValue diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index 9f38c16b4fe..8d3b5be312c 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Volvo.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index db2654da179..d5534899ce8 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -1,7 +1,5 @@ """Volvo coordinators.""" -from __future__ import annotations - from abc import abstractmethod import asyncio from collections.abc import Callable, Coroutine @@ -263,9 +261,21 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): api.async_get_odometer, ] - location = await api.async_get_location() + # Volvo is returning FORBIDDEN for the location request in case the vehicle + # is in an unsupported region. Since we can't know where the vehicle is + # located, we silently ignore the failure. If (re-)authentication is needed, + # other requests will fail as well and trigger the re-auth flow. + location = None + try: + location = await api.async_get_location() + except VolvoAuthException as ex: + _LOGGER.debug( + "%s - Location not supported for this vehicle. %s", + self.config_entry.entry_id, + ex.message, + ) - if location.get("location") is not None: + if location and location.get("location") is not None: api_calls.append(api.async_get_location) return api_calls diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index ccc5e51d2aa..e5358ad4a70 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -55,7 +55,8 @@ class VolvoBaseEntity(Entity): model = ( f"{vehicle.description.model} ({vehicle.model_year})" if vehicle.fuel_type == "NONE" - else f"{vehicle.description.model} {vehicle.fuel_type} ({vehicle.model_year})" + else f"{vehicle.description.model}" + f" {vehicle.fuel_type} ({vehicle.model_year})" ) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/volvo/lock.py b/homeassistant/components/volvo/lock.py index 85686ca2320..8c7f55131e8 100644 --- a/homeassistant/components/volvo/lock.py +++ b/homeassistant/components/volvo/lock.py @@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity): def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: """Update the state of the entity.""" + if api_field is None: + self._attr_is_locked = None + return + assert isinstance(api_field, VolvoCarsValue) self._attr_is_locked = api_field.value == "LOCKED" diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 77e3fdfa29d..2820095cba2 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -1,7 +1,5 @@ """Volvo sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -101,6 +99,7 @@ def _direction_value(field: VolvoCarsApiBaseModel) -> str | None: _CHARGING_POWER_STATUS_OPTIONS = [ "fault", + "initialization", "power_available_but_not_activated", "providing_power", "no_power_available", diff --git a/homeassistant/components/volvo/services.py b/homeassistant/components/volvo/services.py index 4f8ff3739ec..cdf7161cad6 100644 --- a/homeassistant/components/volvo/services.py +++ b/homeassistant/components/volvo/services.py @@ -88,7 +88,8 @@ async def _get_image_url(call: ServiceCall) -> dict[str, Any]: candidates.append((image_type, url)) - # Interior images exist if their URL is populated; exterior images require an HTTP check + # Interior images exist if their URL is populated; + # exterior images require an HTTP check async def _check_exists(image_type: str, url: str) -> bool: if image_type == "interior": return bool(url) diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 2c41bdb3fd2..445fd04cb9c 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -281,6 +281,7 @@ "name": "Charging power status", "state": { "fault": "[%key:common::state::fault%]", + "initialization": "Initialization", "no_power_available": "No power", "power_available_but_not_activated": "Power available", "providing_power": "Providing power" diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 6542f34b487..b07f1fb59d7 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,7 +1,5 @@ """The Volvo On Call integration.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index e1aa95cb730..d85446c6d9f 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Volvo On Call integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py index c8cc166ec01..c436f89f2df 100644 --- a/homeassistant/components/w800rf32/binary_sensor.py +++ b/homeassistant/components/w800rf32/binary_sensor.py @@ -1,7 +1,5 @@ """Support for w800rf32 binary sensors.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index f69755d05e8..8dd19727570 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import CONF_SECUREON_PASSWORD, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,7 @@ SERVICE_SEND_MAGIC_PACKET = "send_magic_packet" WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema( { vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_SECUREON_PASSWORD): cv.string, vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, vol.Optional(CONF_BROADCAST_PORT): cv.port, } @@ -34,7 +35,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def send_magic_packet(call: ServiceCall) -> None: """Send magic packet to wake up a device.""" - mac_address = call.data.get(CONF_MAC) + mac_address: str = call.data[CONF_MAC] + secureon_password = call.data.get(CONF_SECUREON_PASSWORD) broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS) broadcast_port = call.data.get(CONF_BROADCAST_PORT) @@ -45,14 +47,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: service_kwargs["port"] = broadcast_port _LOGGER.debug( - "Send magic packet to mac %s (broadcast: %s, port: %s)", + "Send magic packet to mac %s (secureon: %s, broadcast: %s, port: %s)", mac_address, + secureon_password is not None, broadcast_address, broadcast_port, ) + if secureon_password: + mac_address += f"/{secureon_password}" + await hass.async_add_executor_job( - partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) # type: ignore[arg-type] + partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) ) hass.services.async_register( diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py index e9cf69b1fe7..88173e1160c 100644 --- a/homeassistant/components/wake_on_lan/button.py +++ b/homeassistant/components/wake_on_lan/button.py @@ -1,7 +1,5 @@ """Support for button entity in wake on lan.""" -from __future__ import annotations - from functools import partial import logging from typing import Any @@ -15,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import CONF_SECUREON_PASSWORD + _LOGGER = logging.getLogger(__name__) @@ -27,6 +27,7 @@ async def async_setup_entry( broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS) broadcast_port: int | None = entry.options.get(CONF_BROADCAST_PORT) mac_address: str = entry.options[CONF_MAC] + secureon_password: str | None = entry.options.get(CONF_SECUREON_PASSWORD) name: str = entry.title async_add_entities( @@ -34,6 +35,7 @@ async def async_setup_entry( WolButton( name, mac_address, + secureon_password, broadcast_address, broadcast_port, ) @@ -50,11 +52,13 @@ class WolButton(ButtonEntity): self, name: str, mac_address: str, + secureon_password: str | None, broadcast_address: str | None, broadcast_port: int | None, ) -> None: """Initialize the WOL button.""" self._mac_address = mac_address + self._secureon_password = secureon_password self._broadcast_address = broadcast_address self._broadcast_port = broadcast_port self._attr_unique_id = dr.format_mac(mac_address) @@ -72,12 +76,17 @@ class WolButton(ButtonEntity): service_kwargs["port"] = self._broadcast_port _LOGGER.debug( - "Send magic packet to mac %s (broadcast: %s, port: %s)", + "Send magic packet to mac %s (secureon: %s, broadcast: %s, port: %s)", self._mac_address, + self._secureon_password is not None, self._broadcast_address, self._broadcast_port, ) + mac = self._mac_address + if self._secureon_password: + mac += f"/{self._secureon_password}" + await self.hass.async_add_executor_job( - partial(wakeonlan.send_magic_packet, self._mac_address, **service_kwargs) + partial(wakeonlan.send_magic_packet, mac, **service_kwargs) ) diff --git a/homeassistant/components/wake_on_lan/config_flow.py b/homeassistant/components/wake_on_lan/config_flow.py index e6700c04604..9fcc02542bd 100644 --- a/homeassistant/components/wake_on_lan/config_flow.py +++ b/homeassistant/components/wake_on_lan/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.helpers.selector import ( TextSelector, ) -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_SECUREON_PASSWORD, DEFAULT_NAME, DOMAIN async def validate( @@ -48,6 +48,7 @@ async def validate_options( DATA_SCHEMA = {vol.Required(CONF_MAC): TextSelector()} OPTIONS_SCHEMA = { + vol.Optional(CONF_SECUREON_PASSWORD): TextSelector(), vol.Optional(CONF_BROADCAST_ADDRESS): TextSelector(), vol.Optional(CONF_BROADCAST_PORT): NumberSelector( NumberSelectorConfig(min=0, max=65535, step=1, mode=NumberSelectorMode.BOX) diff --git a/homeassistant/components/wake_on_lan/const.py b/homeassistant/components/wake_on_lan/const.py index 20b9573cfde..48d9cbd225b 100644 --- a/homeassistant/components/wake_on_lan/const.py +++ b/homeassistant/components/wake_on_lan/const.py @@ -6,6 +6,7 @@ DOMAIN = "wake_on_lan" PLATFORMS = [Platform.BUTTON] CONF_OFF_ACTION = "turn_off" +CONF_SECUREON_PASSWORD = "secureon_password" DEFAULT_NAME = "Wake on LAN" DEFAULT_PING_TIMEOUT = 1 diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json index 4643ea4a4ff..c9547419f79 100644 --- a/homeassistant/components/wake_on_lan/manifest.json +++ b/homeassistant/components/wake_on_lan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wake_on_lan", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wakeonlan==3.1.0"] + "requirements": ["wakeonlan==3.3.0"] } diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml index e7c048daf64..d30754304fb 100644 --- a/homeassistant/components/wake_on_lan/services.yaml +++ b/homeassistant/components/wake_on_lan/services.yaml @@ -5,6 +5,11 @@ send_magic_packet: example: "aa:bb:cc:dd:ee:ff" selector: text: + secureon_password: + example: "11:22:33:44:55:66" + selector: + text: + type: password broadcast_address: example: 192.168.255.255 selector: diff --git a/homeassistant/components/wake_on_lan/strings.json b/homeassistant/components/wake_on_lan/strings.json index a72edaba580..ce0a5d40c89 100644 --- a/homeassistant/components/wake_on_lan/strings.json +++ b/homeassistant/components/wake_on_lan/strings.json @@ -8,12 +8,14 @@ "data": { "broadcast_address": "Broadcast address", "broadcast_port": "Broadcast port", - "mac": "MAC address" + "mac": "MAC address", + "secureon_password": "SecureOn password" }, "data_description": { "broadcast_address": "The IP address of the host to send the magic packet to. Defaults to `255.255.255.255` and is normally not changed.", "broadcast_port": "The port to send the magic packet to. Defaults to `9` and is normally not changed.", - "mac": "MAC address of the device to wake up." + "mac": "MAC address of the device to wake up.", + "secureon_password": "The SecureOn password in 6 bytes hexadecimal format to append to the magic packet." } } } @@ -26,11 +28,13 @@ "init": { "data": { "broadcast_address": "[%key:component::wake_on_lan::config::step::user::data::broadcast_address%]", - "broadcast_port": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]" + "broadcast_port": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]", + "secureon_password": "[%key:component::wake_on_lan::config::step::user::data::secureon_password%]" }, "data_description": { "broadcast_address": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_address%]", - "broadcast_port": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]" + "broadcast_port": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]", + "secureon_password": "[%key:component::wake_on_lan::config::step::user::data_description::secureon_password%]" } } } @@ -50,6 +54,10 @@ "mac": { "description": "[%key:component::wake_on_lan::config::step::user::data_description::mac%]", "name": "[%key:component::wake_on_lan::config::step::user::data::mac%]" + }, + "secureon_password": { + "description": "[%key:component::wake_on_lan::config::step::user::data_description::secureon_password%]", + "name": "[%key:component::wake_on_lan::config::step::user::data::secureon_password%]" } }, "name": "Send magic packet" diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index 16df34c1d1b..33be7239a0b 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -1,7 +1,5 @@ """Support for wake on lan.""" -from __future__ import annotations - import logging import subprocess as sp from typing import Any @@ -125,6 +123,16 @@ class WolSwitch(SwitchEntity): self._state = True self.schedule_update_ha_state() + async def async_will_remove_from_hass(self) -> None: + """Clean up script when removing from Home Assistant.""" + if self._off_script is None: + return + if self.registry_entry and self.registry_entry.entity_id != self.entity_id: + # Entity ID change, do not unload the script as it will be reused. + await self._off_script.async_stop() + return + await self._off_script.async_unload() + def turn_off(self, **kwargs: Any) -> None: """Turn the device off if an off action is present.""" if self._off_script is not None: @@ -135,7 +143,7 @@ class WolSwitch(SwitchEntity): self.schedule_update_ha_state() def update(self) -> None: - """Check if device is on and update the state. Only called if assumed state is false.""" + """Check if device is on and update the state.""" ping_cmd = [ "ping", "-c", diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 65556668bac..ce700852984 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -1,7 +1,5 @@ """Provide functionality to wake word.""" -from __future__ import annotations - from abc import abstractmethod import asyncio from collections.abc import AsyncIterable diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index c6fe991be5e..44d71476944 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -1,7 +1,5 @@ """The Wallbox integration.""" -from __future__ import annotations - from wallbox import Wallbox from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -17,6 +15,7 @@ from .const import ( from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity PLATFORMS = [ + Platform.BUTTON, Platform.LOCK, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/wallbox/button.py b/homeassistant/components/wallbox/button.py new file mode 100644 index 00000000000..24c7b5f4e02 --- /dev/null +++ b/homeassistant/components/wallbox/button.py @@ -0,0 +1,71 @@ +"""Home Assistant component for accessing the Wallbox Portal API button.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CHARGER_DATA_KEY, + CHARGER_RESUME_SCHEDULE_KEY, + CHARGER_SERIAL_NUMBER_KEY, +) +from .coordinator import WallboxConfigEntry, WallboxCoordinator +from .entity import WallboxEntity + + +@dataclass(frozen=True, kw_only=True) +class WallboxButtonEntityDescription(ButtonEntityDescription): + """Describes Wallbox button entity.""" + + press_fn: Callable[[WallboxCoordinator], Awaitable[None]] + + +BUTTON_TYPES: dict[str, WallboxButtonEntityDescription] = { + CHARGER_RESUME_SCHEDULE_KEY: WallboxButtonEntityDescription( + key=CHARGER_RESUME_SCHEDULE_KEY, + translation_key=CHARGER_RESUME_SCHEDULE_KEY, + press_fn=lambda coordinator: coordinator.async_resume_schedule(), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WallboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create wallbox button entities in HASS.""" + coordinator: WallboxCoordinator = entry.runtime_data + async_add_entities( + [WallboxButton(coordinator, BUTTON_TYPES[CHARGER_RESUME_SCHEDULE_KEY])] + ) + + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class WallboxButton(WallboxEntity, ButtonEntity): + """Representation of the Wallbox portal.""" + + entity_description: WallboxButtonEntityDescription + + def __init__( + self, + coordinator: WallboxCoordinator, + description: WallboxButtonEntityDescription, + ) -> None: + """Initialize a Wallbox button.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{description.key}-" + f"{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) + + async def async_press(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + await self.entity_description.press_fn(self.coordinator) diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index 46de061a33c..b2444cabc16 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Wallbox integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index e0289b57ad7..0cdb1670efa 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -38,6 +38,7 @@ CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" +CHARGER_RESUME_SCHEDULE_KEY = "resume_schedule" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" CHARGER_STATE_OF_CHARGE_KEY = "state_of_charge" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 7558ddecc98..74df6efd57a 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the wallbox integration.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus @@ -239,7 +237,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication async def _async_update_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations.""" + """Get new sensor data for Wallbox component.""" self.update_interval = timedelta( seconds=UPDATE_INTERVAL @@ -392,6 +390,31 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.hass.async_add_executor_job(self._pause_charger, pause) await self.async_request_refresh() + def _resume_schedule(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + try: + self._wallbox.resumeSchedule(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, + ) from wallbox_connection_error + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error + + @_require_authentication + async def async_resume_schedule(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + await self.hass.async_add_executor_job(self._resume_schedule) + await self.async_request_refresh() + def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" try: diff --git a/homeassistant/components/wallbox/entity.py b/homeassistant/components/wallbox/entity.py index 3fe1865af4a..081986616e5 100644 --- a/homeassistant/components/wallbox/entity.py +++ b/homeassistant/components/wallbox/entity.py @@ -1,7 +1,5 @@ """Base entity for the wallbox integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index f48ac000110..05cd93e7230 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -1,6 +1,4 @@ -"""Home Assistant component for accessing the Wallbox Portal API. The lock component creates a lock entity.""" - -from __future__ import annotations +"""Home Assistant component for accessing the Wallbox Portal API lock.""" from typing import Any @@ -54,7 +52,10 @@ class WallboxLock(WallboxEntity, LockEntity): super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + self._attr_unique_id = ( + f"{description.key}" + f"-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) @property def is_locked(self) -> bool: diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 6bc37778a61..7bd91b4d668 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -3,8 +3,6 @@ The number component allows control of charging current. """ -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import cast @@ -109,7 +107,10 @@ class WallboxNumber(WallboxEntity, NumberEntity): super().__init__(coordinator) self.entity_description = description self._coordinator = coordinator - self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + self._attr_unique_id = ( + f"{description.key}" + f"-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) @property def native_max_value(self) -> float: diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 8d4cf252344..bf59d8107b0 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -1,6 +1,4 @@ -"""Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" - -from __future__ import annotations +"""Home Assistant component for accessing the Wallbox Portal API select.""" from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -90,7 +88,10 @@ class WallboxSelect(WallboxEntity, SelectEntity): """Initialize a Wallbox select entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + self._attr_unique_id = ( + f"{description.key}" + f"-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index b59e1e5319d..a593e9bbac2 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -1,6 +1,4 @@ -"""Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" - -from __future__ import annotations +"""Home Assistant component for accessing the Wallbox Portal API sensors.""" from dataclasses import dataclass from typing import cast @@ -198,11 +196,14 @@ class WallboxSensor(WallboxEntity, SensorEntity): """Initialize a Wallbox sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + self._attr_unique_id = ( + f"{description.key}" + f"-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) @property def native_value(self) -> StateType: - """Return the state of the sensor. Round the value when it, and the precision property are not None.""" + """Return the state of the sensor, rounded if applicable.""" if ( sensor_round := self.entity_description.precision ) is not None and self.coordinator.data[ @@ -216,7 +217,7 @@ class WallboxSensor(WallboxEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor. When monetary, get the value from the api.""" + """Return the unit of measurement of the sensor.""" if self.entity_description.key in ( CHARGER_ENERGY_PRICE_KEY, CHARGER_DEPOT_PRICE_KEY, diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 8c3ffc458eb..63c12c7efd0 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "button": { + "resume_schedule": { + "name": "Resume schedule" + } + }, "lock": { "lock": { "name": "[%key:component::lock::title%]" diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 74f1783f539..3b174026bb4 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -1,6 +1,4 @@ -"""Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" - -from __future__ import annotations +"""Home Assistant component for accessing the Wallbox Portal API switch.""" from typing import Any @@ -53,7 +51,10 @@ class WallboxSwitch(WallboxEntity, SwitchEntity): """Initialize a Wallbox switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + self._attr_unique_id = ( + f"{description.key}" + f"-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) @property def available(self) -> bool: diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 2014f376e9c..bf191e5b6c6 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1,7 +1,5 @@ """The World Air Quality Index (WAQI) integration.""" -from __future__ import annotations - from types import MappingProxyType from typing import TYPE_CHECKING diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index d4090e688d9..7350952fa66 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for World Air Quality Index (WAQI) integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 0c9e624ba66..83e3873d650 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the World Air Quality Index (WAQI) integration.""" -from __future__ import annotations - from datetime import timedelta from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError diff --git a/homeassistant/components/waqi/diagnostics.py b/homeassistant/components/waqi/diagnostics.py index 636b8980d0a..e9a26ebe564 100644 --- a/homeassistant/components/waqi/diagnostics.py +++ b/homeassistant/components/waqi/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for WAQI.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index cbec9d7476b..14fb30744d0 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,7 +1,5 @@ """Support for the World Air Quality Index service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index d93bcd53c99..4e9010f5f3f 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -1,7 +1,5 @@ """Support for water heater devices.""" -from __future__ import annotations - from datetime import timedelta from enum import IntFlag import functools as ft diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index da9b8a383d9..6b5754f168e 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -1,7 +1,5 @@ """Provides conditions for water heaters.""" -from __future__ import annotations - from typing import TYPE_CHECKING import voluptuous as vol @@ -76,6 +74,13 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip water heater entities that do not expose a target temperature.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_TEMPERATURE) is not None + ) + def _get_entity_unit(self, entity_state: State) -> str | None: """Get the temperature unit of a water heater entity from its state.""" # Water heater entities convert temperatures to the system unit via show_temp diff --git a/homeassistant/components/water_heater/conditions.yaml b/homeassistant/components/water_heater/conditions.yaml index a200dfcf832..709a3c52b57 100644 --- a/homeassistant/components/water_heater/conditions.yaml +++ b/homeassistant/components/water_heater/conditions.yaml @@ -7,11 +7,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: &condition_for + required: true + default: 00:00:00 + selector: + duration: .temperature_units: &temperature_units - "°C" @@ -32,6 +34,7 @@ is_operation_mode: target: *condition_water_heater_target fields: behavior: *condition_behavior + for: *condition_for operation_mode: context: filter_target: target @@ -48,6 +51,7 @@ is_target_temperature: target: *condition_water_heater_target fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index d68919ff8f3..8ef1735af32 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Water Heater.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/water_heater/reproduce_state.py b/homeassistant/components/water_heater/reproduce_state.py index de0bb320020..7693790f280 100644 --- a/homeassistant/components/water_heater/reproduce_state.py +++ b/homeassistant/components/water_heater/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Water heater state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py index c0db97c6e40..e741d99f15f 100644 --- a/homeassistant/components/water_heater/significant_change.py +++ b/homeassistant/components/water_heater/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Water Heater state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 1e7da70662a..df1286d208c 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is off" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is on" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" + }, "operation_mode": { "description": "The operation mode to check for.", "name": "Operation mode" @@ -43,6 +54,9 @@ "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::water_heater::common::condition_threshold_name%]" } @@ -117,21 +131,6 @@ "message": "Operation mode {operation_mode} is not valid for {entity_id}. The operation list is not defined." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "set_away_mode": { "description": "Sets the away mode of a water heater.", @@ -184,6 +183,9 @@ "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" + }, "operation_mode": { "description": "The operation modes to trigger on.", "name": "Operation mode" @@ -206,6 +208,9 @@ "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } @@ -217,6 +222,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" } }, "name": "Water heater turned off" @@ -226,6 +234,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" } }, "name": "Water heater turned on" diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 0a434b498b5..b64671d46f1 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( - ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityNumericalStateTriggerWithUnitBase, @@ -28,14 +28,16 @@ from .const import DOMAIN CONF_OPERATION_MODE = "operation_mode" -_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( - { - vol.Required(CONF_OPTIONS): { - vol.Required(CONF_OPERATION_MODE): vol.All( - cv.ensure_list, vol.Length(min=1), [str] - ), - }, - } +_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ( + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPERATION_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [str] + ), + }, + } + ) ) @@ -60,6 +62,13 @@ class _WaterHeaterTargetTemperatureTriggerMixin( _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip water heater entities that do not expose a target temperature.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_TEMPERATURE) is not None + ) + def _get_entity_unit(self, state: State) -> str | None: """Get the temperature unit of a water heater entity from its state.""" # Water heater entities convert temperatures to the system unit via show_temp @@ -83,7 +92,9 @@ class WaterHeaterTargetTemperatureCrossedThresholdTrigger( TRIGGERS: dict[str, type[Trigger]] = { "operation_mode_changed": WaterHeaterOperationModeChangedTrigger, "target_temperature_changed": WaterHeaterTargetTemperatureChangedTrigger, - "target_temperature_crossed_threshold": WaterHeaterTargetTemperatureCrossedThresholdTrigger, + "target_temperature_crossed_threshold": ( + WaterHeaterTargetTemperatureCrossedThresholdTrigger + ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_OFF), } diff --git a/homeassistant/components/water_heater/triggers.yaml b/homeassistant/components/water_heater/triggers.yaml index 581b7dbb58c..33c7a364599 100644 --- a/homeassistant/components/water_heater/triggers.yaml +++ b/homeassistant/components/water_heater/triggers.yaml @@ -5,14 +5,15 @@ fields: behavior: &trigger_behavior required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: &trigger_for + required: true + default: 00:00:00 + selector: + duration: .temperature_units: &temperature_units - "°C" @@ -30,6 +31,7 @@ operation_mode_changed: target: *trigger_water_heater_target fields: behavior: *trigger_behavior + for: *trigger_for operation_mode: context: filter_target: target @@ -62,6 +64,7 @@ target_temperature_crossed_threshold: target: *trigger_water_heater_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index bdb370084b4..499e438da68 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -1,7 +1,5 @@ """Support for WaterFurnace geothermal systems.""" -from __future__ import annotations - import asyncio import logging @@ -14,6 +12,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, INTEGRATION_TITLE @@ -25,7 +24,7 @@ from .coordinator import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -115,10 +114,13 @@ async def _async_setup_coordinator( energy_coordinator = WaterFurnaceEnergyCoordinator( hass, device_client, entry, device_client.gwid ) - # Use async_refresh() instead of async_config_entry_first_refresh() so that - # energy data failures (e.g. WFNoDataError for new accounts) don't block - # the integration from loading. Realtime sensor data is the primary concern. - await energy_coordinator.async_refresh() + + # Defer the first energy refresh until HA has fully started so the + # potentially large initial backfill doesn't compete with startup I/O. + async def _async_start_energy(hass: HomeAssistant) -> None: + await energy_coordinator.async_refresh() + + entry.async_on_unload(async_at_started(hass, _async_start_energy)) return device_client.gwid, WaterFurnaceDeviceData( realtime=coordinator, energy=energy_coordinator diff --git a/homeassistant/components/waterfurnace/climate.py b/homeassistant/components/waterfurnace/climate.py new file mode 100644 index 00000000000..9825a98e9d4 --- /dev/null +++ b/homeassistant/components/waterfurnace/climate.py @@ -0,0 +1,210 @@ +"""Support for WaterFurnace climate entity.""" + +from typing import Any + +from waterfurnace.waterfurnace import WFException + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WaterFurnaceConfigEntry +from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity + +PARALLEL_UPDATES = 0 + +# Maps ActiveSettings.mode string to HVACMode +ACTIVE_MODE_TO_HVAC: dict[str, HVACMode] = { + "Off": HVACMode.OFF, + "Auto": HVACMode.HEAT_COOL, + "Cool": HVACMode.COOL, + "Heat": HVACMode.HEAT, + "E-Heat": HVACMode.HEAT, +} + +# Maps HVACMode to library's integer mode +HVAC_TO_WF_MODE: dict[HVACMode, int] = { + HVACMode.OFF: 0, + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.HEAT: 3, +} + +# Maps WFReading.mode string to HVACAction +FURNACE_MODE_TO_ACTION: dict[str, HVACAction] = { + "Standby": HVACAction.IDLE, + "Fan Only": HVACAction.FAN, + "Cooling 1": HVACAction.COOLING, + "Cooling 2": HVACAction.COOLING, + "Reheat": HVACAction.HEATING, + "Heating 1": HVACAction.HEATING, + "Heating 2": HVACAction.HEATING, + "E-Heat": HVACAction.HEATING, + "Aux Heat": HVACAction.HEATING, + "Lockout": HVACAction.OFF, +} + +# Library temperature limits (Fahrenheit) +HEATING_MIN = 40 +HEATING_MAX = 80 +COOLING_MIN = 60 +COOLING_MAX = 90 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WaterFurnaceConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up WaterFurnace climate from a config entry.""" + async_add_entities( + WaterFurnaceClimate(device_data.realtime) + for device_data in config_entry.runtime_data.values() + ) + + +class WaterFurnaceClimate(WaterFurnaceEntity, ClimateEntity): + """Climate entity for WaterFurnace geothermal systems.""" + + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_humidity = 15 + _attr_max_humidity = 95 + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.unit + + @property + def min_temp(self) -> float: + """Return the minimum temperature based on current mode.""" + if self.hvac_mode == HVACMode.COOL: + return COOLING_MIN + return HEATING_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature based on current mode.""" + if self.hvac_mode == HVACMode.HEAT: + return HEATING_MAX + return COOLING_MAX + + @property + def current_temperature(self) -> float | None: + """Return the current room temperature.""" + return self.coordinator.data.tstatroomtemp + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self.coordinator.data.tstatrelativehumidity + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + return ACTIVE_MODE_TO_HVAC.get(self.coordinator.data.activesettings.mode) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return FURNACE_MODE_TO_ACTION.get(self.coordinator.data.mode) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature (single setpoint modes).""" + if self.hvac_mode == HVACMode.COOL: + return self.coordinator.data.tstatcoolingsetpoint + if self.hvac_mode == HVACMode.HEAT: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatcoolingsetpoint + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature (Heat/Cool mode).""" + if self.hvac_mode == HVACMode.HEAT_COOL: + return self.coordinator.data.tstatheatingsetpoint + return None + + @property + def target_humidity(self) -> float | None: + """Return the target humidity.""" + return self.coordinator.data.tstathumidsetpoint + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_mode, HVAC_TO_WF_MODE[hvac_mode] + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set HVAC mode: {err}") from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature(s).""" + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(hvac_mode) + + low = kwargs.get(ATTR_TARGET_TEMP_LOW) + high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + current_mode = hvac_mode if hvac_mode is not None else self.hvac_mode + try: + await self.hass.async_add_executor_job( + self._set_temperature, low, high, temp, current_mode + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set temperature: {err}") from err + + def _set_temperature( + self, + low: float | None, + high: float | None, + temp: float | None, + current_mode: HVACMode | None, + ) -> None: + """Send temperature setpoint(s) to the device.""" + client = self.coordinator.client + if low is not None and high is not None: + client.set_heating_setpoint(low) + client.set_cooling_setpoint(high) + elif temp is not None: + if current_mode == HVACMode.COOL: + client.set_cooling_setpoint(temp) + else: + client.set_heating_setpoint(temp) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.client.set_humidity, humidity + ) + except (WFException, ValueError) as err: + raise HomeAssistantError(f"Failed to set humidity: {err}") from err diff --git a/homeassistant/components/waterfurnace/config_flow.py b/homeassistant/components/waterfurnace/config_flow.py index f068558ff59..6021ae37fcf 100644 --- a/homeassistant/components/waterfurnace/config_flow.py +++ b/homeassistant/components/waterfurnace/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WaterFurnace integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index 483bc9e54c8..719d04657b3 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -1,15 +1,17 @@ """Data update coordinator for WaterFurnace.""" -from __future__ import annotations - +import asyncio from dataclasses import dataclass from datetime import datetime, timedelta import logging +import math +import random from typing import TYPE_CHECKING from waterfurnace.waterfurnace import ( WaterFurnace, WFCredentialError, + WFError, WFException, WFGateway, WFNoDataError, @@ -39,6 +41,13 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +BACKFILL_BATCH_DAYS = 5 +BACKFILL_LOOKBACK_DAYS = 395 # 13 Months +BACKFILL_GAP_THRESHOLD = timedelta(days=BACKFILL_BATCH_DAYS) +BACKFILL_DELAY_MIN_SECONDS = 5 +BACKFILL_DELAY_MAX_SECONDS = 30 +BACKFILL_MAX_EMPTY_DAYS = 15 + @dataclass class WaterFurnaceDeviceData: @@ -80,7 +89,7 @@ class WaterFurnaceCoordinator(DataUpdateCoordinator[WFReading]): (device for device in client.devices if device.gwid == self.unit), None ) - async def _async_update_data(self): + async def _async_update_data(self) -> WFReading: """Fetch data from WaterFurnace API with built-in retry logic.""" try: return await self.hass.async_add_executor_job(self.client.read_with_retry) @@ -115,6 +124,7 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): self.client = client self.gwid = gwid self.statistic_id = f"{DOMAIN}:{gwid.lower()}_energy" + self._backfill_task: asyncio.Task | None = None self._statistic_metadata = StatisticMetaData( has_sum=True, mean_type=StatisticMeanType.NONE, @@ -144,28 +154,47 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): if not last_stat: return None entry = last_stat[self.statistic_id][0] - if entry["sum"] is None: + if "sum" not in entry or "start" not in entry or entry["sum"] is None: return None + return (entry["start"], entry["sum"]) def _fetch_energy_data( self, start_date: str, end_date: str ) -> list[tuple[datetime, float]]: - """Fetch energy data and return list of (timestamp, kWh) tuples.""" - # Re-login to refresh the HTTP session token, which expires between - # the 2-hour polling intervals. + """Fetch energy data and return list of (timestamp, kWh) tuples. + + On auth failure, re-login once and retry the request. + """ try: - self.client.login() - except WFCredentialError as err: - raise UpdateFailed( - "Authentication failed during energy data fetch" - ) from err - data = self.client.get_energy_data( - start_date, - end_date, - frequency="1H", - timezone_str=self.hass.config.time_zone, - ) + data = self.client.get_energy_data( + start_date, + end_date, + frequency="1H", + timezone_str=self.hass.config.time_zone, + ) + except WFCredentialError, WFError: + try: + self.client.login() + except WFCredentialError as err: + raise UpdateFailed( + "Authentication failed during energy data fetch" + ) from err + try: + data = self.client.get_energy_data( + start_date, + end_date, + frequency="1H", + timezone_str=self.hass.config.time_zone, + ) + except WFCredentialError as err: + raise UpdateFailed( + "Authentication failed during energy data fetch" + ) from err + except WFError as err: + raise UpdateFailed( + "Error fetching energy data after re-authentication" + ) from err return [ (reading.timestamp, reading.total_power) for reading in data @@ -177,10 +206,14 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): readings: list[tuple[datetime, float]], last_ts: float, last_sum: float, - now: datetime, + current_hour_ts: float | None = None, ) -> list[StatisticData]: - """Build hourly statistics from readings, skipping already-recorded ones.""" - current_hour_ts = now.replace(minute=0, second=0, microsecond=0).timestamp() + """Build hourly statistics from readings, skipping already-recorded ones. + + When provided, current_hour_ts acts as an exclusive cutoff so readings at + or after that timestamp are excluded, such as to skip the incomplete + current hour during normal polling and backfill. + """ statistics: list[StatisticData] = [] seen_hours: set[float] = set() running_sum = last_sum @@ -188,7 +221,7 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): ts = timestamp.timestamp() if ts <= last_ts: continue - if ts >= current_hour_ts: + if current_hour_ts is not None and ts >= current_hour_ts: continue hour_ts = timestamp.replace(minute=0, second=0, microsecond=0).timestamp() if hour_ts in seen_hours: @@ -204,23 +237,141 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): ) return statistics + async def _async_backfill( + self, + start_dt: datetime, + end_dt: datetime, + initial_sum: float = 0.0, + last_ts: float = -math.inf, + ) -> None: + """Backfill energy statistics by walking backwards in batches. + + Collects all readings into memory, then inserts them chronologically + in a single pass. Stops early if no data is found for + BACKFILL_MAX_EMPTY_DAYS consecutive days. + """ + all_readings: list[tuple[datetime, float]] = [] + batch_end = end_dt + local_tz = dt_util.DEFAULT_TIME_ZONE + consecutive_empty_days = 0 + + while batch_end > start_dt: + batch_start = max(batch_end - timedelta(days=BACKFILL_BATCH_DAYS), start_dt) + start_str = batch_start.astimezone(local_tz).strftime("%Y-%m-%d") + end_str = batch_end.astimezone(local_tz).strftime("%Y-%m-%d") + + try: + parsed = await self.hass.async_add_executor_job( + self._fetch_energy_data, start_str, end_str + ) + except WFNoDataError: + _LOGGER.debug( + "No energy data for %s to %s, skipping", start_str, end_str + ) + consecutive_empty_days += BACKFILL_BATCH_DAYS + if consecutive_empty_days >= BACKFILL_MAX_EMPTY_DAYS: + _LOGGER.debug( + "No data for %d consecutive days, stopping backfill", + consecutive_empty_days, + ) + break + batch_end = batch_start + continue + except UpdateFailed, WFException: + _LOGGER.exception("Error fetching energy data during backfill") + break + + _LOGGER.debug( + "Fetched %d readings for backfill batch %s to %s", + len(parsed), + start_str, + end_str, + ) + + all_readings.extend(parsed) + consecutive_empty_days = 0 + + batch_end = batch_start + if batch_end > start_dt: + await asyncio.sleep( + random.uniform( + BACKFILL_DELAY_MIN_SECONDS, BACKFILL_DELAY_MAX_SECONDS + ) + ) + + if all_readings: + # Exclude the incomplete current hour. Use local timezone so + # the hour boundary is correct for partial-offset timezones + # (e.g. UTC+5:30). + current_hour_ts = ( + end_dt.astimezone(local_tz) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + statistics = self._build_statistics( + all_readings, last_ts, initial_sum, current_hour_ts + ) + if statistics: + async_add_external_statistics( + self.hass, self._statistic_metadata, statistics + ) + + def _backfill_done_callback(self, task: asyncio.Task[None]) -> None: + """Log any exception from a completed backfill task.""" + if task.cancelled(): + return + if exc := task.exception(): + _LOGGER.error("Backfill task failed", exc_info=exc) + + async def async_wait_backfill(self) -> None: + """Wait for any in-progress backfill task to complete.""" + if self._backfill_task: + await self._backfill_task + async def _async_update_data(self) -> None: - """Fetch energy data and insert statistics.""" + """Fetch energy data and insert statistics. + + Handles three scenarios: + 1. No statistics exist → first-load backfill (background task) + 2. Last stat is older than gap threshold → gap backfill (background task) + 3. Last stat is recent → normal poll for recent data + """ + if self._backfill_task and not self._backfill_task.done(): + _LOGGER.debug("Backfill already in progress, skipping update") + return + last = await self._async_get_last_stat() now = dt_util.utcnow() if last is None: - _LOGGER.info("No prior statistics found, fetching recent energy data") - last_ts = 0.0 - last_sum = 0.0 - start_dt = now - timedelta(days=1) - else: - last_ts, last_sum = last - start_dt = dt_util.utc_from_timestamp(last_ts) - _LOGGER.debug("Last stat: ts=%s, sum=%s", start_dt.isoformat(), last_sum) + # First load: backfill walking backwards from today + start = now - timedelta(days=BACKFILL_LOOKBACK_DAYS) + self._backfill_task = self.config_entry.async_create_background_task( + self.hass, + self._async_backfill(start, now), + f"waterfurnace_backfill_{self.gwid}", + ) + self._backfill_task.add_done_callback(self._backfill_done_callback) + return + last_ts, last_sum = last + last_dt = dt_util.utc_from_timestamp(last_ts) + + if now - last_dt > BACKFILL_GAP_THRESHOLD: + # Large gap detected, backfill using batches + self._backfill_task = self.config_entry.async_create_background_task( + self.hass, + self._async_backfill(last_dt, now, last_sum, last_ts), + f"waterfurnace_backfill_{self.gwid}", + ) + self._backfill_task.add_done_callback(self._backfill_done_callback) + return + + # Normal poll: fetch recent data (up to + # BACKFILL_GAP_THRESHOLD) and insert missing hours + _LOGGER.debug("Last stat: ts=%s, sum=%s", last_dt.isoformat(), last_sum) local_tz = dt_util.DEFAULT_TIME_ZONE - start_date = start_dt.astimezone(local_tz).strftime("%Y-%m-%d") + start_date = last_dt.astimezone(local_tz).strftime("%Y-%m-%d") end_date = (now.astimezone(local_tz) + timedelta(days=1)).strftime("%Y-%m-%d") try: @@ -239,7 +390,16 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug("Fetched %s readings", len(readings)) - statistics = self._build_statistics(readings, last_ts, last_sum, now) + # Use local timezone so the hour boundary is correct for + # partial-offset timezones (e.g. UTC+5:30). + current_hour_ts = ( + now.astimezone(local_tz) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + statistics = self._build_statistics( + readings, last_ts, last_sum, current_hour_ts + ) _LOGGER.debug("Built %s statistics to insert", len(statistics)) diff --git a/homeassistant/components/waterfurnace/entity.py b/homeassistant/components/waterfurnace/entity.py new file mode 100644 index 00000000000..b542cf0d200 --- /dev/null +++ b/homeassistant/components/waterfurnace/entity.py @@ -0,0 +1,31 @@ +"""Base entity for WaterFurnace.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WaterFurnaceCoordinator + + +class WaterFurnaceEntity(CoordinatorEntity[WaterFurnaceCoordinator]): + """Base entity for WaterFurnace.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: WaterFurnaceCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unit)}, + manufacturer="WaterFurnace", + name="WaterFurnace System", + ) + + if coordinator.device_metadata: + if coordinator.device_metadata.description: + device_info["model"] = coordinator.device_metadata.description + if coordinator.device_metadata.awlabctypedesc: + device_info["name"] = coordinator.device_metadata.awlabctypedesc + + self._attr_device_info = device_info diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 31934f71ae5..f94c80c56f6 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "bronze", - "requirements": ["waterfurnace==1.6.2"] + "requirements": ["waterfurnace==1.7.1"] } diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 9634baabb51..585ac07a6dc 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -1,7 +1,5 @@ """Support for Waterfurnace.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -15,12 +13,11 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, WaterFurnaceConfigEntry +from . import WaterFurnaceConfigEntry from .coordinator import WaterFurnaceCoordinator +from .entity import WaterFurnaceEntity SENSORS = [ SensorEntityDescription( @@ -162,12 +159,11 @@ async def async_setup_entry( ) -class WaterFurnaceSensor(CoordinatorEntity[WaterFurnaceCoordinator], SensorEntity): +class WaterFurnaceSensor(WaterFurnaceEntity, SensorEntity): """Implementing the Waterfurnace sensor.""" entity_description: SensorEntityDescription _attr_should_poll = False - _attr_has_entity_name = True def __init__( self, coordinator: WaterFurnaceCoordinator, description: SensorEntityDescription @@ -175,25 +171,8 @@ class WaterFurnaceSensor(CoordinatorEntity[WaterFurnaceCoordinator], SensorEntit """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unit}_{description.key}" - device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unit)}, - manufacturer="WaterFurnace", - name="WaterFurnace System", - ) - - if coordinator.device_metadata: - if coordinator.device_metadata.description: - # Eg. Series 7 - device_info["model"] = coordinator.device_metadata.description - if coordinator.device_metadata.awlabctypedesc: - # Eg. Series 7, 5 Ton - device_info["name"] = coordinator.device_metadata.awlabctypedesc - - self._attr_device_info = device_info - @property def native_value(self): """Return the native value of the sensor.""" diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index 4bc20eb3ff1..ef5e030a30b 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -1,7 +1,5 @@ """The Watergate integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from http import HTTPStatus import logging diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml index f2d058f1062..99a8415c622 100644 --- a/homeassistant/components/watergate/quality_scale.yaml +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -63,7 +63,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: done + exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 0d4f08741e0..3dbb42371de 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -1,7 +1,5 @@ """The Watts Vision + integration.""" -from __future__ import annotations - from dataclasses import dataclass from http import HTTPStatus import logging @@ -14,8 +12,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SUPPORTED_DEVICE_TYPES from .coordinator import ( @@ -23,11 +26,20 @@ from .coordinator import ( WattsVisionDeviceData, WattsVisionHubCoordinator, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Watts Vision component.""" + async_setup_services(hass) + return True + @dataclass class WattsVisionRuntimeData: diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index d30e21b5275..e56d4c4bebe 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -1,25 +1,34 @@ """Climate platform for Watts Vision integration.""" -from __future__ import annotations - +from datetime import timedelta import logging from typing import Any -from visionpluspython.models import ThermostatDevice +from visionpluspython.exceptions import WattsVisionError +from visionpluspython.models import ThermostatDevice, ThermostatMode from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WattsVisionConfigEntry -from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC +from .const import ( + DOMAIN, + HVAC_ACTION_TO_HA, + HVAC_MODE_TO_THERMOSTAT, + PRESET_MODE_TO_THERMOSTAT, + PRESET_MODES, + THERMOSTAT_MODE_TO_HVAC, + THERMOSTAT_MODE_TO_PRESET, +) from .coordinator import WattsVisionDeviceCoordinator from .entity import WattsVisionEntity @@ -28,6 +37,10 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +def _parse_thermostat_mode(mode: str) -> ThermostatMode: + return ThermostatMode[mode.upper()] + + async def async_setup_entry( hass: HomeAssistant, entry: WattsVisionConfigEntry, @@ -81,9 +94,13 @@ async def async_setup_entry( class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity): """Representation of a Watts Vision heater as a climate entity.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_preset_modes = PRESET_MODES _attr_name = None + _attr_translation_key = "thermostat" def __init__( self, @@ -114,7 +131,44 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return hvac mode.""" - return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode) + return THERMOSTAT_MODE_TO_HVAC.get( + _parse_thermostat_mode(self.device.thermostat_mode) + ) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return HVAC_ACTION_TO_HA.get(self.device.hvac_action) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return THERMOSTAT_MODE_TO_PRESET.get( + _parse_thermostat_mode(self.device.thermostat_mode) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + mode = PRESET_MODE_TO_THERMOSTAT[preset_mode] + + try: + await self.coordinator.client.set_thermostat_mode(self.device_id, mode) + except (ValueError, RuntimeError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_preset_mode_error", + ) from err + + _LOGGER.debug( + "Successfully set preset mode to %s (ThermostatMode.%s) for %s", + preset_mode, + mode.name, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -142,6 +196,47 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity): await self.coordinator.async_refresh() + async def async_activate_timer_mode( + self, temperature: float, duration: timedelta + ) -> None: + """Activate timer mode with a target temperature and duration.""" + if not self._attr_min_temp <= temperature <= self._attr_max_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="timer_temperature_out_of_range", + translation_placeholders={ + "temperature": str(temperature), + "min_temp": str(self._attr_min_temp), + "max_temp": str(self._attr_max_temp), + }, + ) + + duration_minutes, remainder = divmod(duration, timedelta(minutes=1)) + if remainder: + duration_minutes += 1 + + try: + await self.coordinator.client.activate_thermostat_timer( + self.device_id, temperature, duration_minutes + ) + except (WattsVisionError, ValueError, RuntimeError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="activate_timer_mode_error", + ) from err + + _LOGGER.debug( + "Successfully activated timer mode: %s%s for %d min on %s", + temperature, + self.temperature_unit, + duration_minutes, + self.device_id, + ) + + self.coordinator.trigger_fast_polling() + + await self.coordinator.async_refresh() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode] diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index 508f24bde0b..e1ba4a0134e 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -2,7 +2,12 @@ from visionpluspython.models import SwitchDevice, ThermostatDevice, ThermostatMode -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import ( + PRESET_COMFORT, + PRESET_ECO, + HVACAction, + HVACMode, +) DOMAIN = "watts" @@ -20,20 +25,49 @@ UPDATE_INTERVAL_SECONDS = 30 FAST_POLLING_INTERVAL_SECONDS = 5 DISCOVERY_INTERVAL_MINUTES = 15 -# Mapping from Watts Vision + modes to Home Assistant HVAC modes - -THERMOSTAT_MODE_TO_HVAC = { - "Program": HVACMode.AUTO, - "Eco": HVACMode.HEAT, - "Comfort": HVACMode.HEAT, - "Off": HVACMode.OFF, +# Mapping from Watts Vision+ modes to Home Assistant HVAC modes +THERMOSTAT_MODE_TO_HVAC: dict[ThermostatMode, HVACMode] = { + ThermostatMode.PROGRAM: HVACMode.AUTO, + ThermostatMode.ECO: HVACMode.HEAT, + ThermostatMode.COMFORT: HVACMode.HEAT, + ThermostatMode.DEFROST: HVACMode.HEAT, + ThermostatMode.TIMER: HVACMode.HEAT, + ThermostatMode.OFF: HVACMode.OFF, } -# Mapping from Home Assistant HVAC modes to Watts Vision + modes -HVAC_MODE_TO_THERMOSTAT = { +# Mapping from Home Assistant HVAC modes to Watts Vision+ modes +HVAC_MODE_TO_THERMOSTAT: dict[HVACMode, ThermostatMode] = { HVACMode.HEAT: ThermostatMode.COMFORT, HVACMode.OFF: ThermostatMode.OFF, HVACMode.AUTO: ThermostatMode.PROGRAM, } +# Preset modes available on all Watts Vision+ thermostats. +PRESET_MODES: list[str] = [PRESET_COMFORT, PRESET_ECO, "defrost", "timer"] + +# Mapping from Watts Vision+ mode to HA preset mode string +THERMOSTAT_MODE_TO_PRESET: dict[ThermostatMode, str] = { + ThermostatMode.COMFORT: PRESET_COMFORT, + ThermostatMode.ECO: PRESET_ECO, + ThermostatMode.DEFROST: "defrost", + ThermostatMode.TIMER: "timer", +} + +# Mapping from HA preset mode string to Watts Vision+ ThermostatMode +PRESET_MODE_TO_THERMOSTAT: dict[str, ThermostatMode] = { + v: k for k, v in THERMOSTAT_MODE_TO_PRESET.items() +} + +# Mapping from Watts Vision+ HVAC actions to Home Assistant HVACAction +HVAC_ACTION_TO_HA: dict[str, HVACAction] = { + "Heating": HVACAction.HEATING, + "Cooling": HVACAction.COOLING, + "Idle": HVACAction.IDLE, + "Off": HVACAction.OFF, +} + SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice) + +# Timer service +SERVICE_ACTIVATE_TIMER_MODE = "activate_timer_mode" +ATTR_DURATION = "duration" diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c24853eb52c..c874f827a39 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -1,7 +1,5 @@ """Data coordinator for Watts Vision integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta import logging @@ -192,10 +190,17 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData]) ) def _handle_hub_update(self) -> None: - """Handle updates from hub coordinator.""" + """Handle updates from hub coordinator. + + Update data and notify listeners without rescheduling the refresh + interval, so an in-flight fast-polling cycle is not interrupted. + """ if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data: - device = self.hub_coordinator.data[self.device_id] - self.async_set_updated_data(WattsVisionDeviceData(device=device)) + self.data = WattsVisionDeviceData( + device=self.hub_coordinator.data[self.device_id] + ) + self.last_update_success = True + self.async_update_listeners() async def _async_update_data(self) -> WattsVisionDeviceData: """Refresh specific device.""" @@ -208,7 +213,7 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData]) ) try: - device = await self.client.get_device(self.device_id, refresh=True) + device = await self.client.get_device(self.device_id) except ( WattsVisionAuthError, WattsVisionConnectionError, diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py index 33912dc71a8..2c1f03a3cdd 100644 --- a/homeassistant/components/watts/diagnostics.py +++ b/homeassistant/components/watts/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Watts Vision +.""" -from __future__ import annotations - import dataclasses from datetime import datetime from typing import Any diff --git a/homeassistant/components/watts/entity.py b/homeassistant/components/watts/entity.py index f36320f281c..ea0bfc96aee 100644 --- a/homeassistant/components/watts/entity.py +++ b/homeassistant/components/watts/entity.py @@ -1,7 +1,5 @@ """Base entity for Watts Vision integration.""" -from __future__ import annotations - from typing import cast from visionpluspython.models import Device diff --git a/homeassistant/components/watts/icons.json b/homeassistant/components/watts/icons.json new file mode 100644 index 00000000000..58eb4bd0445 --- /dev/null +++ b/homeassistant/components/watts/icons.json @@ -0,0 +1,23 @@ +{ + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "comfort": "mdi:weather-sunny", + "defrost": "mdi:snowflake", + "eco": "mdi:moon-waning-crescent", + "timer": "mdi:timer" + } + } + } + } + } + }, + "services": { + "activate_timer_mode": { + "service": "mdi:timer" + } + } +} diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 65d4a1323d9..45a0c83dec4 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["visionpluspython==1.0.2"] + "requirements": ["visionpluspython==1.1.0"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index c42cee4a798..246931c4927 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -53,13 +53,9 @@ rules: entity-category: done entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: exempt - comment: No entity required translations. + entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: Thermostat entities use standard HA Climate entity. + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt diff --git a/homeassistant/components/watts/services.py b/homeassistant/components/watts/services.py new file mode 100644 index 00000000000..e7659c6e47a --- /dev/null +++ b/homeassistant/components/watts/services.py @@ -0,0 +1,31 @@ +"""Services for Watts Vision integration.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ATTR_DURATION, DOMAIN, SERVICE_ACTIVATE_TIMER_MODE + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the Watts Vision integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ACTIVATE_TIMER_MODE, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), + vol.Required(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=1), max=timedelta(days=1)), + ), + }, + func="async_activate_timer_mode", + ) diff --git a/homeassistant/components/watts/services.yaml b/homeassistant/components/watts/services.yaml new file mode 100644 index 00000000000..f252528a94f --- /dev/null +++ b/homeassistant/components/watts/services.yaml @@ -0,0 +1,18 @@ +activate_timer_mode: + target: + entity: + domain: climate + integration: watts + fields: + temperature: + required: true + selector: + number: + step: 0.5 + unit_of_measurement: "°" + mode: box + duration: + required: true + selector: + duration: + enable_second: false diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index 9f1c761d8f7..5ae5f6eb988 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -30,7 +30,24 @@ } } }, + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "defrost": "Defrost", + "timer": "Timer" + } + } + } + } + } + }, "exceptions": { + "activate_timer_mode_error": { + "message": "An error occurred while activating timer mode" + }, "authentication_failed": { "message": "Authentication failed" }, @@ -58,6 +75,9 @@ "set_hvac_mode_error": { "message": "An error occurred while setting the HVAC mode" }, + "set_preset_mode_error": { + "message": "An error occurred while setting the preset mode" + }, "set_switch_state_error": { "message": "An error occurred while setting the switch state" }, @@ -66,6 +86,25 @@ }, "temporary_connection_error": { "message": "Temporary connection error" + }, + "timer_temperature_out_of_range": { + "message": "Timer temperature {temperature} is out of range ({min_temp}-{max_temp})" + } + }, + "services": { + "activate_timer_mode": { + "description": "Activates timer mode on the thermostat for a specified temperature and duration.", + "fields": { + "duration": { + "description": "Duration of the timer.", + "name": "Duration" + }, + "temperature": { + "description": "Target temperature while the timer is active.", + "name": "Temperature" + } + }, + "name": "Activate timer mode" } } } diff --git a/homeassistant/components/watts/switch.py b/homeassistant/components/watts/switch.py index 4b3a2526478..e14533bd473 100644 --- a/homeassistant/components/watts/switch.py +++ b/homeassistant/components/watts/switch.py @@ -1,7 +1,5 @@ """Switch platform for Watts Vision integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index 6e67994b11a..bdd8263bba1 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -1,23 +1,20 @@ """The WattTime integration.""" -from __future__ import annotations - from aiowatttime import Client from aiowatttime.errors import InvalidCredentialsError, WattTimeError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, LOGGER -from .coordinator import WattTimeCoordinator +from .const import LOGGER +from .coordinator import WattTimeConfigEntry, WattTimeCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WattTimeConfigEntry) -> bool: """Set up WattTime from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) @@ -34,8 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = WattTimeCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -44,15 +40,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WattTimeConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_reload_entry( + hass: HomeAssistant, config_entry: WattTimeConfigEntry +) -> None: """Handle an options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index ad676e166c5..abf37e6cdaa 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WattTime integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import TYPE_CHECKING, Any @@ -9,12 +7,7 @@ from aiowatttime import Client from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -31,6 +24,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import WattTimeConfigEntry CONF_LOCATION_TYPE = "location_type" @@ -127,7 +121,7 @@ class WattTimeConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: WattTimeConfigEntry, ) -> WattTimeOptionsFlowHandler: """Define the config flow to handle options.""" return WattTimeOptionsFlowHandler() diff --git a/homeassistant/components/watttime/coordinator.py b/homeassistant/components/watttime/coordinator.py index a726555db53..8d21c72b557 100644 --- a/homeassistant/components/watttime/coordinator.py +++ b/homeassistant/components/watttime/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the WattTime integration.""" -from __future__ import annotations - from datetime import timedelta from aiowatttime import Client @@ -18,16 +16,18 @@ from .const import DOMAIN, LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) +type WattTimeConfigEntry = ConfigEntry[WattTimeCoordinator] + class WattTimeCoordinator(DataUpdateCoordinator[RealTimeEmissionsResponseType]): """Coordinator for WattTime data updates.""" - config_entry: ConfigEntry + config_entry: WattTimeConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: WattTimeConfigEntry, client: Client, ) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/watttime/diagnostics.py b/homeassistant/components/watttime/diagnostics.py index b779b2759d1..132f4595ef1 100644 --- a/homeassistant/components/watttime/diagnostics.py +++ b/homeassistant/components/watttime/diagnostics.py @@ -1,11 +1,8 @@ """Diagnostics support for WattTime.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -15,8 +12,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN -from .coordinator import WattTimeCoordinator +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV +from .coordinator import WattTimeConfigEntry CONF_TITLE = "title" @@ -34,15 +31,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WattTimeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: WattTimeCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data, + "data": entry.runtime_data.data, }, TO_REDACT, ) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 23824a1369a..351ef600dca 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -1,7 +1,5 @@ """Support for WattTime sensors.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast @@ -10,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -25,7 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN -from .coordinator import WattTimeCoordinator +from .coordinator import WattTimeConfigEntry, WattTimeCoordinator ATTR_BALANCING_AUTHORITY = "balancing_authority" @@ -51,11 +48,11 @@ REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WattTimeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WattTime sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ RealtimeEmissionsSensor(coordinator, entry, description) @@ -73,7 +70,7 @@ class RealtimeEmissionsSensor(CoordinatorEntity[WattTimeCoordinator], SensorEnti def __init__( self, coordinator: WattTimeCoordinator, - entry: ConfigEntry, + entry: WattTimeConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 4dd901e8bdc..f49a33393b2 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers.selector import ( BooleanSelector, DurationSelector, DurationSelectorConfig, + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -33,6 +34,7 @@ from .const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -47,11 +49,12 @@ from .const import ( DOMAIN, METRIC_UNITS, REGIONS, - SEMAPHORE, + SEMAPHORE_KEY, UNITS, VEHICLE_TYPES, ) from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times +from .helpers import base_coordinates_to_tuple, default_base_coordinates_for_region PLATFORMS = [Platform.SENSOR] @@ -103,6 +106,7 @@ SERVICE_GET_TRAVEL_TIMES_SCHEMA = vol.Schema( vol.Optional(CONF_TIME_DELTA): DurationSelector( DurationSelectorConfig(allow_negative=True, enable_second=False) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector(), } ) @@ -111,8 +115,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" - if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): - hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) + if SEMAPHORE_KEY not in hass.data: + hass.data[SEMAPHORE_KEY] = asyncio.Semaphore(1) httpx_client = get_async_client(hass) client = WazeRouteCalculator( @@ -137,6 +141,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b origin = origin_coordinates or service.data[CONF_ORIGIN] destination = destination_coordinates or service.data[CONF_DESTINATION] + base_coordinates = base_coordinates_to_tuple( + service.data.get(CONF_BASE_COORDINATES) + ) time_delta = int( timedelta( @@ -158,9 +165,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), time_delta=time_delta, + base_coordinates=base_coordinates, ) return {"routes": [vars(route) for route in response]} + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, SERVICE_GET_TRAVEL_TIMES, @@ -218,4 +227,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) + if config_entry.version == 2 and config_entry.minor_version == 2: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + options.setdefault( + CONF_BASE_COORDINATES, + default_base_coordinates_for_region(config_entry.data[CONF_REGION]), + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=3 + ) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 1b97bed0a88..dfb6b3e1f42 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Waze Travel Time integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol @@ -13,12 +11,14 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_NAME, CONF_REGION +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, DurationSelector, DurationSelectorConfig, + LocationSelector, + LocationSelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -32,6 +32,7 @@ from .const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -92,11 +93,16 @@ OPTIONS_SCHEMA = vol.Schema( enable_second=False, ) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector( + LocationSelectorConfig(radius=False) + ), } ) CONFIG_SCHEMA = vol.Schema( { + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_ORIGIN): TextSelector(), vol.Required(CONF_DESTINATION): TextSelector(), @@ -114,18 +120,24 @@ CONFIG_SCHEMA = vol.Schema( def default_options( hass: HomeAssistant, -) -> dict[str, str | bool | list[str] | dict[str, int]]: +) -> dict[str, str | bool | list[str] | dict[str, int] | dict[str, float]]: """Get the default options.""" defaults = DEFAULT_OPTIONS.copy() if hass.config.units is US_CUSTOMARY_SYSTEM: defaults[CONF_UNITS] = IMPERIAL_UNITS + defaults[CONF_BASE_COORDINATES] = { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } return defaults class WazeOptionsFlow(OptionsFlow): """Handle an options flow for Waze Travel Time.""" - async def async_step_init(self, user_input=None) -> ConfigFlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: if user_input.get(CONF_INCL_FILTER) is None: @@ -151,7 +163,7 @@ class WazeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Waze Travel Time.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 894c8a6c0a8..42ec0182d3a 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -1,10 +1,13 @@ """Constants for waze_travel_time.""" -from __future__ import annotations +import asyncio + +from homeassistant.util.hass_dict import HassKey DOMAIN = "waze_travel_time" -SEMAPHORE = "semaphore" +SEMAPHORE_KEY: HassKey[asyncio.Semaphore] = HassKey(DOMAIN) +CONF_BASE_COORDINATES = "base_coordinates" CONF_DESTINATION = "destination" CONF_ORIGIN = "origin" CONF_INCL_FILTER = "incl_filter" @@ -33,7 +36,9 @@ UNITS = [METRIC_UNITS, IMPERIAL_UNITS] REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] -DEFAULT_OPTIONS: dict[str, str | bool | list[str] | dict[str, int]] = { +DEFAULT_OPTIONS: dict[ + str, str | bool | list[str] | dict[str, int] | dict[str, float] +] = { CONF_REALTIME: DEFAULT_REALTIME, CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, CONF_UNITS: METRIC_UNITS, diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py index 0cf4f4ef783..d48fbd17d41 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta import logging from typing import Literal +import httpx from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError from homeassistant.config_entries import ConfigEntry @@ -20,6 +21,7 @@ from .const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -30,8 +32,9 @@ from .const import ( CONF_VEHICLE_TYPE, DOMAIN, IMPERIAL_UNITS, - SEMAPHORE, + SEMAPHORE_KEY, ) +from .helpers import base_coordinates_to_tuple _LOGGER = logging.getLogger(__name__) @@ -53,6 +56,7 @@ async def async_get_travel_times( incl_filters: Collection[str] | None = None, excl_filters: Collection[str] | None = None, time_delta: int = 0, + base_coordinates: tuple[float, float] | None = None, ) -> list[CalcRoutesResponse]: """Get all available routes.""" @@ -77,6 +81,7 @@ async def async_get_travel_times( real_time=realtime, alternatives=3, time_delta=time_delta, + base_coords=base_coordinates, ) if len(routes) < 1: @@ -96,7 +101,8 @@ async def async_get_travel_times( ) if not should_include: _LOGGER.debug( - "Excluding route [%s], because no inclusive filter matched any streetname", + "Excluding route [%s], because no" + " inclusive filter matched any streetname", route.name, ) return False @@ -111,7 +117,9 @@ async def async_get_travel_times( for excl_filter in excl_filters: if excl_filter == street_name: _LOGGER.debug( - "Excluding route, because exclusive filter [%s] matched streetname: %s", + "Excluding route, because" + " exclusive filter [%s]" + " matched streetname: %s", excl_filter, route.name, ) @@ -142,6 +150,8 @@ async def async_get_travel_times( except WRCError as exp: raise UpdateFailed(f"Error on retrieving data: {exp}") from exp + except httpx.RequestError as exp: + raise UpdateFailed(f"Connection error: {exp}") from exp else: return filtered_routes @@ -192,7 +202,7 @@ class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): self._origin, self._destination, ) - await self.hass.data[DOMAIN][SEMAPHORE].acquire() + await self.hass.data[SEMAPHORE_KEY].acquire() try: if origin_coordinates is None or destination_coordinates is None: raise UpdateFailed("Unable to determine origin or destination") @@ -211,6 +221,9 @@ class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): timedelta(**self.config_entry.options[CONF_TIME_DELTA]).total_seconds() / 60 ) + base_coordinates = base_coordinates_to_tuple( + self.config_entry.options.get(CONF_BASE_COORDINATES) + ) routes = await async_get_travel_times( self.client, @@ -225,6 +238,7 @@ class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): incl_filter, excl_filter, time_delta, + base_coordinates, ) if len(routes) < 1: travel_data = WazeTravelTimeData( @@ -249,6 +263,6 @@ class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) finally: - self.hass.data[DOMAIN][SEMAPHORE].release() + self.hass.data[SEMAPHORE_KEY].release() return travel_data diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index c6fe4d0c9bd..7bee77e8e4f 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -4,6 +4,7 @@ import logging from pywaze.route_calculator import WazeRouteCalculator, WRCError +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates @@ -11,6 +12,25 @@ from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) +def base_coordinates_to_tuple( + base_coordinates: dict[str, float] | None, +) -> tuple[float, float] | None: + """Convert Home Assistant location data to Waze base coordinates.""" + if base_coordinates is None: + return None + + return (base_coordinates[CONF_LATITUDE], base_coordinates[CONF_LONGITUDE]) + + +def default_base_coordinates_for_region(region: str) -> dict[str, float]: + """Return pywaze's default base coordinates for a region.""" + base_coordinates = WazeRouteCalculator.BASE_COORDS[region.upper()] + return { + CONF_LATITUDE: base_coordinates["lat"], + CONF_LONGITUDE: base_coordinates["lon"], + } + + async def is_valid_config_entry( hass: HomeAssistant, origin: str, destination: str, region: str ) -> bool: diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index c1323ce9397..0bbd7b46981 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,7 +1,5 @@ """Support for Waze travel time sensor.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import ( diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml index 6d1faf29045..857728ac0a1 100644 --- a/homeassistant/components/waze_travel_time/services.yaml +++ b/homeassistant/components/waze_travel_time/services.yaml @@ -69,3 +69,9 @@ get_travel_times: required: false selector: duration: + base_coordinates: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 55bb7cf995b..221b0af5ccf 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -26,6 +26,7 @@ "avoid_ferries": "Avoid ferries?", "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?", "avoid_toll_roads": "Avoid toll roads?", + "base_coordinates": "Base coordinates", "excl_filter": "Exact street name which must NOT be part of the selected route", "incl_filter": "Exact street name which must be part of the selected route", "realtime": "Realtime travel time?", @@ -33,6 +34,9 @@ "units": "Units", "vehicle_type": "Vehicle type" }, + "data_description": { + "base_coordinates": "When Waze finds multiple matching locations for an address, it selects the one closest to these coordinates." + }, "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation." } } @@ -77,6 +81,10 @@ "description": "Whether to avoid toll roads.", "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_toll_roads%]" }, + "base_coordinates": { + "description": "[%key:component::waze_travel_time::options::step::init::data_description::base_coordinates%]", + "name": "[%key:component::waze_travel_time::options::step::init::data::base_coordinates%]" + }, "destination": { "description": "The destination of the route.", "name": "[%key:component::waze_travel_time::config::step::user::data::destination%]" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index df98636d12d..f5384439c92 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,7 +1,5 @@ """Weather component that handles meteorological data for your location.""" -from __future__ import annotations - import abc from collections.abc import Callable, Iterable from contextlib import suppress @@ -988,7 +986,8 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A for fc_twice_daily in native_forecast_list: if fc_twice_daily.get(ATTR_FORECAST_IS_DAYTIME) is None: raise ValueError( - "is_daytime mandatory attribute for forecast_twice_daily is missing" + "is_daytime mandatory attribute" + " for forecast_twice_daily is missing" ) converted_forecast_list = self._convert_forecast(native_forecast_list) @@ -1223,7 +1222,9 @@ class SingleCoordinatorWeatherEntity( def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" super()._handle_coordinator_update() - assert self.coordinator.config_entry - self.coordinator.config_entry.async_create_task( - self.hass, self.async_update_listeners(None) + if entry := self.coordinator.config_entry: + entry.async_create_task(self.hass, self.async_update_listeners(None)) + return + self.hass.async_create_task( + self.async_update_listeners(None), f"{self.coordinator.name}" ) diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index d5d47d27ead..f4dd5b10929 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -1,7 +1,5 @@ """Constants for weather.""" -from __future__ import annotations - from collections.abc import Callable from enum import IntFlag from typing import TYPE_CHECKING, Final diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py index 078108d7afe..226d4bfae03 100644 --- a/homeassistant/components/weather/intent.py +++ b/homeassistant/components/weather/intent.py @@ -1,7 +1,5 @@ """Intents for the weather integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.core import HomeAssistant, State diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index ce7bcd15ede..50da77ef647 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Weather state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index a96c4fa9973..68945a7e594 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -1,7 +1,5 @@ """The weather websocket API.""" -from __future__ import annotations - from typing import Any, Literal import voluptuous as vol diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py index 3e30d15aebe..c4c52d2f679 100644 --- a/homeassistant/components/weatherflow/__init__.py +++ b/homeassistant/components/weatherflow/__init__.py @@ -1,7 +1,5 @@ """Get data from Smart Weather station via UDP.""" -from __future__ import annotations - from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice from pyweatherflowudp.errors import ListenerError @@ -21,8 +19,10 @@ PLATFORMS = [ Platform.SENSOR, ] +type WeatherFlowConfigEntry = ConfigEntry[WeatherFlowListener] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WeatherFlowConfigEntry) -> bool: """Set up WeatherFlow from a config entry.""" client = WeatherFlowListener() @@ -56,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ListenerError as ex: raise ConfigEntryNotReady from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_handle_ha_shutdown(event: Event) -> None: @@ -70,21 +70,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: WeatherFlowConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - client: WeatherFlowListener = hass.data[DOMAIN].pop(entry.entry_id, None) - if client: - await client.stop_listening() + await entry.runtime_data.stop_listening() return unload_ok async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, + config_entry: WeatherFlowConfigEntry, + device_entry: DeviceEntry, ) -> bool: """Remove a config entry from a device.""" - client: WeatherFlowListener = hass.data[DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data return not any( identifier for identifier in device_entry.identifiers diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py index 52290f50d9c..4793b5f8333 100644 --- a/homeassistant/components/weatherflow/config_flow.py +++ b/homeassistant/components/weatherflow/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WeatherFlow.""" -from __future__ import annotations - import asyncio from asyncio import Future from asyncio.exceptions import CancelledError diff --git a/homeassistant/components/weatherflow/event.py b/homeassistant/components/weatherflow/event.py index 05f7ecc2865..310c216e847 100644 --- a/homeassistant/components/weatherflow/event.py +++ b/homeassistant/components/weatherflow/event.py @@ -1,18 +1,16 @@ """Event entities for the WeatherFlow integration.""" -from __future__ import annotations - from dataclasses import dataclass from pyweatherflowudp.device import EVENT_RAIN_START, EVENT_STRIKE, WeatherFlowDevice from homeassistant.components.event import EventEntity, EventEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import WeatherFlowConfigEntry from .const import DOMAIN, LOGGER, format_dispatch_call @@ -42,7 +40,7 @@ EVENT_DESCRIPTIONS: list[WeatherFlowEventEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow event entities using config entry.""" diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 3d4881324ba..43943588d78 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -1,7 +1,5 @@ """Sensors for the weatherflow integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime @@ -22,7 +20,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, LIGHT_LUX, @@ -46,6 +43,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import METRIC_SYSTEM +from . import WeatherFlowConfigEntry from .const import DOMAIN, LOGGER, format_dispatch_call @@ -295,7 +293,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors using config entry.""" @@ -353,7 +351,9 @@ class WeatherFlowSensorEntity(SensorEntity): self._attr_unique_id = f"{device.serial_number}_{description.key}" - # In the case of the USA - we may want to have a suggested US unit which differs from the internal suggested units + # In the case of the USA - we may want to have a + # suggested US unit which differs from the internal + # suggested units if description.imperial_suggested_unit is not None and not is_metric: self._attr_suggested_unit_of_measurement = ( description.imperial_suggested_unit diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 1b3679b9113..d9860bdb0fe 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -1,21 +1,19 @@ """The WeatherflowCloud integration.""" -from __future__ import annotations - import asyncio -from dataclasses import dataclass from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.ws import WeatherFlowWebsocketAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import LOGGER from .coordinator import ( + WeatherFlowCloudConfigEntry, WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowCoordinators, WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator, ) @@ -23,16 +21,9 @@ from .coordinator import ( PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] -@dataclass -class WeatherFlowCoordinators: - """Data Class for Entry Data.""" - - rest: WeatherFlowCloudUpdateCoordinatorREST - wind: WeatherFlowWindCoordinator - observation: WeatherFlowObservationCoordinator - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: WeatherFlowCloudConfigEntry +) -> bool: """Set up WeatherFlowCloud from a config entry.""" LOGGER.debug("Initializing WeatherFlowCloudDataUpdateCoordinatorREST coordinator") @@ -82,7 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websocket_observation_coordinator.async_setup(), ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = WeatherFlowCoordinators( + entry.runtime_data = WeatherFlowCoordinators( rest_data_coordinator, websocket_wind_coordinator, websocket_observation_coordinator, @@ -100,10 +91,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: WeatherFlowCloudConfigEntry +) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py index 41ac59b0e4b..522e57593ae 100644 --- a/homeassistant/components/weatherflow_cloud/config_flow.py +++ b/homeassistant/components/weatherflow_cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WeatherflowCloud integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 94eba6ce5a4..c1ccece20fe 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -1,6 +1,7 @@ """Improved coordinator design with better type safety.""" from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientResponseError @@ -29,13 +30,27 @@ from homeassistant.util.ssl import client_context from .const import DOMAIN, LOGGER +@dataclass +class WeatherFlowCoordinators: + """Data Class for Entry Data.""" + + rest: WeatherFlowCloudUpdateCoordinatorREST + wind: WeatherFlowWindCoordinator + observation: WeatherFlowObservationCoordinator + + +type WeatherFlowCloudConfigEntry = ConfigEntry[WeatherFlowCoordinators] + + class BaseWeatherFlowCoordinator[T](DataUpdateCoordinator[dict[int, T]], ABC): """Base class for WeatherFlow coordinators.""" + config_entry: WeatherFlowCloudConfigEntry + def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowCloudConfigEntry, rest_api: WeatherFlowRestAPI, stations: StationsResponseREST, update_interval: timedelta | None = None, @@ -70,7 +85,7 @@ class WeatherFlowCloudUpdateCoordinatorREST( def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowCloudConfigEntry, rest_api: WeatherFlowRestAPI, stations: StationsResponseREST, ) -> None: @@ -111,7 +126,7 @@ class BaseWebsocketCoordinator[T](BaseWeatherFlowCoordinator[dict[int, T | None] def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowCloudConfigEntry, rest_api: WeatherFlowRestAPI, websocket_api: WeatherFlowWebsocketAPI, stations: StationsResponseREST, @@ -168,8 +183,10 @@ class WeatherFlowWindCoordinator(BaseWebsocketCoordinator[EventDataRapidWind]): """Create rapid wind listen message.""" return RapidWindListenStartMessage(device_id=str(device_id)) - async def _handle_websocket_message(self, data: RapidWindWS) -> None: + async def _handle_websocket_message(self, data: RapidWindWS | None) -> None: """Handle rapid wind websocket data.""" + if data is None: + return device_id = data.device_id station_id = self.device_to_station_map[device_id] @@ -187,8 +204,12 @@ class WeatherFlowObservationCoordinator(BaseWebsocketCoordinator[WebsocketObserv """Create observation listen message.""" return ListenStartMessage(device_id=str(device_id)) - async def _handle_websocket_message(self, data: ObservationTempestWS) -> None: + async def _handle_websocket_message( + self, data: ObservationTempestWS | None + ) -> None: """Handle observation websocket data.""" + if data is None: + return device_id = data.device_id station_id = self.device_to_station_map[device_id] diff --git a/homeassistant/components/weatherflow_cloud/icons.json b/homeassistant/components/weatherflow_cloud/icons.json index 221ac699372..e5b5d992f19 100644 --- a/homeassistant/components/weatherflow_cloud/icons.json +++ b/homeassistant/components/weatherflow_cloud/icons.json @@ -19,6 +19,9 @@ "heat_index": { "default": "mdi:sun-thermometer" }, + "illuminance": { + "default": "mdi:brightness-5" + }, "lightning_strike_count": { "default": "mdi:lightning-bolt" }, @@ -65,7 +68,7 @@ "state": { "lightning": "mdi:weather-lightning-rainy", "rain": "mdi:weather-rainy", - "rain_snow": "mdi:weather-snoy-rainy", + "rain_snow": "mdi:weather-snowy-rainy", "snow": "mdi:weather-snowy" } }, @@ -88,9 +91,18 @@ } }, + "relative_humidity": { + "default": "mdi:water-percent" + }, "sea_level_pressure": { "default": "mdi:gauge" }, + "solar_radiation": { + "default": "mdi:solar-power" + }, + "uv_index": { + "default": "mdi:sun-wireless" + }, "wet_bulb_globe_temperature": { "default": "mdi:thermometer-water" }, diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index b7d29a3e9d5..60bf521d069 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.5.2"] + "requirements": ["weatherflow4py==1.5.4"] } diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index 68c1c62c544..6674733dda9 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -1,7 +1,5 @@ """Sensors for cloud based weatherflow.""" -from __future__ import annotations - from abc import ABC from collections.abc import Callable from dataclasses import dataclass @@ -20,23 +18,32 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + LIGHT_LUX, + PERCENTAGE, + UV_INDEX, EntityCategory, + UnitOfIrradiance, UnitOfLength, + UnitOfMass, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, UnitOfTime, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import UTC -from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators -from .const import DOMAIN -from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator +from .coordinator import ( + WeatherFlowCloudConfigEntry, + WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) from .entity import WeatherFlowCloudEntity PRECIPITATION_TYPE = { @@ -146,7 +153,43 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=5, value_fn=lambda data: data.air_density, - native_unit_of_measurement="kg/m³", + native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfVolume.CUBIC_METERS}", + ), + WeatherFlowCloudSensorEntityDescription( + key="relative_humidity", + translation_key="relative_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data: data.relative_humidity, + native_unit_of_measurement=PERCENTAGE, + ), + # Light Sensors + WeatherFlowCloudSensorEntityDescription( + key="brightness", + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data: data.brightness, + native_unit_of_measurement=LIGHT_LUX, + ), + WeatherFlowCloudSensorEntityDescription( + key="uv", + translation_key="uv_index", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: data.uv, + native_unit_of_measurement=UV_INDEX, + ), + WeatherFlowCloudSensorEntityDescription( + key="solar_radiation", + translation_key="solar_radiation", + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda data: data.solar_radiation, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, ), # Temp Sensors WeatherFlowCloudSensorEntityDescription( @@ -235,42 +278,47 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( WeatherFlowCloudSensorEntityDescription( key="precip_accum_last_1hr", translation_key="precip_accum_last_1hr", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_last_1hr, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_day", translation_key="precip_accum_local_day", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_day, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_day_final", translation_key="precip_accum_local_day_final", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_day_final, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_yesterday", translation_key="precip_accum_local_yesterday", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_yesterday, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_yesterday_final", translation_key="precip_accum_local_yesterday_final", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_yesterday_final, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_analysis_type_yesterday", @@ -350,15 +398,15 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WeatherFlowCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors based on a config entry.""" - coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data rest_coordinator = coordinators.rest - wind_coordinator = coordinators.wind # Now properly typed - observation_coordinator = coordinators.observation # Now properly typed + wind_coordinator = coordinators.wind + observation_coordinator = coordinators.observation entities: list[SensorEntity] = [ WeatherFlowCloudSensorREST(rest_coordinator, sensor_description, station_id) @@ -479,9 +527,22 @@ class WeatherFlowCloudSensorREST(WeatherFlowSensorBase): coordinator: WeatherFlowCloudUpdateCoordinatorREST + @property + def _observation(self) -> Observation | None: + """Return the current station observation.""" + observations = self.coordinator.data[self.station_id].observation.obs + if not observations: + return None + return observations[0] + + @property + def available(self) -> bool: + """Get if available.""" + return super().available and self._observation is not None + @property def native_value(self) -> StateType | datetime: """Return the native value.""" - return self.entity_description.value_fn( - self.coordinator.data[self.station_id].observation.obs[0] - ) + if (observation := self._observation) is None: + return None + return self.entity_description.value_fn(observation) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index b5ed90294e6..8663c835397 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -41,6 +41,9 @@ "heat_index": { "name": "Heat index" }, + "illuminance": { + "name": "Illuminance" + }, "lightning_strike_count": { "name": "Lightning count" }, @@ -84,9 +87,18 @@ "precip_minutes_local_yesterday_final": { "name": "Nearcast precipitation duration yesterday" }, + "relative_humidity": { + "name": "Relative humidity" + }, "sea_level_pressure": { "name": "Pressure sea level" }, + "solar_radiation": { + "name": "Solar radiation" + }, + "uv_index": { + "name": "UV index" + }, "wet_bulb_globe_temperature": { "name": "Wet bulb globe temperature" }, diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index 1114d84b858..29f0aafe1e8 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -1,7 +1,5 @@ """Support for WeatherFlow Forecast weather service.""" -from __future__ import annotations - from weatherflow4py.models.rest.unified import WeatherFlowDataREST from homeassistant.components.weather import ( @@ -9,7 +7,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfPrecipitationDepth, UnitOfPressure, @@ -19,18 +16,21 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators -from .const import DOMAIN, STATE_MAP +from .const import STATE_MAP +from .coordinator import ( + WeatherFlowCloudConfigEntry, + WeatherFlowCloudUpdateCoordinatorREST, +) from .entity import WeatherFlowCloudEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index 4cbac2b32d8..7927de085a5 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -1,35 +1,24 @@ """Integration for Apple's WeatherKit API.""" -from __future__ import annotations - from apple_weatherkit.client import ( WeatherKitApiClient, WeatherKitApiClientAuthenticationError, WeatherKitApiClientError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - CONF_KEY_ID, - CONF_KEY_PEM, - CONF_SERVICE_ID, - CONF_TEAM_ID, - DOMAIN, - LOGGER, -) -from .coordinator import WeatherKitDataUpdateCoordinator +from .const import CONF_KEY_ID, CONF_KEY_PEM, CONF_SERVICE_ID, CONF_TEAM_ID, LOGGER +from .coordinator import WeatherKitConfigEntry, WeatherKitDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WeatherKitConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) coordinator = WeatherKitDataUpdateCoordinator( hass=hass, config_entry=entry, @@ -51,14 +40,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WeatherKitConfigEntry) -> bool: """Handle removal of an entry.""" - if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unloaded + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py index 760516e894d..207f65fac7d 100644 --- a/homeassistant/components/weatherkit/config_flow.py +++ b/homeassistant/components/weatherkit/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for WeatherKit.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index fd790ee230f..04d47282501 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for WeatherKit integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from apple_weatherkit import DataSetType @@ -25,18 +23,20 @@ STALE_DATA_THRESHOLD = timedelta(hours=1) HOURLY_FORECAST_DURATION = timedelta(days=7) +type WeatherKitConfigEntry = ConfigEntry[WeatherKitDataUpdateCoordinator] + class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" - config_entry: ConfigEntry + config_entry: WeatherKitConfigEntry supported_data_sets: list[DataSetType] | None = None last_updated_at: datetime | None = None def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherKitConfigEntry, client: WeatherKitApiClient, ) -> None: """Initialize.""" diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py index b3639fa5356..224f5986477 100644 --- a/homeassistant/components/weatherkit/sensor.py +++ b/homeassistant/components/weatherkit/sensor.py @@ -6,15 +6,14 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolumetricFlux from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_CURRENT_WEATHER, DOMAIN -from .coordinator import WeatherKitDataUpdateCoordinator +from .const import ATTR_CURRENT_WEATHER +from .coordinator import WeatherKitConfigEntry, WeatherKitDataUpdateCoordinator from .entity import WeatherKitEntity SENSORS = ( @@ -35,13 +34,11 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherKitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensor entities from a config_entry.""" - coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( WeatherKitSensor(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index b57e488d06a..0c234834b0c 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -21,7 +21,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -36,23 +35,18 @@ from .const import ( ATTR_FORECAST_DAILY, ATTR_FORECAST_HOURLY, ATTRIBUTION, - DOMAIN, ) -from .coordinator import WeatherKitDataUpdateCoordinator +from .coordinator import WeatherKitConfigEntry, WeatherKitDataUpdateCoordinator from .entity import WeatherKitEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherKitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] - - async_add_entities([WeatherKitWeather(coordinator)]) + async_add_entities([WeatherKitWeather(config_entry.runtime_data)]) condition_code_to_hass = { @@ -149,7 +143,7 @@ class WeatherKitWeather( @property def supported_features(self) -> WeatherEntityFeature: - """Determine supported features based on available data sets reported by WeatherKit.""" + """Determine supported features based on available data sets.""" features = WeatherEntityFeature(0) if not self.coordinator.supported_data_sets: diff --git a/homeassistant/components/web_rtc/__init__.py b/homeassistant/components/web_rtc/__init__.py index 8b684cbda3c..2fd4de986a2 100644 --- a/homeassistant/components/web_rtc/__init__.py +++ b/homeassistant/components/web_rtc/__init__.py @@ -1,7 +1,5 @@ """The WebRTC integration.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from typing import Any diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py index 62a9ac76240..45696a086e4 100644 --- a/homeassistant/components/webdav/__init__.py +++ b/homeassistant/components/webdav/__init__.py @@ -1,7 +1,5 @@ """The WebDAV integration.""" -from __future__ import annotations - import logging from aiowebdav2.client import Client @@ -50,6 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo # Ensure the backup directory exists if not await async_ensure_path_exists(client, path): + # pylint: disable-next=home-assistant-exception-translation-key-missing raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_access_or_create_backup_path", diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index cb1607685b2..51691870ea6 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -1,7 +1,5 @@ """Support for WebDAV backup.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging @@ -22,6 +20,7 @@ from homeassistant.components.backup import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_dumps +from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import WebDavConfigEntry @@ -31,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) CACHE_TTL = 300 +METADATA_DOWNLOAD_CONCURRENCY = 4 async def async_get_backup_agents( @@ -239,11 +239,18 @@ class WebDavBackupAgent(BackupAgent): async def _list_metadata_files() -> dict[str, AgentBackup]: """List metadata files.""" files = await self._client.list_files(self._backup_path) + metadata_contents = await gather_with_limited_concurrency( + METADATA_DOWNLOAD_CONCURRENCY, + *( + _download_metadata(file_name) + for file_name in files + if file_name.endswith(".metadata.json") + ), + ) return { metadata_content.backup_id: metadata_content - for file_name in files - if file_name.endswith(".metadata.json") - if (metadata_content := await _download_metadata(file_name)) + for metadata_content in metadata_contents + if metadata_content } self._cache_metadata_files = await _list_metadata_files() diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index 95b20761d09..23d9ef52e93 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the WebDAV integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 7771439e46e..7e9b7d83c3e 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -2,6 +2,7 @@ import logging +from aiohttp import ClientTimeout from aiowebdav2.client import Client, ClientOptions from homeassistant.core import HomeAssistant, callback @@ -27,6 +28,7 @@ def async_create_client( options=ClientOptions( verify_ssl=verify_ssl, session=async_get_clientsession(hass), + timeout=ClientTimeout(total=30), ), ) diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml index 59a98e0e747..b37bd739deb 100644 --- a/homeassistant/components/webdav/quality_scale.yaml +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -124,7 +124,7 @@ rules: status: exempt comment: | This integration does not have entities. - exception-translations: done + exception-translations: todo icon-translations: status: exempt comment: | diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index 0f530f3ce77..a9c2de79602 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -30,6 +30,9 @@ } }, "exceptions": { + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path" + }, "cannot_connect": { "message": "Cannot connect to WebDAV server" }, diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 92ef59db908..897e997541f 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,13 +1,12 @@ """Webhooks for Home Assistant.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass from http import HTTPStatus from ipaddress import ip_address import logging import secrets -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from aiohttp import StreamReader from aiohttp.hdrs import METH_GET, METH_HEAD, METH_POST, METH_PUT @@ -20,9 +19,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util import network as network_util from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) @@ -34,21 +33,36 @@ URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +type HandlerType = Callable[[HomeAssistant, str, Request], Awaitable[Response | None]] + + +@dataclass(frozen=True, slots=True) +class WebhookData: + """Data for a registered webhook.""" + + domain: str + name: str + handler: HandlerType + local_only: bool + allowed_methods: frozenset[str] + + +_HANDLERS: HassKey[dict[str, WebhookData]] = HassKey(DOMAIN) + @callback -@bind_hass def async_register( hass: HomeAssistant, domain: str, name: str, webhook_id: str, - handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], + handler: HandlerType, *, - local_only: bool | None = False, + local_only: bool = False, allowed_methods: Iterable[str] | None = None, ) -> None: """Register a webhook.""" - handlers = hass.data.setdefault(DOMAIN, {}) + handlers = hass.data.setdefault(_HANDLERS, {}) if webhook_id in handlers: raise ValueError("Handler is already defined!") @@ -62,20 +76,27 @@ def async_register( f"Unexpected method: {allowed_methods.difference(SUPPORTED_METHODS)}" ) - handlers[webhook_id] = { - "domain": domain, - "name": name, - "handler": handler, - "local_only": local_only, - "allowed_methods": allowed_methods, - } + if not isinstance(local_only, bool): + # Previously it was valid to pass None for + # local_only and it was treated as False + # with a deprecation warning. In case a custom component is still passing None, + # we want to raise an error instead of silently treating it as False as the + # deprecation period has ended and the message was removed. + raise TypeError("local_only must be a boolean") + + handlers[webhook_id] = WebhookData( + domain=domain, + name=name, + handler=handler, + local_only=local_only, + allowed_methods=allowed_methods, + ) @callback -@bind_hass def async_unregister(hass: HomeAssistant, webhook_id: str) -> None: """Remove a webhook.""" - handlers = hass.data.setdefault(DOMAIN, {}) + handlers = hass.data.setdefault(_HANDLERS, {}) handlers.pop(webhook_id, None) @@ -86,7 +107,6 @@ def async_generate_id() -> str: @callback -@bind_hass def async_generate_url( hass: HomeAssistant, webhook_id: str, @@ -117,18 +137,24 @@ def async_generate_path(webhook_id: str) -> str: return URL_WEBHOOK_PATH.format(webhook_id=webhook_id) -@bind_hass async def async_handle_webhook( hass: HomeAssistant, webhook_id: str, request: Request | MockRequest ) -> Response: """Handle a webhook.""" - handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}) + handlers = hass.data.setdefault(_HANDLERS, {}) content_stream: StreamReader | MockStreamReader + received_from: str | None if isinstance(request, MockRequest): received_from = request.mock_source + if request.remote is not None: + received_from += f" ({request.remote})" content_stream = request.content method_name = request.method + if TYPE_CHECKING: + # MockRequest mimics the aiohttp Request interface and is used for + # cloudhooks and webhooks triggered via the WebSocket API. + request = cast(Request, request) else: received_from = request.remote content_stream = request.content @@ -147,7 +173,7 @@ async def async_handle_webhook( _LOGGER.debug("%s", content) return Response(status=HTTPStatus.OK) - if method_name not in webhook["allowed_methods"]: + if method_name not in webhook.allowed_methods: if method_name == METH_HEAD: # Allow websites to verify that the URL exists. return Response(status=HTTPStatus.OK) @@ -155,17 +181,17 @@ async def async_handle_webhook( _LOGGER.warning( "Webhook %s only supports %s methods but %s was received from %s", webhook_id, - ",".join(webhook["allowed_methods"]), + ",".join(webhook.allowed_methods), method_name, received_from, ) return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) - if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): - is_local = not is_cloud_connection(hass) + if webhook.local_only: + is_local = not (is_cloud_connection(hass) or request.remote is None) + if is_local: if TYPE_CHECKING: - assert isinstance(request, Request) assert request.remote is not None try: @@ -178,20 +204,10 @@ async def async_handle_webhook( if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) - if webhook["local_only"]: - return Response(status=HTTPStatus.OK) - if not webhook.get("warned_about_deprecation"): - webhook["warned_about_deprecation"] = True - _LOGGER.warning( - "Deprecation warning: " - "Webhook '%s' does not provide a value for local_only. " - "This webhook will be blocked after the 2023.11.0 release. " - "Use `local_only: false` to keep this webhook operating as-is", - webhook_id, - ) + return Response(status=HTTPStatus.OK) try: - response: Response | None = await webhook["handler"](hass, webhook_id, request) + response = await webhook.handler(hass, webhook_id, request) if response is None: response = Response(status=HTTPStatus.OK) except Exception: @@ -233,6 +249,7 @@ class WebhookView(HomeAssistantView): "type": "webhook/list", } ) +@websocket_api.require_admin @callback def websocket_list( hass: HomeAssistant, @@ -240,14 +257,14 @@ def websocket_list( msg: dict[str, Any], ) -> None: """Return a list of webhooks.""" - handlers = hass.data.setdefault(DOMAIN, {}) + handlers = hass.data.setdefault(_HANDLERS, {}) result = [ { "webhook_id": webhook_id, - "domain": info["domain"], - "name": info["name"], - "local_only": info["local_only"], - "allowed_methods": sorted(info["allowed_methods"]), + "domain": info.domain, + "name": info.name, + "local_only": info.local_only, + "allowed_methods": sorted(info.allowed_methods), } for webhook_id, info in handlers.items() ] @@ -278,6 +295,7 @@ async def websocket_handle( method=msg["method"], query_string=msg["query"], mock_source=f"{DOMAIN}/ws", + remote=connection.remote, ) response = await async_handle_webhook(hass, msg["webhook_id"], request) diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index f651a56b2dd..bba332f50d8 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -1,7 +1,5 @@ """Offer webhook triggered automation rules.""" -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 903d6c50a09..726c189bce1 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Webmin.""" -from __future__ import annotations - from collections.abc import Mapping from http import HTTPStatus from typing import Any, cast diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 261139faf10..86962864008 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Webmin integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py index a21c73bed13..638cc9e65ab 100644 --- a/homeassistant/components/webmin/sensor.py +++ b/homeassistant/components/webmin/sensor.py @@ -1,7 +1,5 @@ """Support for Webmin sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 411ec94e8e4..dfbf6b1f498 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -1,7 +1,5 @@ """The LG webOS TV integration.""" -from __future__ import annotations - from contextlib import suppress from aiowebostv import WebOsClient, WebOsTvPairError @@ -21,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import DATA_HASS_CONFIG, DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS from .helpers import WebOsTvConfigEntry, update_client_key from .services import async_setup_services @@ -30,8 +28,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LG webOS TV platform.""" - hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) - async_setup_services(hass) return True @@ -50,7 +46,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b try: await client.connect() except WebOsTvPairError as err: - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err # If pairing request accepted there will be no error # Update the stored key without triggering reauth @@ -69,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b CONF_NAME: entry.title, ATTR_CONFIG_ENTRY_ID: entry.entry_id, }, - hass.data[DOMAIN][DATA_HASS_CONFIG], + {}, ) ) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 44711c2b456..a48295e54ac 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -1,7 +1,5 @@ """Config flow for LG webOS TV integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, Self from urllib.parse import urlparse @@ -136,7 +134,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._host == self._host # noqa: SLF001 + return other_flow._host == self._host async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 25c5a908fdc..94b8291ab68 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -9,7 +9,6 @@ from homeassistant.const import Platform DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" ATTR_PAYLOAD = "payload" diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index 951c11525b1..6b713d70df2 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -1,13 +1,12 @@ """Provides device automations for control of LG webOS TV.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -15,10 +14,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN, trigger -from .helpers import ( - async_get_client_by_device_entry, - async_get_device_entry_by_device_id, -) +from .helpers import async_get_device_entry_by_device_id from .triggers.turn_on import ( PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, async_get_turn_on_trigger, @@ -42,9 +38,31 @@ async def async_validate_trigger_config( device_id = config[CONF_DEVICE_ID] try: device = async_get_device_entry_by_device_id(hass, device_id) - async_get_client_by_device_entry(hass, device) except ValueError as err: - raise InvalidDeviceAutomationConfig(err) from err + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_valid", + translation_placeholders={"device_id": device_id}, + ) from err + + for config_entry_id in device.config_entries: + if ( + entry := hass.config_entries.async_get_entry(config_entry_id) + ) and entry.domain == DOMAIN: + if entry.state is ConfigEntryState.LOADED: + break + + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_config_entry_not_loaded", + translation_placeholders={"device_id": device.id}, + ) + else: + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_valid", + translation_placeholders={"device_id": device.id}, + ) return config diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index e4ea38064a8..528f43f4f3d 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for LG webOS TV.""" -from __future__ import annotations - from typing import Any from aiowebostv import WebOsClient diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index f70f250f91d..01e7ba5ff07 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -1,12 +1,10 @@ """Helper functions for LG webOS TV.""" -from __future__ import annotations - import logging from aiowebostv import WebOsClient, WebOsTvState -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -58,31 +56,6 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s return entity_entry.device_id -@callback -def async_get_client_by_device_entry( - hass: HomeAssistant, device: DeviceEntry -) -> WebOsClient: - """Get WebOsClient from Device Registry by device entry. - - Raises ValueError if client is not found. - """ - for config_entry_id in device.config_entries: - entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry( - config_entry_id - ) - if entry and entry.domain == DOMAIN: - if entry.state is ConfigEntryState.LOADED: - return entry.runtime_data - - raise ValueError( - f"Device {device.id} is not from a loaded {DOMAIN} config entry" - ) - - raise ValueError( - f"Device {device.id} is not from an existing {DOMAIN} config entry" - ) - - def get_sources(tv_state: WebOsTvState) -> list[str]: """Construct sources list.""" sources = [] diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index cb2059be2f4..5a97126caed 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,7 +1,5 @@ """Support for interface with an LG webOS TV.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress @@ -256,7 +254,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): } def _update_sources(self) -> None: - """Update list of sources from current source, apps, inputs and configured list.""" + """Update list of sources from current source and apps.""" tv_state = self._client.tv_state source_list = self._source_list self._source_list = {} @@ -364,7 +362,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - tv_volume = int(round(volume * 100)) + tv_volume = round(volume * 100) await self._client.set_volume(tv_volume) @cmd diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index a2e9753c172..d093c39f43d 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -1,7 +1,5 @@ """Support for LG webOS TV notification service.""" -from __future__ import annotations - from typing import Any from aiowebostv import WebOsClient @@ -53,10 +51,7 @@ class LgWebOSNotificationService(BaseNotificationService): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="notify_device_off", - translation_placeholders={ - "name": str(self._entry.title), - "func": __name__, - }, + translation_placeholders={"name": str(self._entry.title)}, ) try: await client.send_message(message, icon_path=icon_path) diff --git a/homeassistant/components/webostv/services.py b/homeassistant/components/webostv/services.py index 1515ca67d1e..04cd10548ca 100644 --- a/homeassistant/components/webostv/services.py +++ b/homeassistant/components/webostv/services.py @@ -1,7 +1,5 @@ """LG webOS TV services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index c3297dd8902..3949577f655 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -30,7 +30,6 @@ command: payload: example: >- target: https://www.google.com - advanced: true selector: object: diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a055d7bd6f7..2c8e1c3292e 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -46,9 +46,18 @@ } }, "exceptions": { + "auth_failed": { + "message": "Pairing failed, make sure to accept the pairing request on your TV." + }, "communication_error": { "message": "Communication error while calling {func} for device {name}: {error}" }, + "device_config_entry_not_loaded": { + "message": "The LG webOS TV integration for device {device_id} is not loaded." + }, + "device_not_valid": { + "message": "Device {device_id} is not a valid LG webOS TV device." + }, "device_off": { "message": "Error calling {func} for device {name}: Device is off and cannot be controlled." }, diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index f121daafb91..bc378e368d2 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -1,7 +1,5 @@ """LG webOS TV trigger dispatcher.""" -from __future__ import annotations - from typing import cast from homeassistant.const import CONF_PLATFORM diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py index 648da690715..34afcd892a9 100644 --- a/homeassistant/components/webostv/triggers/turn_on.py +++ b/homeassistant/components/webostv/triggers/turn_on.py @@ -1,7 +1,5 @@ """LG webOS TV device turn on trigger.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index f9bc4396e01..3a526e32fc1 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -1,13 +1,10 @@ """WebSocket based API for Home Assistant.""" -from __future__ import annotations - from typing import Final, cast from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, VolSchemaType -from homeassistant.loader import bind_hass from . import commands, connection, const, decorators, http, messages # noqa: F401 from .connection import ActiveConnection, current_connection # noqa: F401 @@ -47,7 +44,6 @@ DEPENDENCIES: Final[tuple[str]] = ("http",) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -@bind_hass @callback def async_register_command( hass: HomeAssistant, diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index b0e319bbce5..f46606640f7 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -1,7 +1,5 @@ """Handle the auth of a connection.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Final @@ -9,6 +7,7 @@ from aiohttp.web import Request import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.const import __version__ @@ -77,6 +76,7 @@ class AuthPhase: self._send_message, self._request[KEY_HASS_USER], refresh_token=None, + remote=self._request.remote, ) await self._send_bytes_text(AUTH_OK_MESSAGE) self._logger.debug("Auth OK (unix socket)") @@ -97,12 +97,20 @@ class AuthPhase: if (access_token := valid_msg.get("access_token")) and ( refresh_token := self._hass.auth.async_validate_access_token(access_token) ): + if user_access_error := async_user_not_allowed_do_auth( + self._hass, refresh_token.user, self._request + ): + await self._send_bytes_text(auth_invalid_message(user_access_error)) + await process_wrong_login(self._request) + raise Disconnect + conn = ActiveConnection( self._logger, self._hass, self._send_message, refresh_token.user, refresh_token, + remote=self._request.remote, ) conn.subscriptions["auth"] = ( self._hass.auth.async_register_revoke_token_callback( diff --git a/homeassistant/components/websocket_api/automation.py b/homeassistant/components/websocket_api/automation.py index 5efd6de792a..c091895b5bb 100644 --- a/homeassistant/components/websocket_api/automation.py +++ b/homeassistant/components/websocket_api/automation.py @@ -1,7 +1,5 @@ """Automation related helper methods for the Websocket API.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass from enum import StrEnum @@ -10,7 +8,7 @@ from typing import Any, Self from homeassistant.const import CONF_TARGET from homeassistant.core import HomeAssistant -from homeassistant.helpers import target as target_helpers +from homeassistant.helpers import entity_registry as er, target as target_helpers from homeassistant.helpers.condition import ( async_get_all_descriptions as async_get_all_condition_descriptions, ) @@ -92,12 +90,14 @@ class _AutomationComponentLookupData: component: str filters: list[_EntityFilter] + primary_entities_only: bool = True @classmethod def create(cls, component: str, target_description: dict[str, Any]) -> Self: """Build automation component lookup data from target description.""" filters: list[_EntityFilter] = [] + primary_entities_only = target_description.get("primary_entities_only", True) entity_filters_config = target_description.get("entity", []) for entity_filter_config in entity_filters_config: entity_filter = _EntityFilter( @@ -110,14 +110,29 @@ class _AutomationComponentLookupData: ) filters.append(entity_filter) - return cls(component=component, filters=filters) + return cls( + component=component, + filters=filters, + primary_entities_only=primary_entities_only, + ) def matches( - self, hass: HomeAssistant, entity_id: str, domain: str, integration: str + self, + hass: HomeAssistant, + entity_id: str, + domain: str, + integration: str, + check_entity_category: bool, ) -> bool: """Return if entity matches ANY of the filters.""" + if check_entity_category and self.primary_entities_only: + entry = er.async_get(hass).async_get(entity_id) + if entry is not None and entry.entity_category is not None: + return False + if not self.filters: return True + return any( f.matches(hass, entity_id, domain, integration) for f in self.filters ) @@ -167,7 +182,7 @@ def _get_automation_component_lookup_table( component_type: AutomationComponentType, component_descriptions: Mapping[str, Mapping[str, Any] | None], ) -> _AutomationComponentLookupTable: - """Get a dict of automation components keyed by domain, along with the total number of components. + """Get automation components keyed by domain with total count. Returns a cached object if available. """ @@ -214,12 +229,14 @@ def _async_get_automation_components_for_target( ) -> set[str]: """Get automation components (triggers/conditions/services) for a target. - Returns all components that can be used on any entity that are currently part of a target. + Returns all components that can be used on any entity + that are currently part of a target. """ extracted = target_helpers.async_extract_referenced_entity_ids( hass, target_helpers.TargetSelection(target_selection), expand_group=expand_group, + primary_entities_only=False, ) _LOGGER.debug("Extracted entities for lookup: %s", extracted) @@ -232,30 +249,40 @@ def _async_get_automation_components_for_target( entity_infos = entity_sources(hass) matched_components: set[str] = set() - for entity_id in extracted.referenced | extracted.indirectly_referenced: - if lookup_table.component_count == len(matched_components): - # All automation components matched already, so we don't need to iterate further - break - entity_info = entity_infos.get(entity_id) - if entity_info is None: - _LOGGER.debug("No entity source found for %s", entity_id) - continue + def _match_components(entities: set[str], check_entity_category: bool) -> None: + for entity_id in entities: + if lookup_table.component_count == len(matched_components): + # All automation components matched already, + # so we don't need to iterate further + break - entity_domain = entity_id.split(".")[0] - entity_integration = entity_info["domain"] - for domain in (entity_domain, entity_integration, None): - if not ( - domain_component_data := lookup_table.domain_components.get(domain) - ): + entity_info = entity_infos.get(entity_id) + if entity_info is None: + _LOGGER.debug("No entity source found for %s", entity_id) continue - for component_data in domain_component_data: - if component_data.component in matched_components: - continue - if component_data.matches( - hass, entity_id, entity_domain, entity_integration + + entity_domain = entity_id.split(".")[0] + entity_integration = entity_info["domain"] + for domain in (entity_domain, entity_integration, None): + if not ( + domain_component_data := lookup_table.domain_components.get(domain) ): - matched_components.add(component_data.component) + continue + for component_data in domain_component_data: + if component_data.component in matched_components: + continue + if component_data.matches( + hass, + entity_id, + entity_domain, + entity_integration, + check_entity_category, + ): + matched_components.add(component_data.component) + + _match_components(extracted.referenced, check_entity_category=False) + _match_components(extracted.indirectly_referenced, check_entity_category=True) return matched_components diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e083a8253b1..5bc658490e6 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1,8 +1,7 @@ """Commands part of Websocket API.""" -from __future__ import annotations - from collections.abc import Callable +from datetime import datetime, timedelta from functools import lru_cache, partial import json import logging @@ -40,6 +39,7 @@ from homeassistant.helpers import ( entity, target as target_helpers, template, + trace, ) from homeassistant.helpers.condition import ( async_from_config as async_condition_from_config, @@ -57,6 +57,7 @@ from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, async_track_template_result, + async_track_time_interval, ) from homeassistant.helpers.json import ( JSON_DUMP, @@ -126,6 +127,7 @@ def async_register_commands( async_reg(hass, handle_ping) async_reg(hass, handle_render_template) async_reg(hass, handle_subscribe_bootstrap_integrations) + async_reg(hass, handle_subscribe_condition) async_reg(hass, handle_subscribe_condition_platforms) async_reg(hass, handle_subscribe_events) async_reg(hass, handle_subscribe_trigger) @@ -865,6 +867,7 @@ def handle_entity_source( vol.Required("type"): "extract_from_target", vol.Required("target"): cv.TARGET_FIELDS, vol.Optional("expand_group", default=False): bool, + vol.Optional("primary_entities_only", default=True): bool, } ) def handle_extract_from_target( @@ -874,7 +877,10 @@ def handle_extract_from_target( target_selection = target_helpers.TargetSelection(msg["target"]) extracted = target_helpers.async_extract_referenced_entity_ids( - hass, target_selection, expand_group=msg["expand_group"] + hass, + target_selection, + expand_group=msg["expand_group"], + primary_entities_only=msg["primary_entities_only"], ) extracted_dict = { @@ -905,8 +911,8 @@ async def handle_get_triggers_for_target( ) -> None: """Handle get triggers for target command. - This command returns all triggers that can be used with any entities that are currently - part of a target. + This command returns all triggers that can be used + with any entities that are currently part of a target. """ triggers = await async_get_triggers_for_target( hass, msg["target"], msg["expand_group"] @@ -928,8 +934,8 @@ async def handle_get_conditions_for_target( ) -> None: """Handle get conditions for target command. - This command returns all conditions that can be used with any entities that are currently - part of a target. + This command returns all conditions that can be used + with any entities that are currently part of a target. """ conditions = await async_get_conditions_for_target( hass, msg["target"], msg["expand_group"] @@ -951,8 +957,8 @@ async def handle_get_services_for_target( ) -> None: """Handle get services for target command. - This command returns all services that can be used with any entities that are currently - part of a target. + This command returns all services that can be used + with any entities that are currently part of a target. """ services = await async_get_services_for_target( hass, msg["target"], msg["expand_group"] @@ -974,7 +980,24 @@ async def handle_subscribe_trigger( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe trigger command.""" - trigger_config = await async_validate_trigger_config(hass, msg["trigger"]) + # Validating the trigger config can fail on bad user input. Handle those + # errors here so they are reported to the client without being logged as + # unexpected errors by the default websocket error handler. + try: + trigger_config = await async_validate_trigger_config(hass, msg["trigger"]) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return @callback def forward_triggers( @@ -1021,13 +1044,132 @@ async def handle_test_condition( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle test condition command.""" - # Do static + dynamic validation of the condition - config = await async_validate_condition_config(hass, msg["condition"]) - # Test the condition - check_condition = await async_condition_from_config(hass, config) - connection.send_result( - msg["id"], {"result": check_condition(hass, msg.get("variables"))} + # Validating and instantiating the condition can fail on bad user input. + # Handle those errors here so they are reported to the client without being + # logged as unexpected errors by the default websocket error handler. + try: + # Do static + dynamic validation of the condition + config = await async_validate_condition_config(hass, msg["condition"]) + condition = await async_condition_from_config(hass, config) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return + + # Template errors (e.g. undefined variables) are recorded in the trace + # instead of being logged. Capture the trace and forward them to the client + # alongside the result. + condition_trace = trace.trace_get() + try: + with trace.suppress_template_error_logging(): + check_result = condition.async_check(variables=msg.get("variables")) + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + else: + result: dict[str, Any] = {"result": check_result} + if template_errors := [ + template_error + for elements in condition_trace.values() + for element in elements + for template_error in element.template_errors + ]: + result["template_errors"] = template_errors + connection.send_result(msg["id"], result) + finally: + condition.async_unload() + + +@decorators.websocket_command( + { + vol.Required("type"): "subscribe_condition", + vol.Required("condition"): cv.CONDITION_SCHEMA, + } +) +@decorators.require_admin +@decorators.async_response +async def handle_subscribe_condition( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe condition command.""" + try: + condition_config = await async_validate_condition_config(hass, msg["condition"]) + condition = await async_condition_from_config(hass, condition_config) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return + + event_data: dict[str, Any] = {} + + @callback + def evaluate_condition(now: datetime | None) -> None: + """Forward events to websocket.""" + nonlocal event_data + new_event_data: dict[str, Any] + + condition_trace = trace.trace_get() + try: + with trace.suppress_template_error_logging(): + new_event_data = {"result": condition.async_check()} + except HomeAssistantError as err: + new_event_data = {"error": str(err)} + + # Template errors (e.g. undefined variables) are recorded in the trace + # instead of being logged. Forward them to the client so they are not + # lost, even when the condition still evaluated to a result. + if template_errors := [ + template_error + for elements in condition_trace.values() + for element in elements + for template_error in element.template_errors + ]: + new_event_data["template_errors"] = template_errors + + if new_event_data == event_data: + return + event_data = new_event_data + connection.send_event(msg["id"], event_data) + + @callback + def unsubscribe() -> None: + """Unsubscribe from condition updates.""" + condition.async_unload() + unsub() + + unsub = async_track_time_interval( + hass, + evaluate_condition, + timedelta(seconds=1), + name="websocket_api_condition_subscription", ) + connection.subscriptions[msg["id"]] = unsubscribe + connection.send_result(msg["id"]) + evaluate_condition(None) @decorators.websocket_command( @@ -1069,6 +1211,8 @@ async def handle_execute_script( translation_placeholders=err.translation_placeholders, ) return + finally: + await script_obj.async_unload() connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index dad8ebe5686..7d372c2ed44 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -1,7 +1,5 @@ """Connection session.""" -from __future__ import annotations - from collections.abc import Callable, Hashable from contextvars import ContextVar from typing import TYPE_CHECKING, Any, Literal @@ -13,6 +11,7 @@ from homeassistant.auth.models import RefreshToken, User from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers.http import current_request +from homeassistant.helpers.redact import async_redact_data from homeassistant.util.json import JsonValueType from . import const, messages @@ -32,6 +31,15 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +REDACT_KEYS = { + "access_token", + "password", + "api_password", + "refresh_token", + "token", + "auth_token", +} + type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None] type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None] @@ -47,6 +55,7 @@ class ActiveConnection: "last_id", "logger", "refresh_token_id", + "remote", "send_message", "subscriptions", "supported_features", @@ -60,6 +69,7 @@ class ActiveConnection: send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, refresh_token: RefreshToken | None, + remote: str | None, ) -> None: """Initialize an active connection.""" self.logger = logger @@ -67,6 +77,7 @@ class ActiveConnection: self.send_message = send_message self.user = user self.refresh_token_id = refresh_token.id if refresh_token else None + self.remote = remote self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.can_coalesce = False @@ -198,6 +209,7 @@ class ActiveConnection: or type(type_) is not str ) ): + msg = async_redact_data(msg, REDACT_KEYS) self.logger.error("Received invalid command: %s", msg) id_ = msg.get("id") if isinstance(msg, dict) else 0 self.send_message( @@ -261,6 +273,7 @@ class ActiveConnection: self, msg: bytes | str | dict[str, Any] | Callable[[], str] ) -> None: """Send a message when the connection is closed.""" + msg = async_redact_data(msg, REDACT_KEYS) self.logger.debug("Tried to send message %s on closed connection", msg) @callback @@ -274,6 +287,8 @@ class ActiveConnection: translation_key: str | None = None translation_placeholders: dict[str, Any] | None = None + msg = async_redact_data(msg, REDACT_KEYS) + if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED err_message = "Unauthorized" diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index fce85339430..f1eb480dd11 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -1,7 +1,5 @@ """Websocket constants.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, Final diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 2c8a6cc02f1..37ac60baba0 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -1,7 +1,5 @@ """Decorators for the Websocket API.""" -from __future__ import annotations - from collections.abc import Callable from functools import wraps from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 27280f46516..de04e085f38 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -1,7 +1,5 @@ """View to accept incoming websocket connection.""" -from __future__ import annotations - import asyncio from collections import deque from collections.abc import Callable, Coroutine @@ -94,7 +92,9 @@ class WebSocketHandler: self._hass = hass self._loop = hass.loop self._request: web.Request = request - self._wsock = web.WebSocketResponse(heartbeat=55) + # decode_text=False so orjson decodes the raw TEXT bytes directly + # instead of decoding to str first and re-scanning. + self._wsock = web.WebSocketResponse(heartbeat=55, decode_text=False) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False @@ -216,7 +216,8 @@ class WebSocketHandler: if (queue_size_after_add := len(message_queue)) >= MAX_PENDING_MSG: self._logger.error( ( - "%s: Client unable to keep up with pending messages. Reached %s pending" + "%s: Client unable to keep up with" + " pending messages. Reached %s pending" " messages. The system's load is too high or an integration is" " misbehaving; Last message was: %s" ), @@ -280,7 +281,8 @@ class WebSocketHandler: self._logger.error( ( - "%s: Client unable to keep up with pending messages. Stayed over %s for %s" + "%s: Client unable to keep up with" + " pending messages. Stayed over %s for %s" " seconds. The system's load is too high or an integration is" " misbehaving; Last message was: %s" ), @@ -401,7 +403,8 @@ class WebSocketHandler: msg = await self._wsock.receive(AUTH_MESSAGE_TIMEOUT) except TimeoutError as err: raise Disconnect( - f"Did not receive auth message within {AUTH_MESSAGE_TIMEOUT} seconds" + "Did not receive auth message within" + f" {AUTH_MESSAGE_TIMEOUT} seconds" ) from err if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): @@ -551,8 +554,10 @@ class WebSocketHandler: if disconnect_warn is None: logger.debug("%s: Disconnected", self.description) elif connection is None: - # Auth phase disconnects (connection is None) should be logged at debug level - # as they can be from random port scanners or non-legitimate connections + # Auth phase disconnects (connection is + # None) should be logged at debug level + # as they can be from random port scanners + # or non-legitimate connections logger.debug( "%s: Disconnected during auth phase: %s", self.description, diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 4d5a53907b2..13f856e6667 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -1,7 +1,5 @@ """Message templates for websocket commands.""" -from __future__ import annotations - from functools import lru_cache import logging from typing import Any, Final @@ -245,7 +243,8 @@ def _state_diff_event( additions[COMPRESSED_STATE_ATTRIBUTES] = added if removed := old_attributes.keys() - new_attributes: # sets are not JSON serializable by default so we convert to list - # here if there are any values to avoid jumping into the json_encoder_default + # here if there are any values to avoid jumping + # into the json_encoder_default # for every state diff with a removed attribute diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: list(removed)} return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 4d874bca74e..4937afa458d 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -1,7 +1,5 @@ """Entity to track connections to websocket API.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect diff --git a/homeassistant/components/websocket_api/util.py b/homeassistant/components/websocket_api/util.py index 6af9c9c9bc2..c17f646791c 100644 --- a/homeassistant/components/websocket_api/util.py +++ b/homeassistant/components/websocket_api/util.py @@ -1,7 +1,5 @@ """Websocket API util."".""" -from __future__ import annotations - from aiohttp import web diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index 2e3df341881..f7d5ffafdb8 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -1,7 +1,5 @@ """The Weheat integration.""" -from __future__ import annotations - import asyncio from http import HTTPStatus diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index 2911ebdd49b..3afec57506e 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -32,7 +32,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): } async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: - """Override the create entry method to change to the step to find the heat pumps.""" + """Override create entry to find heat pumps.""" # get the user id and use that as unique id for this entry user_id = await async_get_user_id_from_token( API_URL, diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index c33e128c09f..d3b4ebbcee6 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -42,13 +42,16 @@ class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo): """Initialize the HeatPump object with the provided pump information. Args: - pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including: + pump_info (HeatPumpDiscovery.HeatPumpInfo): + An object containing the heat pump's discovery + information, including: - uuid (str): Unique identifier for the heat pump. - uuid (str): Unique identifier for the heat pump. - device_name (str): Name of the heat pump device. - model (str): Model of the heat pump. - sn (str): Serial number of the heat pump. - - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality. + - has_dhw (bool): Indicates if the heat pump + has domestic hot water functionality. """ super().__init__( diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 304494fcc37..98a147b72dd 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/weheat", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["weheat==2026.2.28"] + "requirements": ["weheat==2026.4.8"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 960749a1aa1..e9f512d0386 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -218,7 +218,7 @@ ENERGY_SENSORS = [ key="energy_output", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value_fn=lambda status: status.energy_output, ), WeHeatSensorEntityDescription( @@ -245,6 +245,14 @@ ENERGY_SENSORS = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda status: status.energy_in_defrost, ), + WeHeatSensorEntityDescription( + translation_key="electricity_used_standby", + key="electricity_used_standby", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_standby, + ), WeHeatSensorEntityDescription( translation_key="energy_output_heating", key="energy_output_heating", diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index f98d1ab086d..f75e6014356 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -96,6 +96,9 @@ "electricity_used_heating": { "name": "Electricity used heating" }, + "electricity_used_standby": { + "name": "Electricity used standby" + }, "energy_output": { "name": "Total energy output" }, diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 96e61dfded6..68cce367259 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,7 +1,5 @@ """Support for WeMo device discovery.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine, Sequence from datetime import datetime import logging @@ -99,7 +97,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) yaml_config = config.get(DOMAIN, {}) - hass.data[DOMAIN] = WemoData( + hass.data[DATA_WEMO] = WemoData( discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY), static_config=yaml_config.get(CONF_STATIC, []), registry=registry, @@ -126,7 +124,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: dispatcher=dispatcher, ) - # Need to do this at least once in case statistics are defined and discovery is disabled + # Need to do this at least once in case statistics + # are defined and discovery is disabled await discovery.discover_statics() if wemo_data.discovery_enabled: @@ -200,7 +199,8 @@ class WemoDispatcher: # Three cases: # - Platform is loaded, dispatch discovery # - Platform is being loaded, add to backlog - # - First time we see platform, we need to load it and initialize the backlog + # - First time we see platform, we need to load + # it and initialize the backlog if platform in self._dispatch_callbacks: await self._dispatch_callbacks[platform](coordinator) @@ -221,7 +221,7 @@ class WemoDispatcher: async def async_connect_platform( self, platform: Platform, dispatch: DispatchCallback ) -> None: - """Consider a platform as loaded and dispatch any backlog of discovered devices.""" + """Mark platform loaded and dispatch backlog of discovered devices.""" self._dispatch_callbacks[platform] = dispatch await gather_with_limited_concurrency( diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index 361c58953c5..f10a8c94d3f 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Wemo.""" -from __future__ import annotations - from dataclasses import fields from typing import Any, get_type_hints @@ -64,11 +62,12 @@ def _schema_for_options(options: Options) -> vol.Schema: All values are optional. The default value is set to the current value and the type hint is set to the value of the field type annotation. """ + type_hints = get_type_hints(type(options)) return vol.Schema( { - vol.Optional( - field.name, default=getattr(options, field.name) - ): get_type_hints(options)[field.name] + vol.Optional(field.name, default=getattr(options, field.name)): type_hints[ + field.name + ] for field in fields(options) } ) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 129c00b18cf..64765784aff 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -1,7 +1,5 @@ """Home Assistant wrapper for a pyWeMo device.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, fields from datetime import timedelta @@ -50,7 +48,8 @@ class OptionsValidationError(Exception): The field_key and error_key strings must be the same as in strings.json. Args: - field_key: Name of the options.step.init.data key that corresponds to this error. + field_key: Name of the options.step.init.data key + that corresponds to this error. field_key must also match one of the field names inside the Options class. error_key: Name of the options.error key that corresponds to this error. message: Message for the Exception class. @@ -81,7 +80,8 @@ class Options: raise OptionsValidationError( "enable_subscription", "long_press_requires_subscription", - "Local push update subscriptions must be enabled to use long-press events", + "Local push update subscriptions must be" + " enabled to use long-press events", ) @@ -154,7 +154,8 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): if self.options.enable_subscription: await self._async_set_enable_subscription(False) # Check that the device is available (last_update_success) before disabling long - # press. That avoids long shutdown times for devices that are no longer connected. + # press. That avoids long shutdown times for devices + # that are no longer connected. if self.options.enable_long_press and self.last_update_success: await self._async_set_enable_long_press(False) diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index 353b0470476..8444dd4cd12 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -1,7 +1,5 @@ """Triggers for WeMo devices.""" -from __future__ import annotations - from pywemo.subscribe import EVENT_TYPE_LONG_PRESS import voluptuous as vol diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 9ca690af6b4..3102d8dd578 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -1,7 +1,5 @@ """Classes shared among Wemo entities.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 491c2fcfe72..e835f44ab28 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -1,7 +1,5 @@ """Support for WeMo humidifier.""" -from __future__ import annotations - from datetime import timedelta import functools as ft import math diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 1a349e8bacd..65ac2fa087c 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,7 +1,5 @@ """Support for Belkin WeMo lights.""" -from __future__ import annotations - import functools as ft from typing import Any, cast diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py index b96cd502cd4..6ff9eed5e46 100644 --- a/homeassistant/components/wemo/models.py +++ b/homeassistant/components/wemo/models.py @@ -1,7 +1,5 @@ """Common data structures and helpers for accessing them.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 76a0265d7da..e3bffd18edf 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,7 +1,5 @@ """Support for power sensors in WeMo Insight devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 433736c64d7..7558fc1bd64 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -1,7 +1,5 @@ """Support for WeMo switches.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 972d99c33ed..e4362249f66 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -1,7 +1,5 @@ """Platform for climate integration.""" -from __future__ import annotations - from typing import Any from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index cf5d437b099..9a19b77bcea 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Whirlpool Appliances integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 6ff57ffdb67..9fde3a11a45 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Whirlpool.""" -from __future__ import annotations - from typing import Any from whirlpool.appliance import Appliance diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py index 3b65969b371..9bac108976a 100644 --- a/homeassistant/components/whirlpool/select.py +++ b/homeassistant/components/whirlpool/select.py @@ -1,7 +1,5 @@ """The select platform for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Final, override diff --git a/homeassistant/components/whois/__init__.py b/homeassistant/components/whois/__init__.py index 6f6462cd48b..ba9a098d23f 100644 --- a/homeassistant/components/whois/__init__.py +++ b/homeassistant/components/whois/__init__.py @@ -1,28 +1,22 @@ """The Whois integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import WhoisCoordinator +from .const import PLATFORMS +from .coordinator import WhoisConfigEntry, WhoisCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WhoisConfigEntry) -> bool: """Set up from a config entry.""" coordinator = WhoisCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WhoisConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py index a8306be7632..e3e6c888216 100644 --- a/homeassistant/components/whois/config_flow.py +++ b/homeassistant/components/whois/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Whois integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/whois/const.py b/homeassistant/components/whois/const.py index 0b1d1717474..748a9d337d6 100644 --- a/homeassistant/components/whois/const.py +++ b/homeassistant/components/whois/const.py @@ -1,7 +1,5 @@ """Constants for the Whois integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/whois/coordinator.py b/homeassistant/components/whois/coordinator.py index 6344e8a72e8..62820d38e34 100644 --- a/homeassistant/components/whois/coordinator.py +++ b/homeassistant/components/whois/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Whois integration.""" -from __future__ import annotations - from whois import Domain, query as whois_query from whois.exceptions import ( FailedParsingWhoisOutput, @@ -17,13 +15,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type WhoisConfigEntry = ConfigEntry[WhoisCoordinator] + class WhoisCoordinator(DataUpdateCoordinator[Domain | None]): """Class to manage fetching WHOIS data.""" - config_entry: ConfigEntry + config_entry: WhoisConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: WhoisConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/whois/diagnostics.py b/homeassistant/components/whois/diagnostics.py index ad7d8cd7164..114b0163e61 100644 --- a/homeassistant/components/whois/diagnostics.py +++ b/homeassistant/components/whois/diagnostics.py @@ -1,22 +1,17 @@ """Diagnostics support for Whois.""" -from __future__ import annotations - from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import WhoisCoordinator +from .coordinator import WhoisConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WhoisConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: WhoisCoordinator = hass.data[DOMAIN][entry.entry_id] - if (data := coordinator.data) is None: + if (data := entry.runtime_data.data) is None: return {} return { "creation_date": data.creation_date, diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index c30afbe3ac7..59cb4058f90 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -1,7 +1,5 @@ """Get WHOIS information for a given host.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -14,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -30,7 +27,7 @@ from .const import ( DOMAIN, STATUS_TYPES, ) -from .coordinator import WhoisCoordinator +from .coordinator import WhoisConfigEntry, WhoisCoordinator @dataclass(frozen=True, kw_only=True) @@ -66,8 +63,10 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: def _get_status_type(status: str | None) -> str | None: """Get the status type from the status string. - Returns the status type in snake_case, so it can be used as a key for the translations. - E.g: "clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited" -> "client_delete_prohibited". + Return the status type in snake_case for translations. + + E.g: "clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited" + -> "client_delete_prohibited". """ if status is None: return None @@ -158,11 +157,11 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WhoisConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" - coordinator: WhoisCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ WhoisSensorEntity( diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index b6811190a27..6d712367dcf 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -13,12 +13,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CHECK_ENTITIES_SIGNAL, - CREATE_ENTITY_SIGNAL, - DOMAIN, - UPDATE_ENTITY_SIGNAL, -) +from .const import CHECK_ENTITIES_SIGNAL, CREATE_ENTITY_SIGNAL, UPDATE_ENTITY_SIGNAL from .entity import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -26,16 +21,18 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +type WiffiConfigEntry = ConfigEntry[WiffiIntegrationApi] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up wiffi from a config entry, config_entry contains data from config entry database.""" + +async def async_setup_entry(hass: HomeAssistant, entry: WiffiConfigEntry) -> bool: + """Set up wiffi from a config entry.""" # create api object api = WiffiIntegrationApi(hass) api.async_setup(entry) # store api object - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + entry.runtime_data = api try: await api.server.start_server() @@ -51,21 +48,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WiffiConfigEntry) -> bool: """Unload a config entry.""" - api: WiffiIntegrationApi = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data await api.server.close_server() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - api = hass.data[DOMAIN].pop(entry.entry_id) api.shutdown() return unload_ok class WiffiIntegrationApi: - """API object for wiffi handling. Stored in hass.data.""" + """API object for wiffi handling.""" def __init__(self, hass): """Initialize the instance.""" diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index abb6dd11235..0b7b51f2740 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -1,18 +1,18 @@ """Binary sensor platform support for wiffi devices.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import WiffiConfigEntry from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WiffiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index c40bd5519e0..38f83b9df67 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -3,8 +3,6 @@ Used by UI to setup a wiffi integration. """ -from __future__ import annotations - import errno from typing import Any @@ -12,7 +10,6 @@ import voluptuous as vol from wiffi import WiffiTcpServer from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -20,6 +17,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PORT, CONF_TIMEOUT from homeassistant.core import callback +from . import WiffiConfigEntry from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN @@ -31,7 +29,7 @@ class WiffiFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: WiffiConfigEntry, ) -> OptionsFlowHandler: """Create Wiffi server setup option flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index f28c68dc31c..5d000c323f4 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -5,12 +5,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, LIGHT_LUX, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import WiffiConfigEntry from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity from .wiffi_strings import ( @@ -40,7 +40,7 @@ UOM_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WiffiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wiim/__init__.py b/homeassistant/components/wiim/__init__.py index 0c18c6fd060..75ef295bff0 100644 --- a/homeassistant/components/wiim/__init__.py +++ b/homeassistant/components/wiim/__init__.py @@ -1,7 +1,5 @@ """The WiiM integration.""" -from __future__ import annotations - from wiim.controller import WiimController from wiim.discovery import async_create_wiim_device from wiim.exceptions import WiimDeviceException, WiimRequestException @@ -72,7 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool entry.runtime_data = wiim_device LOGGER.info( - "WiiM device %s (UDN: %s) linked to HASS. Name: '%s', HTTP: %s, UPnP Location: %s", + "WiiM device %s (UDN: %s) linked to HASS." + " Name: '%s', HTTP: %s, UPnP Location: %s", entry.entry_id, wiim_device.udn, wiim_device.name, diff --git a/homeassistant/components/wiim/config_flow.py b/homeassistant/components/wiim/config_flow.py index 4002f6113a7..bf5328da95d 100644 --- a/homeassistant/components/wiim/config_flow.py +++ b/homeassistant/components/wiim/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WiiM integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/wiim/entity.py b/homeassistant/components/wiim/entity.py index 3c1dbcbafa9..3b6033eef07 100644 --- a/homeassistant/components/wiim/entity.py +++ b/homeassistant/components/wiim/entity.py @@ -1,7 +1,5 @@ """Base entity for the WiiM integration.""" -from __future__ import annotations - from wiim.wiim_device import WiimDevice from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index f9080754a74..a93652c0e97 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.0"], + "requirements": ["wiim==0.1.4"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index d1be658d0c2..c4bcc9e1854 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -1,7 +1,5 @@ """Support for WiiM Media Players.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate @@ -71,7 +69,7 @@ SUPPORT_WIIM_BASE = ( def media_player_exception_wrap[ - _WiimMediaPlayerEntityT: "WiimMediaPlayerEntity", + _WiimMediaPlayerEntityT: WiimMediaPlayerEntity, **_P, _R, ]( @@ -219,7 +217,8 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): """Update HA state from SDK's cache/HTTP poll attributes. This is the main method for updating this entity's HA attributes. - Crucially, it also handles propagating metadata to followers if this is a leader. + Crucially, it also handles propagating metadata to + followers if this is a leader. """ LOGGER.debug( "Device %s: Updating HA state from SDK cache/HTTP poll", @@ -350,14 +349,12 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): sdk_status_str, ) else: - self._device.playing_status = sdk_status if sdk_status == SDKPlayingStatus.STOPPED: LOGGER.debug( - "Device %s: TransportState is STOPPED. Resetting media position and metadata", + "Device %s: TransportState is STOPPED." + " Resetting media position and metadata", self.entity_id, ) - self._device.current_position = 0 - self._device.current_track_duration = 0 self._attr_media_position_updated_at = None self._attr_media_duration = None self._attr_media_position = None @@ -427,7 +424,8 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): ): self._transport_capabilities = None LOGGER.debug( - "Device %s: Follower transport capabilities unavailable, using base features", + "Device %s: Follower transport capabilities" + " unavailable, using base features", self.entity_id, ) diff --git a/homeassistant/components/wiim/util.py b/homeassistant/components/wiim/util.py index cd1a5e335ef..083b5bc148a 100644 --- a/homeassistant/components/wiim/util.py +++ b/homeassistant/components/wiim/util.py @@ -1,7 +1,5 @@ """Utility helpers for the WiiM integration.""" -from __future__ import annotations - from urllib.parse import urlparse from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 5242f84ab93..5dd94ac44d3 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -1,18 +1,16 @@ """The WiLight integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .parent_device import WiLightParent +from .parent_device import WiLightConfigEntry, WiLightParent # List the platforms that you want to support. PLATFORMS = [Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WiLightConfigEntry) -> bool: """Set up a wilight config entry.""" parent = WiLightParent(hass, entry) @@ -20,8 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await parent.async_setup(): raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = parent + entry.runtime_data = parent # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -29,15 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WiLightConfigEntry) -> bool: """Unload WiLight config entry.""" # Unload entities for this entry/device. unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # Cleanup - parent = hass.data[DOMAIN][entry.entry_id] - await parent.async_reset() - del hass.data[DOMAIN][entry.entry_id] + await entry.runtime_data.async_reset() return unload_ok diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 1036e5b1ead..fb4445c5052 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -82,7 +82,8 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") - # Check if all components of this WiLight are allowed in this version of the HA integration + # Check if all components of this WiLight are + # allowed in this version of the HA integration component_ok = all( wilight_component in ALLOWED_WILIGHT_COMPONENTS for wilight_component in self._wilight_components diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 2e9b92e7a21..777ba0bd9fd 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -1,7 +1,5 @@ """Support for WiLight Cover.""" -from __future__ import annotations - from typing import Any from pywilight.const import ( @@ -16,22 +14,20 @@ from pywilight.const import ( ) from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WiLightDevice -from .parent_device import WiLightParent +from .parent_device import WiLightConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WiLightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight covers from a config entry.""" - parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] + parent = entry.runtime_data # Handle a discovered WiLight device. entities = [] diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 6a22da5879e..ef075c8ecc1 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -1,7 +1,5 @@ """Support for WiLight Fan.""" -from __future__ import annotations - from typing import Any from pywilight.const import ( @@ -17,7 +15,6 @@ from pywilight.const import ( from pywilight.wilight_device import PyWiLightDevice from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -25,20 +22,19 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .const import DOMAIN from .entity import WiLightDevice -from .parent_device import WiLightParent +from .parent_device import WiLightConfigEntry ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WiLightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" - parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] + parent = entry.runtime_data # Handle a discovered WiLight device. entities = [] diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 7df0eb1a4c6..b8a36b345e1 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -1,7 +1,5 @@ """Support for WiLight lights.""" -from __future__ import annotations - from typing import Any from pywilight.const import ITEM_LIGHT, LIGHT_COLOR, LIGHT_DIMMER, LIGHT_ON_OFF @@ -13,13 +11,11 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WiLightDevice -from .parent_device import WiLightParent +from .parent_device import WiLightConfigEntry def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightEntity]: @@ -42,11 +38,11 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightE async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WiLightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" - parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] + parent = entry.runtime_data # Handle a discovered WiLight device. assert parent.api diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py index 6e71649d8fc..82250dd9c6c 100644 --- a/homeassistant/components/wilight/parent_device.py +++ b/homeassistant/components/wilight/parent_device.py @@ -1,7 +1,5 @@ """The WiLight Device integration.""" -from __future__ import annotations - import asyncio import logging @@ -16,11 +14,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) +type WiLightConfigEntry = ConfigEntry[WiLightParent] + class WiLightParent: """Manages a single WiLight Parent Device.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: WiLightConfigEntry) -> None: """Initialize the system.""" self._host: str = config_entry.data[CONF_HOST] self._hass = hass diff --git a/homeassistant/components/wilight/support.py b/homeassistant/components/wilight/support.py index a88345bb1d6..c345c38a95a 100644 --- a/homeassistant/components/wilight/support.py +++ b/homeassistant/components/wilight/support.py @@ -1,7 +1,5 @@ """Support for config validation using voluptuous and Translate Trigger.""" -from __future__ import annotations - import calendar import locale from typing import Any diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index 148ea65dd94..543189dd018 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -1,7 +1,5 @@ """Support for WiLight switches.""" -from __future__ import annotations - from typing import Any from pywilight.const import ITEM_SWITCH, SWITCH_PAUSE_VALVE, SWITCH_VALVE @@ -9,14 +7,12 @@ from pywilight.wilight_device import PyWiLightDevice import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WiLightDevice -from .parent_device import WiLightParent +from .parent_device import WiLightConfigEntry from .support import wilight_to_hass_trigger, wilight_trigger as wl_trigger # Attr of features supported by the valve switch entities @@ -76,11 +72,11 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> tuple[Any]: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WiLightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight switches from a config entry.""" - parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] + parent = entry.runtime_data # Handle a discovered WiLight device. assert parent.api diff --git a/homeassistant/components/window/__init__.py b/homeassistant/components/window/__init__.py index b4577fd370e..d91cab671ed 100644 --- a/homeassistant/components/window/__init__.py +++ b/homeassistant/components/window/__init__.py @@ -1,7 +1,5 @@ """Integration for window triggers.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/window/conditions.yaml b/homeassistant/components/window/conditions.yaml index 327fb2826a8..34275f9b42b 100644 --- a/homeassistant/components/window/conditions.yaml +++ b/homeassistant/components/window/conditions.yaml @@ -3,11 +3,13 @@ required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: is_closed: fields: *condition_common_fields diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json index 5f8de98998f..30ff7ce6734 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Window", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::trigger_for_name%]" } }, "name": "Window closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::trigger_for_name%]" } }, "name": "Window opened" diff --git a/homeassistant/components/window/triggers.yaml b/homeassistant/components/window/triggers.yaml index 4d770a85d2c..443080b5ff6 100644 --- a/homeassistant/components/window/triggers.yaml +++ b/homeassistant/components/window/triggers.yaml @@ -1,14 +1,15 @@ .trigger_common_fields: &trigger_common_fields behavior: required: true - default: any + default: each selector: - select: - translation_key: trigger_behavior - options: - - first - - last - - any + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: closed: fields: *trigger_common_fields diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 84d032dec46..424bb24f330 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -1,7 +1,5 @@ """Support for Wireless Sensor Tags.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index b153f43109e..0bf7d24ee24 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor support for Wireless Sensor Tags.""" -from __future__ import annotations - import voluptuous as vol from wirelesstagpy import SensorTag, constants as WT_CONSTANTS diff --git a/homeassistant/components/wirelesstag/const.py b/homeassistant/components/wirelesstag/const.py index b9ddf816fb8..6901f9afb58 100644 --- a/homeassistant/components/wirelesstag/const.py +++ b/homeassistant/components/wirelesstag/const.py @@ -1,7 +1,5 @@ """Support for Wireless Sensor Tags.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 33ea005c56a..26fdbb69d91 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -1,7 +1,5 @@ """Sensor support for Wireless Sensor Tags platform.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 6743138fb99..b4b0a5fde17 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -1,7 +1,5 @@ """Switch implementation for Wireless Sensor Tags (wirelesstag.net).""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index f687979eef8..c17025159e4 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -3,8 +3,6 @@ For more details about this platform, please refer to the documentation at """ -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable import contextlib @@ -44,6 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -152,6 +151,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> for coordinator in withings_data.coordinators: await coordinator.async_config_entry_first_refresh() + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(entry.unique_id))}, + manufacturer="Withings", + ) entry.runtime_data = withings_data webhook_manager = WithingsWebhookManager(hass, entry) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 457bbe59bcc..b12901e51e2 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -1,7 +1,5 @@ """Sensors flow for Withings.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 8dcad9d73ba..00b148e6654 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for Withings.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index d7f07ccc184..4e0e202b4e1 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Withings.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 13789816d85..419f613e181 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,7 +1,5 @@ """Withings coordinator.""" -from __future__ import annotations - from abc import abstractmethod from datetime import date, datetime, timedelta from typing import TYPE_CHECKING @@ -272,7 +270,7 @@ class WithingsActivityDataUpdateCoordinator( self._last_valid_update ) - today = date.today() + today = date.today() # noqa: DTZ011 for activity in activities: if activity.date == today: self._previous_data = activity diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index dd154488be2..85e0f05aebf 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Withings.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 5c548fdb260..f911a580506 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -1,7 +1,5 @@ """Base entity for Withings.""" -from __future__ import annotations - from typing import Any from aiowithings import Device @@ -31,7 +29,6 @@ class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_ self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, - manufacturer="Withings", ) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 95fd43b00fc..5b0717aad92 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -1,7 +1,5 @@ """Sensors flow for Withings.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -158,13 +156,15 @@ MEASUREMENT_SENSORS: dict[ suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, ), - MeasurementType.DIASTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( - key="diastolic_blood_pressure_mmhg", - measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, - translation_key="diastolic_blood_pressure", - native_unit_of_measurement=UnitOfPressure.MMHG, - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, + MeasurementType.DIASTOLIC_BLOOD_PRESSURE: ( + WithingsMeasurementSensorEntityDescription( + key="diastolic_blood_pressure_mmhg", + measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, + translation_key="diastolic_blood_pressure", + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ) ), MeasurementType.SYSTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( key="systolic_blood_pressure_mmhg", @@ -243,26 +243,32 @@ MEASUREMENT_SENSORS: dict[ translation_key="visceral_fat_index", entity_registry_enabled_default=False, ), - MeasurementType.ELECTRODERMAL_ACTIVITY_FEET: WithingsMeasurementSensorEntityDescription( - key="electrodermal_activity_feet", - measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_FEET, - translation_key="electrodermal_activity_feet", - native_unit_of_measurement=PERCENTAGE, - entity_registry_enabled_default=False, + MeasurementType.ELECTRODERMAL_ACTIVITY_FEET: ( + WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_feet", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_FEET, + translation_key="electrodermal_activity_feet", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ) ), - MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT: WithingsMeasurementSensorEntityDescription( - key="electrodermal_activity_left_foot", - measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT, - translation_key="electrodermal_activity_left_foot", - native_unit_of_measurement=PERCENTAGE, - entity_registry_enabled_default=False, + MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT: ( + WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_left_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT, + translation_key="electrodermal_activity_left_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ) ), - MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: WithingsMeasurementSensorEntityDescription( - key="electrodermal_activity_right_foot", - measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT, - translation_key="electrodermal_activity_right_foot", - native_unit_of_measurement=PERCENTAGE, - entity_registry_enabled_default=False, + MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: ( + WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_right_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT, + translation_key="electrodermal_activity_right_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ) ), } @@ -851,7 +857,7 @@ async def async_setup_entry( config_entry_id ) ) - and config_entry.state == ConfigEntryState.LOADED + and config_entry.state is ConfigEntryState.LOADED for config_entry_id in device.config_entries ): continue @@ -876,7 +882,8 @@ async def async_setup_entry( if not entities: LOGGER.warning( - "No data found for Withings entry %s, sensors will be added when new data is available", + "No data found for Withings entry %s, sensors" + " will be added when new data is available", entry.title, ) diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index f66df15f6b4..52b69963185 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -1,12 +1,9 @@ """WiZ Platform integration.""" -from __future__ import annotations - import logging from typing import Any from pywizlight import PilotParser, wizlight -from pywizlight.bulb import PIR_SOURCE from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback @@ -20,6 +17,7 @@ from .const import ( DISCOVER_SCAN_TIMEOUT, DISCOVERY_INTERVAL, DOMAIN, + OCCUPANCY_SOURCES, SIGNAL_WIZ_PIR, WIZ_CONNECT_EXCEPTIONS, ) @@ -101,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: """Receive a push update.""" _LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult) coordinator.async_set_updated_data(coordinator.data) - if state.get_source() == PIR_SOURCE: + if state.get_source() in OCCUPANCY_SOURCES: async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac)) await bulb.start_push(_async_push_update) diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 9f5e548d552..bcbf6419696 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -1,11 +1,7 @@ """WiZ integration binary sensor platform.""" -from __future__ import annotations - from collections.abc import Callable -from pywizlight.bulb import PIR_SOURCE - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -16,7 +12,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SIGNAL_WIZ_PIR +from .const import DOMAIN, OCCUPANCY_SOURCES, SIGNAL_WIZ_PIR from .coordinator import WizConfigEntry, WizData from .entity import WizEntity @@ -75,5 +71,5 @@ class WizOccupancyEntity(WizEntity, BinarySensorEntity): @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - if self._device.state.get_source() == PIR_SOURCE: + if self._device.state.get_source() in OCCUPANCY_SOURCES: self._attr_is_on = self._device.status diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index a676c77688d..be1a0f38885 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WiZ Platform.""" -from __future__ import annotations - import logging from typing import Any @@ -12,7 +10,7 @@ import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address @@ -23,8 +21,6 @@ from .utils import _short_mac, name_from_bulb_type_and_mac _LOGGER = logging.getLogger(__name__) -CONF_DEVICE = "device" - class WizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for WiZ.""" @@ -81,6 +77,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): exc_info=True, ) raise AbortFlow("cannot_connect") from ex + finally: + await bulb.async_close() self._name = name_from_bulb_type_and_mac(bulbtype, device.mac_address) async def async_step_discovery_confirm( @@ -118,6 +116,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): bulbtype = await bulb.get_bulbtype() except WIZ_CONNECT_EXCEPTIONS: return self.async_abort(reason="cannot_connect") + finally: + await bulb.async_close() return self.async_create_entry( title=name_from_bulb_type_and_mac(bulbtype, device.mac_address), @@ -182,6 +182,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): title=name, data=user_input, ) + finally: + await bulb.async_close() return self.async_show_form( step_id="user", diff --git a/homeassistant/components/wiz/const.py b/homeassistant/components/wiz/const.py index 78074a3d5fb..59cc7788a74 100644 --- a/homeassistant/components/wiz/const.py +++ b/homeassistant/components/wiz/const.py @@ -2,6 +2,7 @@ from datetime import timedelta +from pywizlight.bulb import PIR_SOURCE from pywizlight.exceptions import ( WizLightConnectionError, WizLightNotKnownBulb, @@ -24,3 +25,4 @@ WIZ_EXCEPTIONS = ( WIZ_CONNECT_EXCEPTIONS = (WizLightNotKnownBulb, *WIZ_EXCEPTIONS) SIGNAL_WIZ_PIR = "wiz_pir_{}" +OCCUPANCY_SOURCES = frozenset({PIR_SOURCE, "wfsens"}) diff --git a/homeassistant/components/wiz/coordinator.py b/homeassistant/components/wiz/coordinator.py index 4ff125934a2..b6d2308d2f4 100644 --- a/homeassistant/components/wiz/coordinator.py +++ b/homeassistant/components/wiz/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the WiZ Platform integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/wiz/diagnostics.py b/homeassistant/components/wiz/diagnostics.py index 7aa5940b7ca..d342a797ca4 100644 --- a/homeassistant/components/wiz/diagnostics.py +++ b/homeassistant/components/wiz/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for WiZ.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/wiz/discovery.py b/homeassistant/components/wiz/discovery.py index 118ed20ff87..c51b442b20a 100644 --- a/homeassistant/components/wiz/discovery.py +++ b/homeassistant/components/wiz/discovery.py @@ -1,7 +1,5 @@ """The wiz integration discovery.""" -from __future__ import annotations - import asyncio from dataclasses import asdict import logging diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index 9a32b2a8ad9..a8144355ec4 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -1,7 +1,5 @@ """WiZ integration entities.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py index 888a72f14ec..ae93e4e2ca1 100644 --- a/homeassistant/components/wiz/fan.py +++ b/homeassistant/components/wiz/fan.py @@ -1,7 +1,5 @@ """WiZ integration fan platform.""" -from __future__ import annotations - import math from typing import Any, ClassVar diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 713849514a4..1c63febf4fc 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -1,7 +1,5 @@ """WiZ integration light platform.""" -from __future__ import annotations - from typing import Any from pywizlight import PilotBuilder diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index e9b5125d200..5ac86fccede 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -1,7 +1,5 @@ """Support for WiZ effect speed numbers.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index 1cafa58996c..2953f4ece11 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -1,7 +1,5 @@ """Support for WiZ sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py index 688adc0caa3..b07a79aeca9 100644 --- a/homeassistant/components/wiz/switch.py +++ b/homeassistant/components/wiz/switch.py @@ -1,7 +1,5 @@ """WiZ integration switch platform.""" -from __future__ import annotations - from typing import Any from pywizlight import PilotBuilder diff --git a/homeassistant/components/wiz/utils.py b/homeassistant/components/wiz/utils.py index 4849e0fb22c..67ebe7bb0be 100644 --- a/homeassistant/components/wiz/utils.py +++ b/homeassistant/components/wiz/utils.py @@ -1,7 +1,5 @@ """WiZ utils.""" -from __future__ import annotations - from pywizlight import BulbType from pywizlight.bulblibrary import BulbClass diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 945b68a74cf..35868c072a3 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -1,7 +1,5 @@ """Support for WLED.""" -from __future__ import annotations - import asyncio import logging from typing import TYPE_CHECKING @@ -108,7 +106,8 @@ async def async_migrate_entry( ] if ignored_entries: _LOGGER.info( - "Found %d ignored WLED config entries with the same MAC address, removing them", + "Found %d ignored WLED config entries" + " with the same MAC address, removing them", len(ignored_entries), ) await asyncio.gather( @@ -119,7 +118,9 @@ async def async_migrate_entry( ) if len(duplicate_entries) - len(ignored_entries) > 1: _LOGGER.warning( - "Found multiple WLED config entries with the same MAC address, cannot migrate to version 1.2" + "Found multiple WLED config entries with" + " the same MAC address, cannot migrate" + " to version 1.2" ) return False diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index d208950eefd..565c3a7ad78 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -1,7 +1,5 @@ """Support for WLED button.""" -from __future__ import annotations - from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2ea9b3d4891..e55c71e4bab 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the WLED integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 8d09867a46e..6d768577d95 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -53,7 +53,9 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = ColorMode.COLOR_TEMP, ], LightCapability.RGB_COLOR | LightCapability.COLOR_TEMPERATURE: [ - # Technically this is RGBWW but wled does not support RGBWW colors (with warm and cold white separately) + # Technically this is RGBWW but wled does not + # support RGBWW colors (with warm and cold white + # separately) # but rather RGB + CCT which does not have a direct mapping in HA ColorMode.RGB, ], diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index eb876985c57..ec7e07a1cc1 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for WLED.""" -from __future__ import annotations - from typing import TYPE_CHECKING from wled import ( @@ -83,6 +81,15 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): self.data is not None and len(self.data.state.segments) > 1 ) + @property + def segment_ids(self) -> set[int]: + """Return the set of segment IDs.""" + return { + segment.segment_id + for segment in self.data.state.segments.values() + if segment.segment_id is not None + } + @callback def _use_websocket(self) -> None: """Use WebSocket for updates, instead of polling.""" @@ -90,29 +97,35 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): async def listen() -> None: """Listen for state changes via WebSocket.""" try: - await self.wled.connect() - except WLEDError as err: - self.logger.info(err) + try: + await self.wled.connect() + except WLEDError as err: + self.logger.info(err) + return + + try: + # Stop polling as long as we have a websocket. WS will push + # updates to us + self.update_interval = None + await self.wled.listen(callback=self.async_set_updated_data) + except WLEDConnectionClosedError as err: + self.last_update_success = False + self.logger.info(err) + except WLEDError as err: + self.last_update_success = False + self.async_update_listeners() + self.logger.error(err) + finally: + # Pull data immediately and restart polling + self.update_interval = SCAN_INTERVAL + self.hass.async_create_task(self.async_request_refresh()) + + # Ensure we are disconnected + await self.wled.disconnect() + finally: if self.unsub: self.unsub() self.unsub = None - return - - try: - await self.wled.listen(callback=self.async_set_updated_data) - except WLEDConnectionClosedError as err: - self.last_update_success = False - self.logger.info(err) - except WLEDError as err: - self.last_update_success = False - self.async_update_listeners() - self.logger.error(err) - - # Ensure we are disconnected - await self.wled.disconnect() - if self.unsub: - self.unsub() - self.unsub = None async def close_websocket(_: Event) -> None: """Close WebSocket connection.""" diff --git a/homeassistant/components/wled/diagnostics.py b/homeassistant/components/wled/diagnostics.py index c38953b81b0..de1a7a898d3 100644 --- a/homeassistant/components/wled/diagnostics.py +++ b/homeassistant/components/wled/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for WLED.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 367abf8815a..64921e3258d 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -1,7 +1,5 @@ """Helpers for WLED.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, Concatenate diff --git a/homeassistant/components/wled/icons.json b/homeassistant/components/wled/icons.json index a4e8fa1092a..3cbed36792b 100644 --- a/homeassistant/components/wled/icons.json +++ b/homeassistant/components/wled/icons.json @@ -51,12 +51,24 @@ } }, "switch": { + "freeze": { + "default": "mdi:timer", + "state": { + "on": "mdi:eye" + } + }, "nightlight": { "default": "mdi:weather-night" }, "reverse": { "default": "mdi:swap-horizontal-bold" }, + "segment_freeze": { + "default": "mdi:timer", + "state": { + "on": "mdi:eye" + } + }, "segment_reverse": { "default": "mdi:swap-horizontal-bold" }, diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 244837bab20..701f765ebaa 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -1,7 +1,5 @@ """Support for LED lights.""" -from __future__ import annotations - from functools import partial from typing import Any, cast @@ -18,6 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.group import IntegrationSpecificGroup from .const import ( ATTR_CCT, @@ -63,11 +62,23 @@ class WLEDMainLight(WLEDEntity, LightEntity): _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + group: IntegrationSpecificGroup def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED main light.""" super().__init__(coordinator=coordinator) self._attr_unique_id = coordinator.data.info.mac_address + self.group = IntegrationSpecificGroup(self, []) + self._update_group_member() + + def _update_group_member(self) -> None: + """Update group members based on current segments.""" + segment_unique_ids = [ + f"{self.coordinator.data.info.mac_address}_{segment_id}" + for segment_id in sorted(self.coordinator.segment_ids) + ] + if segment_unique_ids != self.group.member_unique_ids: + self.group.member_unique_ids = segment_unique_ids @property def brightness(self) -> int | None: @@ -106,6 +117,12 @@ class WLEDMainLight(WLEDEntity, LightEntity): on=True, brightness=kwargs.get(ATTR_BRIGHTNESS), transition=transition ) + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._update_group_member() + super()._handle_coordinator_update() + class WLEDSegmentLight(WLEDEntity, LightEntity): """Defines a WLED light based on a segment.""" @@ -188,12 +205,11 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # If this is the one and only segment, calculate brightness based # on the main and segment brightness + segment_brightness = int(state.segments[self._segment].brightness) if not self.coordinator.has_main_light: - return int( - (state.segments[self._segment].brightness * state.brightness) / 255 - ) + return int((segment_brightness * state.brightness) / 255) - return state.segments[self._segment].brightness + return segment_brightness @property def effect_list(self) -> list[str]: @@ -283,11 +299,7 @@ def async_update_segments( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" - segment_ids = { - light.segment_id - for light in coordinator.data.state.segments.values() - if light.segment_id is not None - } + segment_ids = coordinator.segment_ids new_entities: list[WLEDMainLight | WLEDSegmentLight] = [] # More than 1 segment now? No main? Add main controls diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index b14c5df25ef..fe7d16d968f 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.21.0"], + "requirements": ["wled==0.23.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index a91d83a3ee9..8806037875f 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -1,7 +1,5 @@ """Support for LED numbers.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from functools import partial @@ -44,21 +42,24 @@ class WLEDNumberEntityDescription(NumberEntityDescription): """Class describing WLED number entities.""" value_fn: Callable[[Segment], int | None] + segment_translation_key: str NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, translation_key="speed", + segment_translation_key="segment_speed", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=255, - value_fn=lambda segment: segment.speed, + value_fn=lambda segment: int(segment.speed), ), WLEDNumberEntityDescription( key=ATTR_INTENSITY, translation_key="intensity", + segment_translation_key="segment_intensity", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -86,7 +87,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_translation_key = f"segment_{description.translation_key}" + self._attr_translation_key = description.segment_translation_key self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( @@ -129,16 +130,10 @@ def async_update_segments( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" - segment_ids = { - segment.segment_id - for segment in coordinator.data.state.segments.values() - if segment.segment_id is not None - } - new_entities: list[WLEDNumber] = [] # Process new segments, add them to Home Assistant - for segment_id in segment_ids - current_ids: + for segment_id in coordinator.segment_ids - current_ids: current_ids.add(segment_id) new_entities.extend( WLEDNumber(coordinator, segment_id, desc) for desc in NUMBERS diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 759f8fdc3db..a2d21d8e3e7 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -1,7 +1,5 @@ """Support for LED selects.""" -from __future__ import annotations - from functools import partial from wled import LiveDataOverride @@ -210,16 +208,10 @@ def async_update_segments( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" - segment_ids = { - segment.segment_id - for segment in coordinator.data.state.segments.values() - if segment.segment_id is not None - } - new_entities: list[WLEDPaletteSelect] = [] # Process new segments, add them to Home Assistant - for segment_id in segment_ids - current_ids: + for segment_id in coordinator.segment_ids - current_ids: current_ids.add(segment_id) new_entities.append(WLEDPaletteSelect(coordinator, segment_id)) diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 34ee012b682..ab8ae2ac5c0 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -1,7 +1,5 @@ """Support for WLED sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -70,7 +68,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="uptime", translation_key="uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: utcnow() - device.info.uptime, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index aa4303c6709..5a2732c7745 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -98,9 +98,6 @@ "ip": { "name": "IP" }, - "uptime": { - "name": "Uptime" - }, "wifi_bssid": { "name": "Wi-Fi BSSID" }, @@ -115,12 +112,18 @@ } }, "switch": { + "freeze": { + "name": "Freeze" + }, "nightlight": { "name": "Nightlight" }, "reverse": { "name": "Reverse" }, + "segment_freeze": { + "name": "Segment {segment} freeze" + }, "segment_reverse": { "name": "Segment {segment} reverse" }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 1e228b0a91e..155732dad5f 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -1,11 +1,13 @@ """Support for WLED switches.""" -from __future__ import annotations - +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from functools import partial from typing import Any -from homeassistant.components.switch import SwitchEntity +from wled import WLED + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,6 +20,36 @@ from .helpers import wled_exception_handler PARALLEL_UPDATES = 1 +@dataclass(frozen=True, kw_only=True) +class WLEDSegmentSwitchEntityDescription(SwitchEntityDescription): + """Describes WLED segment switch entity.""" + + segment_translation_key: str + set_segment: Callable[[WLED, int, bool], Awaitable[None]] + + +SEGMENT_SWITCHES: tuple[WLEDSegmentSwitchEntityDescription, ...] = ( + WLEDSegmentSwitchEntityDescription( + key="reverse", + translation_key="reverse", + segment_translation_key="segment_reverse", + set_segment=lambda wled, segment, value: wled.segment( + segment_id=segment, + reverse=value, + ), + ), + WLEDSegmentSwitchEntityDescription( + key="freeze", + translation_key="freeze", + segment_translation_key="segment_freeze", + set_segment=lambda wled, segment, value: wled.segment( + segment_id=segment, + freeze=value, + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, @@ -144,25 +176,35 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): await self.coordinator.wled.sync(receive=True) -class WLEDReverseSwitch(WLEDEntity, SwitchEntity): - """Defines a WLED reverse effect switch.""" +class WLEDSegmentSwitch(WLEDEntity, SwitchEntity): + """Defines a WLED segment switch.""" + entity_description: WLEDSegmentSwitchEntityDescription _attr_entity_category = EntityCategory.CONFIG - _attr_translation_key = "reverse" - _segment: int - def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: - """Initialize WLED reverse effect switch.""" + def __init__( + self, + coordinator: WLEDDataUpdateCoordinator, + segment: int, + description: WLEDSegmentSwitchEntityDescription, + ) -> None: + """Initialize WLED segment switch.""" super().__init__(coordinator=coordinator) + self.entity_description = description + self._segment = segment + # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_translation_key = "segment_reverse" + self._attr_translation_key = description.segment_translation_key self._attr_translation_placeholders = {"segment": str(segment)} + else: + self._attr_translation_key = description.translation_key - self._attr_unique_id = f"{coordinator.data.info.mac_address}_reverse_{segment}" - self._segment = segment + self._attr_unique_id = ( + f"{coordinator.data.info.mac_address}_{description.key}_{segment}" + ) @property def available(self) -> bool: @@ -174,17 +216,26 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): @property def is_on(self) -> bool: """Return the state of the switch.""" - return self.coordinator.data.state.segments[self._segment].reverse + segment = self.coordinator.data.state.segments[self._segment] + return bool(getattr(segment, self.entity_description.key)) - @wled_exception_handler - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the WLED reverse effect switch.""" - await self.coordinator.wled.segment(segment_id=self._segment, reverse=False) + async def _async_set_state(self, value: bool) -> None: + """Set segment state.""" + await self.entity_description.set_segment( + self.coordinator.wled, + self._segment, + value, + ) @wled_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the WLED reverse effect switch.""" - await self.coordinator.wled.segment(segment_id=self._segment, reverse=True) + """Turn on the WLED segment switch.""" + await self._async_set_state(True) + + @wled_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the WLED segment switch.""" + await self._async_set_state(False) @callback @@ -194,17 +245,18 @@ def async_update_segments( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" - segment_ids = { - segment.segment_id - for segment in coordinator.data.state.segments.values() - if segment.segment_id is not None - } - - new_entities: list[WLEDReverseSwitch] = [] + new_entities: list[WLEDSegmentSwitch] = [] # Process new segments, add them to Home Assistant - for segment_id in segment_ids - current_ids: + for segment_id in coordinator.segment_ids - current_ids: current_ids.add(segment_id) - new_entities.append(WLEDReverseSwitch(coordinator, segment_id)) + new_entities.extend( + WLEDSegmentSwitch( + coordinator=coordinator, + segment=segment_id, + description=description, + ) + for description in SEGMENT_SWITCHES + ) async_add_entities(new_entities) diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 3948319d1c8..c523872fa40 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -1,7 +1,5 @@ """Support for WLED updates.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.update import ( @@ -114,3 +112,8 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity): version = cast(str, self.latest_version) await self.coordinator.wled.upgrade(version=version) await self.coordinator.async_refresh() + + async def async_update(self) -> None: + """Update the entity.""" + await super().async_update() + await self.releases_coordinator.async_request_refresh() diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index 4091278d06d..de13937b469 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -1,7 +1,5 @@ """The WMS WebControl pro API integration.""" -from __future__ import annotations - import aiohttp from wmspro.webcontrol import WebControlPro diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index 1b2772a9c80..ed058a83570 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -1,7 +1,5 @@ """Identify support for WMS WebControl pro.""" -from __future__ import annotations - from wmspro.const import WMS_WebControl_pro_API_actionDescription from homeassistant.components.button import ButtonDeviceClass, ButtonEntity diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 94deed11c08..16aafcc9791 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WMS WebControl pro API integration.""" -from __future__ import annotations - import ipaddress import logging from typing import Any diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 6aa1fdcd437..88f9ac1fe58 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -1,7 +1,5 @@ """Support for covers connected with WMS WebControl pro.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/wmspro/diagnostics.py b/homeassistant/components/wmspro/diagnostics.py index c35cecc5ab5..311f77a76e2 100644 --- a/homeassistant/components/wmspro/diagnostics.py +++ b/homeassistant/components/wmspro/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for WMS WebControl pro API integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py index 758a89b7ed8..d052804234a 100644 --- a/homeassistant/components/wmspro/entity.py +++ b/homeassistant/components/wmspro/entity.py @@ -1,7 +1,5 @@ """Generic entity for the WMS WebControl pro API integration.""" -from __future__ import annotations - from wmspro.destination import Destination from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 2326734ceaf..ab106241605 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -1,7 +1,5 @@ """Support for lights connected with WMS WebControl pro.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 5faf2c4ed79..bcdc71e2b33 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.3"] + "requirements": ["pywmspro==0.4.0"] } diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py index 7edd7a2b186..19adfd90e6d 100644 --- a/homeassistant/components/wmspro/scene.py +++ b/homeassistant/components/wmspro/scene.py @@ -1,7 +1,5 @@ """Support for scenes provided by WMS WebControl pro.""" -from __future__ import annotations - from typing import Any from wmspro.scene import Scene as WMS_Scene diff --git a/homeassistant/components/wmspro/switch.py b/homeassistant/components/wmspro/switch.py index 0e188aa1f22..87ab72ac975 100644 --- a/homeassistant/components/wmspro/switch.py +++ b/homeassistant/components/wmspro/switch.py @@ -1,7 +1,5 @@ """Support for loads connected with WMS WebControl pro.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 3fb733e650b..3e190953ab7 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -12,22 +12,15 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import create_async_httpx_client -from .const import ( - COORDINATOR, - DEVICE_GATEWAY, - DEVICE_ID, - DEVICE_NAME, - DOMAIN, - PARAMETERS, -) -from .coordinator import WolfLinkCoordinator, fetch_parameters +from .const import DEVICE_GATEWAY, DEVICE_ID, DEVICE_NAME, DOMAIN +from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator, fetch_parameters _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WolflinkConfigEntry) -> bool: """Set up Wolf SmartSet Service from a config entry.""" username = entry.data[CONF_USERNAME] @@ -56,24 +49,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - hass.data[DOMAIN][entry.entry_id][PARAMETERS] = parameters - hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator - hass.data[DOMAIN][entry.entry_id][DEVICE_ID] = device_id + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WolflinkConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -103,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def fetch_parameters_init(client: WolfClient, gateway_id: int, device_id: int): - """Fetch all available parameters with usage of WolfClient but handles all exceptions and results in ConfigEntryNotReady.""" + """Fetch all parameters via WolfClient, raising ConfigEntryNotReady on error.""" try: return await fetch_parameters(client, gateway_id, device_id) except (FetchFailed, RequestError) as exception: diff --git a/homeassistant/components/wolflink/const.py b/homeassistant/components/wolflink/const.py index b752b00790f..7fda87282ba 100644 --- a/homeassistant/components/wolflink/const.py +++ b/homeassistant/components/wolflink/const.py @@ -2,8 +2,6 @@ DOMAIN = "wolflink" -COORDINATOR = "coordinator" -PARAMETERS = "parameters" DEVICE_ID = "device_id" DEVICE_GATEWAY = "device_gateway" DEVICE_NAME = "device_name" diff --git a/homeassistant/components/wolflink/coordinator.py b/homeassistant/components/wolflink/coordinator.py index 24e557a9bf5..59ebbb7075d 100644 --- a/homeassistant/components/wolflink/coordinator.py +++ b/homeassistant/components/wolflink/coordinator.py @@ -16,16 +16,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type WolflinkConfigEntry = ConfigEntry[WolfLinkCoordinator] + class WolfLinkCoordinator(DataUpdateCoordinator[dict[int, tuple[int, str]]]): """Class to manage fetching Wolf SmartSet data.""" - config_entry: ConfigEntry + config_entry: WolflinkConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: WolflinkConfigEntry, wolf_client: WolfClient, parameters: list[Parameter], gateway_id: int, @@ -40,30 +42,30 @@ class WolfLinkCoordinator(DataUpdateCoordinator[dict[int, tuple[int, str]]]): update_interval=timedelta(seconds=60), ) self._wolf_client = wolf_client - self._parameters = parameters + self.parameters = parameters self._gateway_id = gateway_id - self._device_id = device_id + self.device_id = device_id self._refetch_parameters = False async def _async_update_data(self) -> dict[int, tuple[int, str]]: """Update all stored entities for Wolf SmartSet.""" try: if not await self._wolf_client.fetch_system_state_list( - self._device_id, self._gateway_id + self.device_id, self._gateway_id ): self._refetch_parameters = True raise UpdateFailed( "Could not fetch values from server because device is offline." ) if self._refetch_parameters: - self._parameters = await fetch_parameters( - self._wolf_client, self._gateway_id, self._device_id + self.parameters = await fetch_parameters( + self._wolf_client, self._gateway_id, self.device_id ) self._refetch_parameters = False values = { v.value_id: v.value for v in await self._wolf_client.fetch_value( - self._gateway_id, self._device_id, self._parameters + self._gateway_id, self.device_id, self.parameters ) } return { @@ -71,7 +73,7 @@ class WolfLinkCoordinator(DataUpdateCoordinator[dict[int, tuple[int, str]]]): parameter.value_id, values[parameter.value_id], ) - for parameter in self._parameters + for parameter in self.parameters if parameter.value_id in values } except RequestError as exception: @@ -96,7 +98,8 @@ async def fetch_parameters( ) -> list[Parameter]: """Fetch all available parameters with usage of WolfClient. - By default Reglertyp entity is removed because API will not provide value for this parameter. + By default Reglertyp entity is removed because API + will not provide value for this parameter. """ fetched_parameters = await client.fetch_parameters(gateway_id, device_id) return [param for param in fetched_parameters if param.name != "Reglertyp"] diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 0d8e6603602..e85d20e3931 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -1,7 +1,7 @@ { "domain": "wolflink", "name": "Wolf SmartSet Service", - "codeowners": ["@adamkrol93", "@mtielen"], + "codeowners": ["@adamkrol93", "@EnjoyingM"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wolflink", "integration_type": "device", diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 0205ce793ed..02e787e124e 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -1,7 +1,5 @@ """The Wolf SmartSet sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -26,7 +24,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -43,8 +40,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES -from .coordinator import WolfLinkCoordinator +from .const import DOMAIN, MANUFACTURER, STATES +from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator def get_listitem_resolve_state(wolf_object, state): @@ -69,29 +66,34 @@ SENSOR_DESCRIPTIONS = [ key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, Temperature), ), WolflinkSensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, Pressure), ), WolflinkSensorEntityDescription( key="energy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, supported_fn=lambda param: isinstance(param, EnergyParameter), ), WolflinkSensorEntityDescription( key="power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, PowerParameter), ), WolflinkSensorEntityDescription( key="percentage", native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, PercentageParameter), ), WolflinkSensorEntityDescription( @@ -102,20 +104,24 @@ SENSOR_DESCRIPTIONS = [ ), WolflinkSensorEntityDescription( key="hours", + device_class=SensorDeviceClass.DURATION, icon="mdi:clock", native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, supported_fn=lambda param: isinstance(param, HoursParameter), ), WolflinkSensorEntityDescription( key="flow", device_class=SensorDeviceClass.VOLUME_FLOW_RATE, native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, FlowParameter), ), WolflinkSensorEntityDescription( key="frequency", device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, supported_fn=lambda param: isinstance(param, FrequencyParameter), ), WolflinkSensorEntityDescription( @@ -133,17 +139,15 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WolflinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] - device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] + coordinator = config_entry.runtime_data entities: list[WolfLinkSensor] = [ - WolfLinkSensor(coordinator, parameter, device_id, description) - for parameter in parameters + WolfLinkSensor(coordinator, parameter, coordinator.device_id, description) + for parameter in coordinator.parameters for description in SENSOR_DESCRIPTIONS if description.supported_fn(parameter) ] @@ -178,7 +182,7 @@ class WolfLinkSensor(CoordinatorEntity[WolfLinkCoordinator], SensorEntity): @property def native_value(self) -> str | None: - """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" + """Return the state, storing old values for unchanged parameters.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] self.wolf_object.value_id = new_state[0] diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index cbcf12cf31c..4f583425980 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,7 +1,5 @@ """Sensor to indicate whether the current day is a workday.""" -from __future__ import annotations - from datetime import timedelta from typing import cast @@ -71,3 +69,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> """Unload Workday config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: + """Migrate old config entry.""" + + # This means the user has downgraded from a future version + if entry.version > 1: + return False + + if entry.version == 1 and entry.minor_version == 1: + # By keeping name in the data, it's enough to bump the minor version + hass.config_entries.async_update_entry( + entry, + minor_version=2, + ) + + return True diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 69bdd315609..2dc39703a4e 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -1,7 +1,5 @@ """Sensor to indicate whether the current day is a workday.""" -from __future__ import annotations - from datetime import datetime from typing import Final @@ -9,7 +7,6 @@ from holidays import HolidayBase import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -33,7 +30,6 @@ async def async_setup_entry( """Set up the Workday sensor.""" days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] - sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] obj_holidays = entry.runtime_data @@ -53,7 +49,7 @@ async def async_setup_entry( workdays, excludes, days_offset, - sensor_name, + entry.title, entry.entry_id, ) ], diff --git a/homeassistant/components/workday/calendar.py b/homeassistant/components/workday/calendar.py index e631ebb6e6a..880dd311b5e 100644 --- a/homeassistant/components/workday/calendar.py +++ b/homeassistant/components/workday/calendar.py @@ -1,13 +1,10 @@ """Workday Calendar.""" -from __future__ import annotations - from datetime import date, datetime, timedelta from holidays import HolidayBase from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -25,7 +22,6 @@ async def async_setup_entry( """Set up the Holiday Calendar config entry.""" days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] - sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] obj_holidays = entry.runtime_data @@ -36,7 +32,7 @@ async def async_setup_entry( workdays, excludes, days_offset, - sensor_name, + entry.title, entry.entry_id, ) ], diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index f3b139b27c0..3683809967d 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Workday integration.""" -from __future__ import annotations - from functools import partial from typing import Any @@ -14,7 +12,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError @@ -30,7 +28,6 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, - TextSelector, ) from homeassistant.util import dt as dt_util @@ -215,6 +212,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Workday integration.""" VERSION = 1 + MINOR_VERSION = 2 data: dict[str, Any] = {} @@ -243,7 +241,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Optional(CONF_COUNTRY): CountrySelector( CountrySelectorConfig( countries=list(supported_countries), @@ -292,8 +289,14 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.debug("Errors have occurred %s", errors) if not errors: LOGGER.debug("No duplicate, no errors, creating entry") + + name = DEFAULT_NAME + if (country := combined_input.get(CONF_COUNTRY)) is not None: + name += f" {country}" + if (province := combined_input.get(CONF_PROVINCE)) is not None: + name += f" {province}" return self.async_create_entry( - title=combined_input[CONF_NAME], + title=name, data={}, options=combined_input, ) @@ -309,7 +312,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=new_schema, errors=errors, description_placeholders={ - "name": self.data[CONF_NAME], "country": self.data.get(CONF_COUNTRY, "-"), }, ) @@ -376,7 +378,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithReload): data_schema=new_schema, errors=errors, description_placeholders={ - "name": options[CONF_NAME], + "name": self.config_entry.title, "country": options.get(CONF_COUNTRY, "-"), }, ) diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index e8a6656d9e2..fc0f4785f43 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -1,7 +1,5 @@ """Add constants for Workday integration.""" -from __future__ import annotations - import logging from homeassistant.const import WEEKDAYS, Platform diff --git a/homeassistant/components/workday/diagnostics.py b/homeassistant/components/workday/diagnostics.py index 84e5073ca5b..ca432633b18 100644 --- a/homeassistant/components/workday/diagnostics.py +++ b/homeassistant/components/workday/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Workday.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/workday/entity.py b/homeassistant/components/workday/entity.py index c75a4089ed2..fc65ed7904e 100644 --- a/homeassistant/components/workday/entity.py +++ b/homeassistant/components/workday/entity.py @@ -1,7 +1,5 @@ """Base workday entity.""" -from __future__ import annotations - from abc import abstractmethod from datetime import date, datetime, timedelta diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ce93a7e823b..56bf3555246 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.93"] + "requirements": ["holidays==0.98"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index e0fa4c766c5..9c4e452771e 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -1,14 +1,15 @@ """Repairs platform for the Workday integration.""" -from __future__ import annotations - from typing import Any, cast from holidays import list_supported_countries import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant @@ -33,7 +34,7 @@ class CountryFixFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" if self.country: return await self.async_step_province() @@ -41,7 +42,7 @@ class CountryFixFlow(RepairsFlow): async def async_step_country( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the country step of a fix flow.""" if user_input is not None: all_countries = list_supported_countries(include_aliases=False) @@ -75,7 +76,7 @@ class CountryFixFlow(RepairsFlow): async def async_step_province( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the province step of a fix flow.""" if user_input is not None: user_input.setdefault(CONF_PROVINCE, None) @@ -123,13 +124,13 @@ class HolidayFixFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_fix_remove_holiday() async def async_step_fix_remove_holiday( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the options step of a fix flow.""" errors: dict[str, str] = {} if user_input: diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index b0a1869aad1..c21a9726db7 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -31,7 +31,7 @@ "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "workdays": "Select which weekdays to include as possible workdays." }, - "description": "Set additional options for {name} configured for country {country}" + "description": "Set additional options for country {country}" }, "user": { "data": { @@ -183,21 +183,33 @@ "selector": { "category": { "options": { + "albanian": "Albanian", "armed_forces": "Armed forces", + "armenian": "Armenian", "bank": "Bank", + "bosnian": "Bosnian", "catholic": "Catholic", "chinese": "Chinese", "christian": "Christian", + "de_facto": "De facto", "government": "Government", "half_day": "Half day", "hebrew": "Hebrew", "hindu": "Hindu", "islamic": "Islamic", "optional": "Optional", + "orthodox": "Orthodox", + "protestant": "Protestant", "public": "Public", + "roma": "Roma", + "sabian": "Sabian", "school": "School", + "serbian": "Serbian", + "turkish": "Turkish", "unofficial": "Unofficial", - "workday": "Workday" + "vlach": "Vlach", + "workday": "Workday", + "yazidi": "Yazidi" } }, "days": { diff --git a/homeassistant/components/workday/util.py b/homeassistant/components/workday/util.py index b83b56bbaa7..0b997e7ab82 100644 --- a/homeassistant/components/workday/util.py +++ b/homeassistant/components/workday/util.py @@ -1,7 +1,5 @@ """Helpers functions for the Workday component.""" -from __future__ import annotations - from datetime import date, timedelta from functools import partial from typing import TYPE_CHECKING diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py index f248d5de4c6..dbae3b09671 100644 --- a/homeassistant/components/worldclock/config_flow.py +++ b/homeassistant/components/worldclock/config_flow.py @@ -1,14 +1,12 @@ """Config flow for World clock.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast import zoneinfo import voluptuous as vol -from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.const import CONF_TIME_ZONE from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -19,10 +17,9 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, - TextSelector, ) -from .const import CONF_TIME_FORMAT, DEFAULT_NAME, DEFAULT_TIME_STR_FORMAT, DOMAIN +from .const import CONF_TIME_FORMAT, DEFAULT_TIME_STR_FORMAT, DOMAIN TIME_STR_OPTIONS = [ SelectOptionDict( @@ -42,7 +39,6 @@ async def validate_duplicate( ) -> dict[str, Any]: """Validate already existing entry.""" handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 - return user_input @@ -55,7 +51,6 @@ async def get_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: ) return vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_TIME_ZONE): SelectSelector( SelectSelectorConfig( options=get_timezones, mode=SelectSelectorMode.DROPDOWN, sort=True @@ -77,13 +72,13 @@ DATA_SCHEMA_OPTIONS = vol.Schema( } ) - CONFIG_FLOW = { "user": SchemaFlowFormStep( schema=get_schema, validate_user_input=validate_duplicate, ), } + OPTIONS_FLOW = { "init": SchemaFlowFormStep( DATA_SCHEMA_OPTIONS, @@ -101,4 +96,4 @@ class WorldclockConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return cast(str, options[CONF_NAME]) + return cast(str, options[CONF_TIME_ZONE]) diff --git a/homeassistant/components/worldclock/const.py b/homeassistant/components/worldclock/const.py index fafa3dbc52f..0964b960c0f 100644 --- a/homeassistant/components/worldclock/const.py +++ b/homeassistant/components/worldclock/const.py @@ -4,8 +4,5 @@ from homeassistant.const import Platform DOMAIN = "worldclock" PLATFORMS = [Platform.SENSOR] - CONF_TIME_FORMAT = "time_format" - -DEFAULT_NAME = "Worldclock Sensor" DEFAULT_TIME_STR_FORMAT = "%H:%M" diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 9b52993919c..3bc2746fdf3 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -1,12 +1,10 @@ """Support for showing the time in a different time zone.""" -from __future__ import annotations - from datetime import tzinfo from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.const import CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,7 +24,7 @@ async def async_setup_entry( [ WorldClockSensor( time_zone, - entry.options[CONF_NAME], + entry.title, entry.options[CONF_TIME_FORMAT], entry.entry_id, ) diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index b38b3d4f602..67f3b5ee9bd 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -1,7 +1,5 @@ """Support for the worldtides.info API.""" -from __future__ import annotations - from datetime import timedelta import logging import time diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 2b10ed38632..be585aecdee 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -1,7 +1,5 @@ """Support for Worx Landroid mower.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 23a27adeb69..f1e50edcc59 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -1,19 +1,16 @@ """The Soundavo WS66i 6-Zone Amplifier integration.""" -from __future__ import annotations - import logging from pyws66i import WS66i, get_ws66i -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SOURCES, DOMAIN +from .const import CONF_SOURCES from .coordinator import Ws66iDataUpdateCoordinator -from .models import SourceRep, Ws66iData +from .models import SourceRep, Ws66iConfigEntry, Ws66iData _LOGGER = logging.getLogger(__name__) @@ -56,7 +53,7 @@ def _find_zones(hass: HomeAssistant, ws66i: WS66i) -> list[int]: return zone_list -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Ws66iConfigEntry) -> bool: """Set up Soundavo WS66i 6-Zone Amplifier from a config entry.""" # Get the source names from the options flow options: dict[str, dict[str, str]] @@ -86,8 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data, retry on failed poll await coordinator.async_config_entry_first_refresh() - # Create the Ws66iData data class save it to hass - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Ws66iData( + entry.runtime_data = Ws66iData( host_ip=entry.data[CONF_IP_ADDRESS], device=ws66i, sources=source_rep, @@ -109,12 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Ws66iConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - ws66i: WS66i = hass.data[DOMAIN][entry.entry_id].device - ws66i.close() - hass.data[DOMAIN].pop(entry.entry_id) + entry.runtime_data.device.close() return unload_ok diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index e70dbd4e8d7..83d7b576985 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -1,7 +1,5 @@ """Config flow for WS66i 6-Zone Amplifier integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index 1b2b43963fc..45a248bec18 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for WS66i.""" -from __future__ import annotations - import logging from pyws66i import WS66i, ZoneStatus diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index 36b199a1c9c..9d62ea2f94c 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -7,7 +7,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -15,18 +14,18 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MAX_VOL from .coordinator import Ws66iDataUpdateCoordinator -from .models import Ws66iData +from .models import Ws66iConfigEntry, Ws66iData PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: Ws66iConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WS66i 6-zone amplifier platform from a config entry.""" - ws66i_data: Ws66iData = hass.data[DOMAIN][config_entry.entry_id] + ws66i_data = config_entry.runtime_data # Build and add the entities from the data class async_add_entities( diff --git a/homeassistant/components/ws66i/models.py b/homeassistant/components/ws66i/models.py index 3c46d071790..d1ed17714aa 100644 --- a/homeassistant/components/ws66i/models.py +++ b/homeassistant/components/ws66i/models.py @@ -1,11 +1,11 @@ """The ws66i integration models.""" -from __future__ import annotations - from dataclasses import dataclass from pyws66i import WS66i +from homeassistant.config_entries import ConfigEntry + from .coordinator import Ws66iDataUpdateCoordinator @@ -27,3 +27,6 @@ class Ws66iData: sources: SourceRep coordinator: Ws66iDataUpdateCoordinator zones: list[int] + + +type Ws66iConfigEntry = ConfigEntry[Ws66iData] diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index d5a1e102c5b..8d11c3eb166 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -1,7 +1,5 @@ """Support for Washington State Department of Transportation (WSDOT) data.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index b32d6e82f81..09e95e486e3 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -1,10 +1,7 @@ """The Wyoming integration.""" -from __future__ import annotations - import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -14,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .devices import SatelliteDevice -from .models import DomainDataItem +from .models import DomainDataItem, WyomingConfigEntry from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) @@ -42,7 +39,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WyomingConfigEntry) -> bool: """Load Wyoming.""" service = await WyomingService.create(entry.data["host"], entry.data["port"]) @@ -50,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Unable to connect") item = DomainDataItem(service=service) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item + entry.runtime_data = item await hass.config_entries.async_forward_entry_setups(entry, service.platforms) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -79,21 +76,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def update_listener(hass: HomeAssistant, entry: WyomingConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WyomingConfigEntry) -> bool: """Unload Wyoming.""" - item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] + item = entry.runtime_data platforms = list(item.service.platforms) if item.device is not None: platforms += SATELLITE_PLATFORMS - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, platforms) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index d9ae7ab875c..c2edf1ed8f6 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -1,7 +1,5 @@ """Assist satellite entity for Wyoming integration.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator import io @@ -34,16 +32,15 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntityDescription, AssistSatelliteEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.ulid import ulid_now -from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH +from .const import SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService from .devices import SatelliteDevice from .entity import WyomingSatelliteEntity -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -68,11 +65,11 @@ _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming Assist satellite entity.""" - domain_data: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data assert domain_data.device is not None async_add_entities( @@ -97,7 +94,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): hass: HomeAssistant, service: WyomingService, device: SatelliteDevice, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, ) -> None: """Initialize an Assist satellite.""" WyomingSatelliteEntity.__init__(self, device) @@ -143,7 +140,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): @property def vad_sensitivity_entity_id(self) -> str | None: - """Return the entity ID of the VAD sensitivity to use for the next conversation.""" + """Return the VAD sensitivity entity ID for next conversation.""" return self.device.get_vad_sensitivity_entity_id(self.hass) @property @@ -181,22 +178,27 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): def on_pipeline_event(self, event: PipelineEvent) -> None: """Set state based on pipeline stage.""" - assert self._client is not None - - if event.type == assist_pipeline.PipelineEventType.RUN_START: - if event.data and (tts_output := event.data["tts_output"]): - # Get stream token early. - # If "tts_start_streaming" is True in INTENT_PROGRESS event, we - # can start streaming TTS before the TTS_END event. - self._tts_stream_token = tts_output["token"] - self._is_tts_streaming = False - elif event.type == assist_pipeline.PipelineEventType.RUN_END: - # Pipeline run is complete + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete — always update bookkeeping state + # even after a disconnect so follow-up reconnects don't retain + # stale _is_pipeline_running / _pipeline_ended_event state. self._is_pipeline_running = False self._pipeline_ended_event.set() self.device.set_is_active(False) self._tts_stream_token = None self._is_tts_streaming = False + + if self._client is None: + # Satellite disconnected, don't try to write to the client + return + + if event.type == assist_pipeline.PipelineEventType.RUN_START: + if event.data and (tts_output := event.data.get("tts_output")): + # Get stream token early. + # If "tts_start_streaming" is True in INTENT_PROGRESS event, we + # can start streaming TTS before the TTS_END event. + self._tts_stream_token = tts_output["token"] + self._is_tts_streaming = False elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.config_entry.async_create_background_task( self.hass, @@ -297,7 +299,8 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): and not self._is_tts_streaming and (stream := tts.async_get_stream(self.hass, tts_output["token"])) ): - # Send TTS only if we haven't already started streaming it in INTENT_PROGRESS. + # Send TTS only if we haven't already started + # streaming it in INTENT_PROGRESS. self.config_entry.async_create_background_task( self.hass, self._stream_tts(stream), @@ -321,7 +324,8 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): Should block until the announcement is done playing. """ - assert self._client is not None + if self._client is None: + raise ConnectionError("Satellite is not connected") if self._ffmpeg_manager is None: self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) @@ -441,6 +445,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Stop any existing pipeline self._audio_queue.put_nowait(None) + # Cancel any pipeline still running so its background + # tasks and audio buffers can be released instead of + # being orphaned across the reconnect. + await self._cancel_running_pipeline() + # Ensure sensor is off (before restart) self.device.set_is_active(False) @@ -449,6 +458,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): finally: unregister_timer_handler() + # Cancel any pipeline still running on final teardown. + await self._cancel_running_pipeline() + # Ensure sensor is off (before stop) self.device.set_is_active(False) @@ -456,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" - _LOGGER.warning( + _LOGGER.debug( "Satellite has been disconnected. Reconnecting in %s second(s)", _RECONNECT_SECONDS, ) @@ -699,10 +711,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): async def _send_delayed_ping(self) -> None: """Send ping to satellite after a delay.""" - assert self._client is not None - try: await asyncio.sleep(_PING_SEND_DELAY) + if self._client is None: + return await self._client.write_event(Ping().event()) except ConnectionError: pass # handled with timeout @@ -728,7 +740,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): async def _stream_tts(self, tts_result: tts.ResultStream) -> None: """Stream TTS WAV audio to satellite in chunks.""" - assert self._client is not None + client = self._client + if client is None: + # Satellite disconnected, cannot stream + return if tts_result.extension != "wav": raise ValueError( @@ -760,7 +775,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): sample_rate, sample_width, sample_channels, data_chunk = ( audio_info ) - await self._client.write_event( + await client.write_event( AudioStart( rate=sample_rate, width=sample_width, @@ -794,12 +809,12 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): timestamp=timestamp, ) - await self._client.write_event(audio_chunk.event()) + await client.write_event(audio_chunk.event()) timestamp += audio_chunk.milliseconds total_seconds += audio_chunk.seconds data_chunk_idx += _AUDIO_CHUNK_BYTES - await self._client.write_event(AudioStop(timestamp=timestamp).event()) + await client.write_event(AudioStop(timestamp=timestamp).event()) _LOGGER.debug("TTS streaming complete") finally: send_duration = time.monotonic() - start_time @@ -840,7 +855,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self, event_type: intent.TimerEventType, timer: intent.TimerInfo ) -> None: """Forward timer events to satellite.""" - assert self._client is not None + if self._client is None: + # Satellite disconnected, drop timer event + return _LOGGER.debug("Timer event: type=%s, info=%s", event_type, timer) event: Event | None = None diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py index a3652e7f70f..be0dcb54057 100644 --- a/homeassistant/components/wyoming/binary_sensor.py +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -1,31 +1,23 @@ """Binary sensor for Wyoming.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WyomingSatelliteEntity - -if TYPE_CHECKING: - from .models import DomainDataItem +from .models import WyomingConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data # Setup is only forwarded for satellites assert item.device is not None diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index 2fa73b430dd..c6270c6200d 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Wyoming integration.""" -from __future__ import annotations - import logging from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 70d0ddc3bb6..aa93ab0122e 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -1,37 +1,38 @@ """Support for Wyoming intent recognition services.""" +import asyncio import logging -from typing import Literal +from typing import Any, Literal from wyoming.asr import Transcript from wyoming.client import AsyncTcpClient from wyoming.handle import Handled, NotHandled from wyoming.info import HandleProgram, IntentProgram -from wyoming.intent import Intent, NotRecognized +from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import chat_session, intent, llm, template from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import ulid as ulid_util from .const import DOMAIN from .data import WyomingService from .error import WyomingError -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming conversation.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data async_add_entities( [ WyomingConversationEntity(config_entry, item.service), @@ -48,7 +49,7 @@ class WyomingConversationEntity( def __init__( self, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" @@ -86,6 +87,10 @@ class WyomingConversationEntity( model_languages.update(handle_model.languages) self._attr_name = self._handle_service.name + if self._handle_service.supports_home_control: + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) self._supported_languages = list(model_languages) self._attr_unique_id = f"{config_entry.entry_id}-conversation" @@ -106,6 +111,8 @@ class WyomingConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) context = {"conversation_id": conversation_id} + if user_input.device_id: + context["device_id"] = user_input.device_id if user_input.satellite_id: context["satellite_id"] = user_input.satellite_id @@ -118,68 +125,17 @@ class WyomingConversationEntity( language=user_input.language, ).event() ) - - while True: - event = await client.read_event() - if event is None: - _LOGGER.debug("Connection lost") - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Connection to service was lost", - ) - return conversation.ConversationResult( - response=intent_response, - conversation_id=user_input.conversation_id, - ) - - if Intent.is_type(event.type): - # Success - recognized_intent = Intent.from_event(event) - _LOGGER.debug("Recognized intent: %s", recognized_intent) - - intent_type = recognized_intent.name - intent_slots = { - e.name: {"value": e.value} - for e in recognized_intent.entities - } - intent_response = await intent.async_handle( - self.hass, - DOMAIN, - intent_type, - intent_slots, - text_input=user_input.text, - language=user_input.language, - satellite_id=user_input.satellite_id, - device_id=user_input.device_id, - ) - - if (not intent_response.speech) and recognized_intent.text: - intent_response.async_set_speech(recognized_intent.text) - - break - - if NotRecognized.is_type(event.type): - not_recognized = NotRecognized.from_event(event) - intent_response.async_set_error( - intent.IntentResponseErrorCode.NO_INTENT_MATCH, - not_recognized.text or "", - ) - break - - if Handled.is_type(event.type): - # Success - handled = Handled.from_event(event) - intent_response.async_set_speech(handled.text or "") - break - - if NotHandled.is_type(event.type): - not_handled = NotHandled.from_event(event) - intent_response.async_set_error( - intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - not_handled.text or "", - ) - break - + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log( + self.hass, session, user_input + ) as chat_log, + ): + intent_response = await self._async_process( + user_input, client, chat_log, intent_response + ) except (OSError, WyomingError) as err: _LOGGER.exception("Unexpected error while communicating with service") intent_response.async_set_error( @@ -205,3 +161,218 @@ class WyomingConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) + + async def _async_process( + self, + user_input: conversation.ConversationInput, + client: AsyncTcpClient, + chat_log: conversation.ChatLog, + intent_response: intent.IntentResponse, + ) -> intent.IntentResponse: + """Process a sentence into an intent response.""" + has_intents_list = False + intents: list[Intent] = [] + + while True: + event = await client.read_event() + if event is None: + raise WyomingError("Connection lost") + + if IntentsStart.is_type(event.type): + # Multiple intents may be present + has_intents_list = True + continue + + if Intent.is_type(event.type): + intents.append(Intent.from_event(event)) + if not has_intents_list: + # Only one intent, no need to wait + break + + if IntentsStop.is_type(event.type): + # End of intents list + break + + if NotRecognized.is_type(event.type): + # Intent was not recognized + not_recognized = NotRecognized.from_event(event) + intent_response.async_set_error( + intent.IntentResponseErrorCode.NO_INTENT_MATCH, + not_recognized.text or "", + ) + + # Don't process any intents if one was not recognized + intents.clear() + break + + if Handled.is_type(event.type): + # Success + handled = Handled.from_event(event) + intent_response.async_set_speech(handled.text or "") + break + + if NotHandled.is_type(event.type): + # Command was not handled + not_handled = NotHandled.from_event(event) + intent_response.async_set_error( + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + not_handled.text or "", + ) + break + + if not intents: + return intent_response + + # Process recognized intents with a task group. + # If any intent fails to be handled, the rest are cancelled. + intent_responses: list[intent.IntentResponse] = [] + try: + async with asyncio.TaskGroup() as task_group: + intent_tasks: list[tuple[str, dict, str | None, asyncio.Task]] = [] + for recognized_intent in intents: + _LOGGER.debug("Handling intent: %s", recognized_intent) + + intent_type = recognized_intent.name + intent_slots = { + e.name: {"value": e.value} for e in recognized_intent.entities + } + + # Add to trace + conversation.async_conversation_trace_append( + conversation.ConversationTraceEventType.TOOL_CALL, + { + "intent_name": intent_type, + "slots": intent_slots, + }, + ) + intent_tasks.append( + ( + intent_type, + intent_slots, + recognized_intent.text, + task_group.create_task( + intent.async_handle( + self.hass, + DOMAIN, + intent_type, + intent_slots, + text_input=user_input.text, + language=user_input.language, + satellite_id=user_input.satellite_id, + device_id=user_input.device_id, + ) + ), + ) + ) + + except* intent.IntentError as err_group: + # Bubble up first exception only. + # There's nothing the caller can do with multiple intent errors. + raise err_group.exceptions[0] from err_group + + # Gather intent handling results + tool_calls: list[llm.ToolInput] = [] + for intent_type, intent_slots, intent_text, intent_task in intent_tasks: + intent_task_response = await intent_task + intent_responses.append(intent_task_response) + + # For the chat log + tool_calls.append( + llm.ToolInput( + tool_name=intent_type, + tool_args=intent_slots, + external=True, + ) + ) + + # Process speech + if (not intent_task_response.speech) and intent_text: + if template.is_template_string(intent_text): + # Render text as a template + intent_text = self._render_speech_template( + intent_text, intent_task_response, intent_slots + ) + + intent_task_response.async_set_speech(intent_text) + + # Add all tool calls to the chat log + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=None, + tool_calls=tool_calls, + ) + ) + + # Must be the case because an exception would have been thrown otherwise + assert intent_responses + + # Use the properties of the first intent (response_type, etc.) and + # combine the speech results. + intent_response = intent_responses[0] + speech_texts: list[str] = [ + speech + for current_response in intent_responses + if (speech := current_response.speech.get("plain", {}).get("speech")) + ] + + if speech_texts: + # Combine response with newlines because punctuation would be + # language-dependent. + intent_response.async_set_speech("\n".join(speech_texts)) + + return intent_response + + def _render_speech_template( + self, + response_text: str, + intent_response: intent.IntentResponse, + intent_slots: dict[str, Any], + ) -> str: + """Render speech template with similar behavior to the default agent.""" + state1: State | None = None + if intent_response.matched_states: + state1 = intent_response.matched_states[0] + elif intent_response.unmatched_states: + state1 = intent_response.unmatched_states[0] + + # Render response template + speech_slots = {name: value["value"] for name, value in intent_slots.items()} + speech_slots.update(intent_response.speech_slots) + + response_template = template.Template(response_text, self.hass) + try: + speech = response_template.async_render( + { + # Slots from intent recognizer and response + "slots": speech_slots, + # First matched or unmatched state + "state": ( + template.TemplateState(self.hass, state1) + if state1 is not None + else None + ), + "query": { + # Entity states that matched the query (e.g, "on") + "matched": [ + template.TemplateState(self.hass, state) + for state in intent_response.matched_states + ], + # Entity states that did not match the query + "unmatched": [ + template.TemplateState(self.hass, state) + for state in intent_response.unmatched_states + ], + }, + } + ) + except TemplateError: + _LOGGER.exception("Unexpected error while rendering response") + raise + + # Normalize whitespace + if speech is not None: + speech = str(speech) + speech = " ".join(speech.strip().split()) + + return speech diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 5925e976421..d314e14d0e7 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -1,7 +1,5 @@ """Base class for Wyoming providers.""" -from __future__ import annotations - import asyncio from wyoming.client import AsyncTcpClient diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index dec5d066f4d..9480b45b09e 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -1,7 +1,5 @@ """Class to manage satellite devices.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py index 1ce105fb860..b6acbd0726a 100644 --- a/homeassistant/components/wyoming/entity.py +++ b/homeassistant/components/wyoming/entity.py @@ -1,7 +1,5 @@ """Wyoming entities.""" -from __future__ import annotations - from homeassistant.helpers import entity from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 628a3e4d147..82a3bb570b6 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.7.2"], + "requirements": ["wyoming==1.9.0"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py index b819d06f916..f41ad9469d8 100644 --- a/homeassistant/components/wyoming/models.py +++ b/homeassistant/components/wyoming/models.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +from homeassistant.config_entries import ConfigEntry + from .data import WyomingService from .devices import SatelliteDevice @@ -12,3 +14,6 @@ class DomainDataItem: service: WyomingService device: SatelliteDevice | None = None + + +type WyomingConfigEntry = ConfigEntry[DomainDataItem] diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py index 96ec5877545..a5dc53351aa 100644 --- a/homeassistant/components/wyoming/number.py +++ b/homeassistant/components/wyoming/number.py @@ -1,20 +1,14 @@ """Number entities for Wyoming integration.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Final +from typing import Final from homeassistant.components.number import NumberEntityDescription, RestoreNumber -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WyomingSatelliteEntity - -if TYPE_CHECKING: - from .models import DomainDataItem +from .models import WyomingConfigEntry _MAX_AUTO_GAIN: Final = 31 _MIN_VOLUME_MULTIPLIER: Final = 0.1 @@ -23,11 +17,11 @@ _MAX_VOLUME_MULTIPLIER: Final = 10.0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming number entities.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data # Setup is only forwarded for satellites assert item.device is not None diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index b3af22a4c16..68595d16b53 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -1,8 +1,6 @@ """Select entities for Wyoming integration.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Final +from typing import Final from homeassistant.components.assist_pipeline import ( AssistPipelineSelect, @@ -10,7 +8,6 @@ from homeassistant.components.assist_pipeline import ( VadSensitivitySelect, ) from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state @@ -19,9 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import SatelliteDevice from .entity import WyomingSatelliteEntity - -if TYPE_CHECKING: - from .models import DomainDataItem +from .models import WyomingConfigEntry _NOISE_SUPPRESSION_LEVEL: Final = { "off": 0, @@ -35,11 +30,11 @@ _DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming select entities.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data # Setup is only forwarded for satellites assert item.device is not None diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index bc2fec2db2f..3b86fa6de09 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -8,25 +8,24 @@ from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from homeassistant.components import stt -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH +from .const import SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data async_add_entities( [ WyomingSttProvider(config_entry, item.service), @@ -39,7 +38,7 @@ class WyomingSttProvider(stt.SpeechToTextEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 9eb91d5ef39..24a02563f68 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -1,30 +1,24 @@ """Wyoming switch entities.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import WyomingSatelliteEntity - -if TYPE_CHECKING: - from .models import DomainDataItem +from .models import WyomingConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data # Setup is only forwarded for satellites assert item.device is not None diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 79b98fed728..5c03a8aaa79 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -18,25 +18,24 @@ from wyoming.tts import ( ) from homeassistant.components import tts -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_SPEAKER, DOMAIN +from .const import ATTR_SPEAKER from .data import WyomingService from .error import WyomingError -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data async_add_entities( [ WyomingTtsProvider(config_entry, item.service), @@ -52,7 +51,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 25ab2f43a01..29027593ded 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -9,25 +9,23 @@ from wyoming.client import AsyncTcpClient from wyoming.wake import Detect, Detection from homeassistant.components import wake_word -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + item = config_entry.runtime_data async_add_entities( [ WyomingWakeWordProvider(hass, config_entry, item.service), @@ -41,7 +39,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py index 613238c302a..66fb2e1eafa 100644 --- a/homeassistant/components/wyoming/websocket_api.py +++ b/homeassistant/components/wyoming/websocket_api.py @@ -9,7 +9,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from .const import DOMAIN -from .models import DomainDataItem +from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -29,14 +29,14 @@ def websocket_info( msg: dict[str, Any], ) -> None: """List service information for Wyoming all config entries.""" - entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {}) + entries: list[WyomingConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) connection.send_result( msg["id"], { "info": { - entry_id: item.service.info.to_dict() - for entry_id, item in entry_items.items() + entry.entry_id: entry.runtime_data.service.info.to_dict() + for entry in entries } }, ) diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 035b306888c..9813347a0fa 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -1,7 +1,5 @@ """Support for X10 lights.""" -from __future__ import annotations - import logging from subprocess import STDOUT, CalledProcessError, check_output from typing import Any @@ -83,7 +81,8 @@ class X10Light(LightEntity): """Instruct the light to turn on.""" old_brightness = self._attr_brightness if old_brightness == 0: - # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate + # Dim down from max if applicable, also avoids + # a "dim" command if an "on" is more appropriate old_brightness = 255 self._attr_brightness = kwargs.get(ATTR_BRIGHTNESS, 255) brightness_diff = self.normalize_x10_brightness( diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index f9f06b503d7..76e10ba5d38 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -1,7 +1,5 @@ """The xbox integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 535dfe97689..6ad6c7a8f57 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -1,7 +1,5 @@ """Xbox friends binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index 595dc965eb8..b761042b042 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -1,7 +1,5 @@ """Support for media browsing.""" -from __future__ import annotations - from typing import TYPE_CHECKING, NamedTuple from pythonxbox.api.client import XboxLiveClient diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 15605555920..83d6403a9b1 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -83,9 +83,7 @@ class OAuth2FlowHandler( description_placeholders={"gamertag": me.people[0].gamertag} ) - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data - ) + return self.async_update_and_abort(self._get_reauth_entry(), data=data) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index fa0c3eec595..3a0952118c0 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the xbox integration.""" -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -258,8 +256,10 @@ class XboxPresenceCoordinator(XboxBaseCoordinator[XboxData]): def last_seen_timestamp(self, person: Person) -> datetime | None: """Returns the most recent of two timestamps.""" - # The Xbox API constantly fluctuates the "last seen" timestamp between two close values, - # causing unnecessary updates. We only accept the most recent one as valild to prevent this. + # The Xbox API constantly fluctuates the "last seen" + # timestamp between two close values, causing + # unnecessary updates. We only accept the most + # recent one as valid to prevent this. prev_dt = ( prev_data.last_seen_date_time_utc diff --git a/homeassistant/components/xbox/diagnostics.py b/homeassistant/components/xbox/diagnostics.py index befc48c0533..b4c0be0a7b6 100644 --- a/homeassistant/components/xbox/diagnostics.py +++ b/homeassistant/components/xbox/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics platform for the Xbox integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index 1a6fd1b86be..8b71046a13d 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -1,7 +1,5 @@ """Base Sensor for the Xbox Integration.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/xbox/image.py b/homeassistant/components/xbox/image.py index 2cbb957e949..6775873b9fd 100644 --- a/homeassistant/components/xbox/image.py +++ b/homeassistant/components/xbox/image.py @@ -1,7 +1,5 @@ """Image platform for the Xbox integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 7be5e252ea5..cae0c031f42 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -1,7 +1,7 @@ { "domain": "xbox", "name": "Xbox", - "codeowners": ["@hunterjm", "@tr4nt0r"], + "codeowners": ["@tr4nt0r"], "config_flow": true, "dependencies": ["application_credentials"], "dhcp": [ diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index dcb68dba9ae..0d4b606a6a4 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -1,7 +1,5 @@ """Xbox Media Player Support.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from http import HTTPStatus diff --git a/homeassistant/components/xbox/media_source.py b/homeassistant/components/xbox/media_source.py index 3ab2a40809e..ef9b6a3b3a3 100644 --- a/homeassistant/components/xbox/media_source.py +++ b/homeassistant/components/xbox/media_source.py @@ -1,7 +1,5 @@ """Xbox Media Source Implementation.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -161,9 +159,10 @@ class XboxSource(MediaSource): ) ) else: - screenshot_response = await client.screenshots.get_recent_community_screenshots_by_title_id( - identifier.title_id + get_community = ( + client.screenshots.get_recent_community_screenshots_by_title_id ) + screenshot_response = await get_community(identifier.title_id) except TimeoutException as e: raise Unresolvable( translation_domain=DOMAIN, @@ -423,7 +422,10 @@ class XboxSource(MediaSource): identifier=str(identifier), media_class=MEDIA_CLASS_MAP[identifier.media_type], media_content_type=MediaClass.DIRECTORY, - title=f"Xbox / {entry.title} / {game.name} / {MAP_TITLE[identifier.media_type]}", + title=( + f"Xbox / {entry.title} / {game.name}" + f" / {MAP_TITLE[identifier.media_type]}" + ), can_play=False, can_expand=True, children=[ @@ -559,7 +561,8 @@ class XboxSource(MediaSource): title=( f"{screenshot.user_caption}" f"{' | ' if screenshot.user_caption else ''}" - f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p" + f"{dt_util.get_age(screenshot.date_taken)}" + f" | {screenshot.resolution_height}p" ), can_play=True, can_expand=False, @@ -603,7 +606,8 @@ class XboxSource(MediaSource): title=( f"{screenshot.user_caption}" f"{' | ' if screenshot.user_caption else ''}" - f"{dt_util.get_age(screenshot.date_taken)} | {screenshot.resolution_height}p" + f"{dt_util.get_age(screenshot.date_taken)}" + f" | {screenshot.resolution_height}p" ), can_play=True, can_expand=False, diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 5efa8f24a8f..135f3da4ff3 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -1,7 +1,5 @@ """Xbox Remote support.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from functools import wraps diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index e192f11c3bd..1bb13cb0e71 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Xbox integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -154,7 +152,8 @@ def now_playing_attributes(person: Person, title: Title | None) -> dict[str, Any attributes.update( { "achievements": ( - f"{achievement.current_achievements} / {achievement.total_achievements}" + f"{achievement.current_achievements}" + f" / {achievement.total_achievements}" ), "gamerscore": ( f"{achievement.current_gamerscore} / {achievement.total_gamerscore}" diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index 0c19e126fa7..6fe1b8fe225 100644 --- a/homeassistant/components/xeoma/camera.py +++ b/homeassistant/components/xeoma/camera.py @@ -1,7 +1,5 @@ """Support for Xeoma Cameras.""" -from __future__ import annotations - import logging from pyxeoma.xeoma import Xeoma, XeomaError @@ -133,6 +131,7 @@ class XeomaCamera(Camera): self._image, self._username, self._password ) self._last_image = image + # pylint: disable-next=home-assistant-action-swallowed-exception except XeomaError as err: _LOGGER.error("Error fetching image: %s", err.message) diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index cb8d5f39dec..ea27b526402 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,7 +1,5 @@ """Component providing support for Xiaomi Cameras.""" -from __future__ import annotations - from ftplib import FTP, error_perm import logging @@ -157,6 +155,7 @@ class XiaomiCamera(Camera): try: host = self.host.async_render(parse_result=False) + # pylint: disable-next=home-assistant-action-swallowed-exception except TemplateError as exc: _LOGGER.error("Error parsing template %s: %s", self.host, exc) return self._last_image diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 5968a17f418..5e271451db4 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -1,7 +1,5 @@ """Support for Xiaomi Mi routers.""" -from __future__ import annotations - from http import HTTPStatus import logging diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 6e4d143d84e..3170149a5cd 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -1,4 +1,5 @@ """Support for Xiaomi Gateways.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import logging @@ -26,12 +27,13 @@ from .const import ( CONF_SID, DEFAULT_DISCOVERY_RETRY, DOMAIN, - GATEWAYS_KEY, KEY_SETUP_LOCK, KEY_UNSUB_STOP, LISTENER_KEY, ) +type XiaomiAqaraConfigEntry = ConfigEntry[XiaomiGateway] + _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = [ @@ -137,11 +139,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: XiaomiAqaraConfigEntry) -> bool: """Set up the xiaomi aqara components from a config entry.""" hass.data.setdefault(DOMAIN, {}) setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock()) - hass.data[DOMAIN].setdefault(GATEWAYS_KEY, {}) # Connect to Xiaomi Aqara Gateway xiaomi_gateway = await hass.async_add_executor_job( @@ -154,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PORT], entry.data[CONF_PROTOCOL], ) - hass.data[DOMAIN][GATEWAYS_KEY][entry.entry_id] = xiaomi_gateway + entry.runtime_data = xiaomi_gateway async with setup_lock: if LISTENER_KEY not in hass.data[DOMAIN]: @@ -203,7 +204,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: XiaomiAqaraConfigEntry +) -> bool: """Unload a config entry.""" if config_entry.data[CONF_KEY] is not None: platforms = GATEWAY_PLATFORMS @@ -213,14 +216,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> unload_ok = await hass.config_entries.async_unload_platforms( config_entry, platforms ) - if unload_ok: - hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # No gateways left, stop Xiaomi socket unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() - hass.data[DOMAIN].pop(GATEWAYS_KEY) _LOGGER.debug("Shutting down Xiaomi Gateway Listener") multicast = hass.data[DOMAIN].pop(LISTENER_KEY) multicast.stop_listen() @@ -228,25 +228,27 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -def _add_gateway_to_schema(hass, schema): +def _add_gateway_to_schema(hass: HomeAssistant, schema: vol.Schema) -> vol.Schema: """Extend a voluptuous schema with a gateway validator.""" - def gateway(sid): + def gateway(sid: str) -> XiaomiGateway: """Convert sid to a gateway.""" sid = str(sid).replace(":", "").lower() - for gateway in hass.data[DOMAIN][GATEWAYS_KEY].values(): - if gateway.sid == sid: - return gateway + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + entry_gateway = entry.runtime_data + if entry_gateway.sid == sid: + return entry_gateway raise vol.Invalid(f"Unknown gateway sid {sid}") kwargs = {} - if (xiaomi_data := hass.data.get(DOMAIN)) is not None: - gateways = list(xiaomi_data[GATEWAYS_KEY].values()) + gateways = [ + entry.runtime_data for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ] - # If the user has only 1 gateway, make it the default for services. - if len(gateways) == 1: - kwargs["default"] = gateways[0].sid + # If the user has only 1 gateway, make it the default for services. + if len(gateways) == 1: + kwargs["default"] = gateways[0].sid return schema.extend({vol.Required(ATTR_GW_MAC, **kwargs): gateway}) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 544cd6f7e31..c16f91dad0b 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -9,13 +9,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -34,12 +33,12 @@ ATTR_DENSITY = "Density" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiBinarySensor] = [] - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for entity in gateway.devices["binary_sensor"]: model = entity["model"] if model in ("motion", "sensor_motion", "sensor_motion.aq2"): @@ -147,7 +146,7 @@ class XiaomiBinarySensor(XiaomiDevice, BinarySensorEntity): xiaomi_hub: XiaomiGateway, data_key: str, device_class: BinarySensorDeviceClass | None, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._data_key = data_key @@ -167,7 +166,7 @@ class XiaomiNatgasSensor(XiaomiBinarySensor): self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = None @@ -224,7 +223,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): device: dict[str, Any], hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiMotionSensor.""" self._hass = hass @@ -333,7 +332,7 @@ class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiDoorSensor.""" self._open_since = 0 @@ -400,7 +399,7 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor): self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiWaterLeakSensor.""" if "proto" not in device or int(device["proto"][0:1]) == 1: @@ -451,7 +450,7 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): self, device: dict[str, Any], xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSmokeSensor.""" self._density = 0 @@ -508,7 +507,7 @@ class XiaomiVibration(XiaomiBinarySensor): name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiVibration.""" self._last_action = None @@ -556,7 +555,7 @@ class XiaomiButton(XiaomiBinarySensor): data_key: str, hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiButton.""" self._hass = hass @@ -623,7 +622,7 @@ class XiaomiCube(XiaomiBinarySensor): device: dict[str, Any], hass: HomeAssistant, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the Xiaomi Cube.""" self._hass = hass diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 3cc8a2b000d..3d04276d9ec 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -40,6 +40,8 @@ GATEWAY_CONFIG_HOST = GATEWAY_CONFIG.extend(CONFIG_HOST) GATEWAY_SETTINGS = vol.Schema( { vol.Optional(CONF_KEY): vol.All(str, vol.Length(min=16, max=16)), + # Name field is no longer allowed in config flow schemas + # pylint: disable-next=home-assistant-config-flow-name-field vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, } ) diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py index d137941d614..6b410d0f566 100644 --- a/homeassistant/components/xiaomi_aqara/const.py +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -2,7 +2,6 @@ DOMAIN = "xiaomi_aqara" -GATEWAYS_KEY = "gateways" LISTENER_KEY = "listener" KEY_UNSUB_STOP = "unsub_stop" KEY_SETUP_LOCK = "setup_lock" diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index ebab3344250..676d946104f 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -5,11 +5,10 @@ from typing import Any from xiaomi_gateway import XiaomiGateway from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice ATTR_CURTAIN_LEVEL = "curtain_level" @@ -20,12 +19,12 @@ DATA_KEY_PROTO_V2 = "curtain_status" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["cover"]: model = device["model"] if model in ("curtain", "curtain.aq2", "curtain.hagl04"): @@ -48,7 +47,7 @@ class XiaomiGenericCover(XiaomiDevice, CoverEntity): name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiGenericCover.""" self._data_key = data_key diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index 3f640b67516..de7d0dfa7da 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any from xiaomi_gateway import XiaomiGateway -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_MAC from homeassistant.core import callback from homeassistant.helpers import device_registry as dr @@ -15,6 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from . import XiaomiAqaraConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ class XiaomiDevice(Entity): device: dict[str, Any], device_type: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the Xiaomi device.""" self._is_available = True diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 585ab39ba6b..359929de185 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -13,12 +13,11 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -26,12 +25,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["light"]: model = device["model"] if model in ("gateway", "gateway.v3"): @@ -52,7 +51,7 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): device: dict[str, Any], name: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiGatewayLight.""" self._data_key = "rgb" diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 86d20a7024f..ccd1c832fa9 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -1,18 +1,15 @@ """Support for Xiaomi Aqara locks.""" -from __future__ import annotations - from typing import Any from xiaomi_gateway import XiaomiGateway from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice FINGER_KEY = "fing_verified" @@ -27,11 +24,11 @@ UNLOCK_MAINTAIN_TIME = 5 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data async_add_entities( XiaomiAqaraLock(device, "Lock", gateway, config_entry) for device in gateway.devices["lock"] @@ -47,7 +44,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): device: dict[str, Any], name: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiAqaraLock.""" self._attr_changed_by = "0" diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 5a344fcf665..53697ce1da7 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -1,7 +1,5 @@ """Support for Xiaomi Aqara sensors.""" -from __future__ import annotations - import logging from typing import Any @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, LIGHT_LUX, @@ -25,7 +22,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS +from . import XiaomiAqaraConfigEntry +from .const import BATTERY_MODELS, POWER_MODELS from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -63,7 +61,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { "bed_activity": SensorEntityDescription( key="bed_activity", native_unit_of_measurement="μm", - device_class=None, state_class=SensorStateClass.MEASUREMENT, ), "load_power": SensorEntityDescription( @@ -87,12 +84,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiSensor | XiaomiBatterySensor] = [] - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["sensor"]: if device["model"] == "sensor_ht": entities.append( @@ -173,7 +170,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): name: str, data_key: str, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiSensor.""" self._data_key = data_key diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 69cba6491cd..ff1232db898 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -6,11 +6,10 @@ from typing import Any from xiaomi_gateway import XiaomiGateway from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, GATEWAYS_KEY +from . import XiaomiAqaraConfigEntry from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) @@ -30,12 +29,12 @@ IN_USE = "inuse" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] - gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] + gateway = config_entry.runtime_data for device in gateway.devices["switch"]: model = device["model"] if model == "plug": @@ -145,7 +144,7 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): data_key: str, supports_power_consumption: bool, xiaomi_hub: XiaomiGateway, - config_entry: ConfigEntry, + config_entry: XiaomiAqaraConfigEntry, ) -> None: """Initialize the XiaomiPlug.""" self._data_key = data_key diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index fae5e4d0c91..738d3dc8329 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -1,7 +1,5 @@ """The Xiaomi Bluetooth integration.""" -from __future__ import annotations - from functools import partial import logging from typing import cast @@ -42,7 +40,7 @@ def process_service_info( device_registry: DeviceRegistry, service_info: BluetoothServiceInfoBleak, ) -> SensorUpdate: - """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + """Process a BluetoothServiceInfoBleak and return sensor data.""" coordinator = entry.runtime_data data = coordinator.device_data update = data.update(service_info) @@ -98,10 +96,11 @@ def process_service_info( ) # If device isn't pending we know it has seen at least one broadcast with a payload - # If that payload was encrypted and the bindkey was not verified then we need to reauth + # If that payload was encrypted and the bindkey was + # not verified then we need to reauth if ( not data.pending - and data.encryption_scheme != EncryptionScheme.NONE + and data.encryption_scheme is not EncryptionScheme.NONE and not data.bindkey_verified ): entry.async_start_reauth(hass, data={"device": data}) diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 8956e207253..eaaa0032ffd 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Xiaomi binary sensors.""" -from __future__ import annotations - from xiaomi_ble.parser import ( BinarySensorDeviceClass as XiaomiBinarySensorDeviceClass, ExtendedBinarySensorDeviceClass, @@ -76,9 +74,11 @@ BINARY_SENSOR_DESCRIPTIONS = { ExtendedBinarySensorDeviceClass.CHILDLOCK: BinarySensorEntityDescription( key=ExtendedBinarySensorDeviceClass.CHILDLOCK, ), - ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED: BinarySensorEntityDescription( - key=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED, - device_class=BinarySensorDeviceClass.PROBLEM, + ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED: ( + BinarySensorEntityDescription( + key=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED, + device_class=BinarySensorDeviceClass.PROBLEM, + ) ), ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN: BinarySensorEntityDescription( key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN, @@ -118,7 +118,9 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[ description.device_class ] - for device_key, description in sensor_update.binary_entity_descriptions.items() + for device_key, description in ( + sensor_update.binary_entity_descriptions.items() + ) if description.device_class }, entity_data={ diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index c293d7832d0..a4623206387 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Xiaomi Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Mapping import dataclasses import logging @@ -16,7 +14,7 @@ from xiaomi_ble import ( ) from xiaomi_ble.parser import EncryptionScheme -from homeassistant.components import onboarding +from homeassistant.components import bluetooth, onboarding from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfo, @@ -111,9 +109,9 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): # encryption later, we can do a reauth return await self.async_step_confirm_slow() - if device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY: + if device.encryption_scheme is EncryptionScheme.MIBEACON_LEGACY: return await self.async_step_get_encryption_key_legacy() - if device.encryption_scheme == EncryptionScheme.MIBEACON_4_5: + if device.encryption_scheme is EncryptionScheme.MIBEACON_4_5: return await self.async_step_get_encryption_key_4_5_choose_method() return await self.async_step_bluetooth_confirm() @@ -298,14 +296,15 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device = discovery.device - if discovery.device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY: + if discovery.device.encryption_scheme is EncryptionScheme.MIBEACON_LEGACY: return await self.async_step_get_encryption_key_legacy() - if discovery.device.encryption_scheme == EncryptionScheme.MIBEACON_4_5: + if discovery.device.encryption_scheme is EncryptionScheme.MIBEACON_4_5: return await self.async_step_get_encryption_key_4_5_choose_method() return self._async_get_or_create_entry() + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address @@ -340,10 +339,10 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = device.last_service_info - if device.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY: + if device.encryption_scheme is EncryptionScheme.MIBEACON_LEGACY: return await self.async_step_get_encryption_key_legacy() - if device.encryption_scheme == EncryptionScheme.MIBEACON_4_5: + if device.encryption_scheme is EncryptionScheme.MIBEACON_4_5: return await self.async_step_get_encryption_key_4_5_choose_method() # Otherwise there wasn't actually encryption so abort diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index aab443c67fa..3d3fc329b38 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -1,7 +1,5 @@ """Constants for the Xiaomi Bluetooth integration.""" -from __future__ import annotations - from typing import Final, TypedDict DOMAIN = "xiaomi_ble" diff --git a/homeassistant/components/xiaomi_ble/device.py b/homeassistant/components/xiaomi_ble/device.py index 4f712a7a77c..53c3debd15d 100644 --- a/homeassistant/components/xiaomi_ble/device.py +++ b/homeassistant/components/xiaomi_ble/device.py @@ -1,7 +1,5 @@ """Support for Xioami BLE devices.""" -from __future__ import annotations - from xiaomi_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 3c5488a1e74..ccc2de63ab6 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Xiaomi BLE.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py index c5f6e01e575..682014cc212 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -1,7 +1,5 @@ """Support for Xiaomi event entities.""" -from __future__ import annotations - from dataclasses import replace from homeassistant.components.event import ( diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 156a9f9e6c4..dabe83e0572 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["xiaomi-ble==1.10.1"] + "requirements": ["xiaomi-ble==1.11.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 3b2fcddc197..d3702efc5f5 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -1,7 +1,5 @@ """Support for xiaomi ble sensors.""" -from __future__ import annotations - from typing import cast from xiaomi_ble import DeviceClass, SensorUpdate, Units @@ -145,10 +143,9 @@ SENSOR_DESCRIPTIONS = { key=str(ExtendedSensorDeviceClass.SCORE), state_class=SensorStateClass.MEASUREMENT, ), - # Counting during brushing - (ExtendedSensorDeviceClass.COUNTER, Units.TIME_SECONDS): SensorEntityDescription( + # Counter of brushing + (ExtendedSensorDeviceClass.COUNTER, None): SensorEntityDescription( key=str(ExtendedSensorDeviceClass.COUNTER), - native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, ), # Key id for locks and fingerprint readers diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 76eb6467780..7de81af4b68 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -1,7 +1,5 @@ """Support for Xiaomi Miio.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass @@ -126,6 +124,14 @@ MODEL_TO_CLASS_MAP = { MODEL_FAN_ZA5: FanZA5, } +# List of models requiring specific lazy_discover setting +LAZY_DISCOVER_FOR_MODEL = { + "zhimi.fan.za3": True, + "zhimi.fan.za5": True, + "zhimi.airpurifier.za1": True, + "dmaker.fan.1c": True, +} + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" @@ -309,13 +315,6 @@ async def async_create_miio_device_and_coordinator( update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator - # List of models requiring specific lazy_discover setting - LAZY_DISCOVER_FOR_MODEL = { - "zhimi.fan.za3": True, - "zhimi.fan.za5": True, - "zhimi.airpurifier.za1": True, - "dmaker.fan.1c": True, - } lazy_discover = LAZY_DISCOVER_FOR_MODEL.get(model, False) if ( @@ -356,7 +355,7 @@ async def async_create_miio_device_and_coordinator( elif model in MODELS_VACUUM or model.startswith( (ROBOROCK_GENERIC, ROCKROBO_GENERIC) ): - # TODO: add lazy_discover as argument when python-miio add support # pylint: disable=fixme + # TODO: add lazy_discover as argument # pylint: disable=fixme device = RoborockVacuum(host, token) update_method = _async_update_data_vacuum coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 435253ae8d1..02b07e4a493 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Xiomi Gateway alarm control panels.""" -from __future__ import annotations - from functools import partial import logging diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 205db7cd21c..efa523a5acf 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Xiaomi Miio binary sensors.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import dataclass import logging @@ -92,7 +90,6 @@ VACUUM_SENSORS = { translation_key=ATTR_WATER_BOX_ATTACHED, icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, - entity_registry_enabled_default=True, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -101,7 +98,6 @@ VACUUM_SENSORS = { translation_key=ATTR_WATER_BOX_ATTACHED, icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, - entity_registry_enabled_default=True, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -110,7 +106,6 @@ VACUUM_SENSORS = { translation_key=ATTR_WATER_SHORTAGE, icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, - entity_registry_enabled_default=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -123,7 +118,6 @@ VACUUM_SENSORS_SEPARATE_MOP = { translation_key=ATTR_MOP_ATTACHED, icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, - entity_registry_enabled_default=True, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 58236e136cb..2a455b01e8f 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -1,7 +1,5 @@ """Support for Xiaomi buttons.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 17ea1105da5..20833694305 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Xiaomi Miio.""" -from __future__ import annotations - from collections.abc import Mapping import logging from re import search diff --git a/homeassistant/components/xiaomi_miio/coordinator.py b/homeassistant/components/xiaomi_miio/coordinator.py index 32c10199c53..86b22885dbf 100644 --- a/homeassistant/components/xiaomi_miio/coordinator.py +++ b/homeassistant/components/xiaomi_miio/coordinator.py @@ -1,7 +1,5 @@ """Support for Xiaomi Miio.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 518003ceedb..cd75f28c9ae 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -1,7 +1,5 @@ """Support for Xiaomi Mi WiFi Repeater 2.""" -from __future__ import annotations - import logging from miio import DeviceException, WifiRepeater diff --git a/homeassistant/components/xiaomi_miio/diagnostics.py b/homeassistant/components/xiaomi_miio/diagnostics.py index cc941b140be..19f5f6d6d17 100644 --- a/homeassistant/components/xiaomi_miio/diagnostics.py +++ b/homeassistant/components/xiaomi_miio/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Xiaomi Miio.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index d10bdaad217..e776016d6ce 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,7 +1,5 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" -from __future__ import annotations - from abc import abstractmethod import asyncio import logging @@ -290,6 +288,7 @@ async def async_setup_entry( for air_purifier_service, method in SERVICE_TO_METHOD.items(): schema = method.schema or AIRPURIFIER_SERVICE_SCHEMA + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, air_purifier_service, async_service_handler, schema=schema ) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 2e1003fd661..c3618bb6060 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -1,4 +1,4 @@ -"""Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" +"""Support for Xiaomi Mi Air Purifier and Humidifier.""" import logging import math @@ -253,7 +253,7 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): if ( self.supported_features & HumidifierEntityFeature.MODES == 0 or AirhumidifierOperationMode(self._attributes[ATTR_MODE]) - == AirhumidifierOperationMode.Auto + is AirhumidifierOperationMode.Auto or AirhumidifierOperationMode.Auto.name not in self.available_modes ): self.async_write_ha_state() @@ -310,7 +310,7 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): return ( self._target_humidity if AirhumidifierMiotOperationMode(self._mode) - == AirhumidifierMiotOperationMode.Auto + is AirhumidifierMiotOperationMode.Auto else None ) return None @@ -331,7 +331,7 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): if ( self.supported_features & HumidifierEntityFeature.MODES == 0 or AirhumidifierMiotOperationMode(self._attributes[ATTR_MODE]) - == AirhumidifierMiotOperationMode.Auto + is AirhumidifierMiotOperationMode.Auto ): self.async_write_ha_state() return @@ -385,7 +385,7 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): if self.is_on: if ( AirhumidifierMjjsqOperationMode(self._mode) - == AirhumidifierMjjsqOperationMode.Humidity + is AirhumidifierMjjsqOperationMode.Humidity ): return self._target_humidity return None @@ -406,7 +406,7 @@ class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): if ( self.supported_features & HumidifierEntityFeature.MODES == 0 or AirhumidifierMjjsqOperationMode(self._attributes[ATTR_MODE]) - == AirhumidifierMjjsqOperationMode.Humidity + is AirhumidifierMjjsqOperationMode.Humidity ): self.async_write_ha_state() return diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index ab11572006e..bfcad41f35b 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -1,7 +1,5 @@ """Support for Xiaomi Philips Lights.""" -from __future__ import annotations - import asyncio import datetime from datetime import timedelta @@ -245,6 +243,7 @@ async def async_setup_entry( for xiaomi_miio_service, method in SERVICE_TO_METHOD.items(): schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema ) @@ -477,7 +476,8 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( - "Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct", + "Setting brightness and color temperature:" + " %s %s%%, %s mireds, %s%% cct", brightness, percent_brightness, color_temp, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 2f7066c6fdf..3aec1148656 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -1,7 +1,5 @@ """Motor speed support for Xiaomi Mi Air Humidifier.""" -from __future__ import annotations - import dataclasses from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 03b778ee358..5b5ba736303 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -1,7 +1,5 @@ """Support for the Xiaomi IR Remote (Chuangmi IR).""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 6dff7cf8ede..b99b4385f1d 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -1,7 +1,5 @@ """Support led_brightness for Mi Air Humidifier.""" -from __future__ import annotations - from dataclasses import dataclass, field import logging from typing import Any, NamedTuple @@ -30,7 +28,7 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import ( ) from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory +from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -67,7 +65,6 @@ from .typing import XiaomiMiioConfigEntry ATTR_DISPLAY_ORIENTATION = "display_orientation" ATTR_LED_BRIGHTNESS = "led_brightness" ATTR_PTC_LEVEL = "ptc_level" -ATTR_MODE = "mode" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 70deeb141c0..8630b14d576 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -1,7 +1,5 @@ """Support for Xiaomi Mi Air Quality Monitor (PM2.5) and Humidifier.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import dataclass import logging @@ -676,15 +674,17 @@ VACUUM_SENSORS = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), - f"clean_history_{ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT}": XiaomiMiioSensorDescription( - native_unit_of_measurement="", - icon="mdi:counter", - state_class=SensorStateClass.TOTAL_INCREASING, - key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, - parent_key=VacuumCoordinatorDataAttributes.clean_history_status, - translation_key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, - entity_registry_enabled_default=False, - entity_category=EntityCategory.DIAGNOSTIC, + f"clean_history_{ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT}": ( + XiaomiMiioSensorDescription( + native_unit_of_measurement="", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, + parent_key=VacuumCoordinatorDataAttributes.clean_history_status, + translation_key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ) ), f"consumable_{ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT}": XiaomiMiioSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -713,14 +713,16 @@ VACUUM_SENSORS = { translation_key=ATTR_CONSUMABLE_STATUS_FILTER_LEFT, entity_category=EntityCategory.DIAGNOSTIC, ), - f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": XiaomiMiioSensorDescription( - native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:eye-outline", - device_class=SensorDeviceClass.DURATION, - key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, - parent_key=VacuumCoordinatorDataAttributes.consumable_status, - translation_key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, - entity_category=EntityCategory.DIAGNOSTIC, + f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": ( + XiaomiMiioSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:eye-outline", + device_class=SensorDeviceClass.DURATION, + key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, + parent_key=VacuumCoordinatorDataAttributes.consumable_status, + translation_key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, + entity_category=EntityCategory.DIAGNOSTIC, + ) ), } diff --git a/homeassistant/components/xiaomi_miio/services.py b/homeassistant/components/xiaomi_miio/services.py index 882cf5b65f6..97397f9feec 100644 --- a/homeassistant/components/xiaomi_miio/services.py +++ b/homeassistant/components/xiaomi_miio/services.py @@ -1,7 +1,5 @@ """Xiaomi services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index a5375433ed8..f07ca7fd3dd 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,7 +1,5 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from functools import partial @@ -27,6 +25,7 @@ from homeassistant.components.switch import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, + ATTR_MODEL, ATTR_TEMPERATURE, CONF_DEVICE, CONF_HOST, @@ -151,7 +150,6 @@ ATTR_LED = "led" ATTR_IONIZER = "ionizer" ATTR_ANION = "anion" ATTR_LOAD_POWER = "load_power" -ATTR_MODEL = "model" ATTR_POWER = "power" ATTR_POWER_MODE = "power_mode" ATTR_POWER_PRICE = "power_price" @@ -519,6 +517,7 @@ async def async_setup_other_entry( for plug_service, method in SERVICE_TO_METHOD.items(): schema = method.schema or SERVICE_SCHEMA + # pylint: disable-next=home-assistant-service-registered-in-setup-entry hass.services.async_register( DOMAIN, plug_service, async_service_handler, schema=schema ) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 5ff31c3bb9e..a4b3850c10c 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -1,7 +1,5 @@ """Support for the Xiaomi vacuum cleaner robot.""" -from __future__ import annotations - from functools import partial import logging from typing import Any @@ -200,6 +198,7 @@ class MiroboVacuum( else: try: fan_speed_int = int(fan_speed) + # pylint: disable-next=home-assistant-action-swallowed-exception except ValueError as exc: _LOGGER.error( "Fan speed step not recognized (%s). Valid speeds are: %s", diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 19cb4faf2b9..3a1787f5ed8 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -1,7 +1,5 @@ """Add support for the Xiaomi TVs.""" -from __future__ import annotations - import logging import pymitv diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 964f66f1db2..6a0762187e0 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -1,7 +1,5 @@ """Jabber (XMPP) notification service.""" -from __future__ import annotations - from concurrent.futures import TimeoutError as FutTimeoutError from http import HTTPStatus import logging diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index 0747b2130bd..680e73a7ba1 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -1,7 +1,5 @@ """Support for XS1 climate devices.""" -from __future__ import annotations - from typing import Any from xs1_api_client.api_constants import ActuatorType diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index d1411fe540b..1403b4c4dde 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -1,7 +1,5 @@ """Support for XS1 sensors.""" -from __future__ import annotations - from xs1_api_client.api_constants import ActuatorType from xs1_api_client.device.actuator import XS1Actuator from xs1_api_client.device.sensor import XS1Sensor diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py index 232bd590c61..49e2a689db8 100644 --- a/homeassistant/components/xs1/switch.py +++ b/homeassistant/components/xs1/switch.py @@ -1,7 +1,5 @@ """Support for XS1 switches.""" -from __future__ import annotations - from typing import Any from xs1_api_client.api_constants import ActuatorType diff --git a/homeassistant/components/xthings_cloud/__init__.py b/homeassistant/components/xthings_cloud/__init__.py new file mode 100644 index 00000000000..4259c3e1f75 --- /dev/null +++ b/homeassistant/components/xthings_cloud/__init__.py @@ -0,0 +1,37 @@ +"""Xthings Cloud integration for Home Assistant.""" + +from ha_xthings_cloud import XthingsCloudApiClient + +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: XthingsCloudConfigEntry +) -> bool: + """Set up config entry.""" + session = async_get_clientsession(hass) + client = XthingsCloudApiClient(session, token=entry.data[CONF_TOKEN]) + + coordinator = XthingsCloudCoordinator(hass, client, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await coordinator.async_start_websocket() + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: XthingsCloudConfigEntry +) -> bool: + """Unload config entry.""" + coordinator = entry.runtime_data + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await coordinator.async_stop_websocket() + return unload_ok diff --git a/homeassistant/components/xthings_cloud/config_flow.py b/homeassistant/components/xthings_cloud/config_flow.py new file mode 100644 index 00000000000..ddc32314ccd --- /dev/null +++ b/homeassistant/components/xthings_cloud/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for Xthings Cloud.""" + +from typing import Any + +from ha_xthings_cloud import ( + XthingsCloudApiClient, + XthingsCloudApiError, + XthingsCloudAuthError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .const import CONF_REFRESH_TOKEN, DOMAIN, LOGGER + +ERROR_CODE_MAP: dict[int, str] = { + 20001: "token_invalid", + 21001: "email_empty", + 21002: "email_invalid", + 21004: "email_not_found", + 21011: "password_empty", + 21014: "password_wrong", + 21021: "user_disabled", + 21022: "user_not_logged_in", + 21023: "user_not_activated", + 20011: "token_invalid", + 20012: "token_expired", + 22001: "device_not_found", + 22003: "device_offline", +} + + +def _error_from_exception(err: XthingsCloudApiError) -> str: + """Return translation key from error code.""" + return ERROR_CODE_MAP.get(err.code, "unknown") + + +class XthingsCloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Xthings Cloud config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user input step.""" + errors: dict[str, str] = {} + + if user_input is not None: + instance_id = await async_get_instance_id(self.hass) + session = async_get_clientsession(self.hass) + client = XthingsCloudApiClient(session) + try: + token_data = await client.async_login( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + client_id=instance_id, + ) + except XthingsCloudAuthError as err: + errors["base"] = _error_from_exception(err) + except XthingsCloudApiError as err: + errors["base"] = ( + _error_from_exception(err) if err.code else "cannot_connect" + ) + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error during login") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(token_data["user_id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_TOKEN: token_data["token"], + CONF_REFRESH_TOKEN: token_data["refresh_token"], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/xthings_cloud/const.py b/homeassistant/components/xthings_cloud/const.py new file mode 100644 index 00000000000..4aa4d2967c7 --- /dev/null +++ b/homeassistant/components/xthings_cloud/const.py @@ -0,0 +1,16 @@ +"""Constants for Xthings Cloud integration.""" + +import logging + +from homeassistant.const import Platform + +DOMAIN = "xthings_cloud" +LOGGER = logging.getLogger(__package__) + +CONF_REFRESH_TOKEN = "refresh_token" +CONF_INSTANCE_ID = "instance_id" + +# Polling interval (seconds) +DEFAULT_SCAN_INTERVAL = 1800 + +PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH] diff --git a/homeassistant/components/xthings_cloud/coordinator.py b/homeassistant/components/xthings_cloud/coordinator.py new file mode 100644 index 00000000000..91faed82c6f --- /dev/null +++ b/homeassistant/components/xthings_cloud/coordinator.py @@ -0,0 +1,129 @@ +"""DataUpdateCoordinator for Xthings Cloud.""" + +from datetime import timedelta +from typing import Any + +from ha_xthings_cloud import ( + XthingsCloudApiClient, + XthingsCloudApiError, + XthingsCloudAuthError, + XthingsCloudWebSocket, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_REFRESH_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + +type XthingsCloudConfigEntry = ConfigEntry[XthingsCloudCoordinator] + + +class XthingsCloudCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Xthings Cloud data update coordinator.""" + + config_entry: XthingsCloudConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: XthingsCloudApiClient, + entry: XthingsCloudConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, + ) + self.client = client + self.websocket: XthingsCloudWebSocket | None = None + + async def _async_ensure_token_valid(self) -> None: + """Ensure the token is valid, refresh if expired. + + Raises ConfigEntryAuthFailed if refresh fails. + """ + if not self.client.is_token_expired(): + return + try: + token_data = await self.client.async_refresh_token( + self.config_entry.data[CONF_REFRESH_TOKEN] + ) + except XthingsCloudAuthError as err: + raise ConfigEntryAuthFailed( + "Token expired and refresh failed, re-authentication required" + ) from err + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_TOKEN: token_data["token"], + CONF_REFRESH_TOKEN: token_data["refresh_token"], + }, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch latest device data from cloud.""" + await self._async_ensure_token_valid() + try: + devices = await self.client.async_get_devices() + except XthingsCloudAuthError as err: + raise ConfigEntryAuthFailed( + "Invalid token, re-authentication required" + ) from err + except XthingsCloudApiError as err: + raise UpdateFailed(f"Failed to fetch data: {err}") from err + return {device["id"]: device for device in devices} + + async def async_start_websocket(self) -> None: + """Start WebSocket connection.""" + if self.websocket: + return + session = async_get_clientsession(self.hass) + token = self.config_entry.data[CONF_TOKEN] + self.websocket = XthingsCloudWebSocket( + session=session, + token=token, + on_device_status=self._handle_ws_device_status, + on_token_expired=self._handle_ws_token_expired, + ) + await self.websocket.async_start() + + async def async_stop_websocket(self) -> None: + """Stop WebSocket connection.""" + if self.websocket: + await self.websocket.async_stop() + self.websocket = None + + def _handle_ws_device_status( + self, device_uuid: str, status: dict[str, Any] + ) -> None: + """Handle WebSocket device status update.""" + if not self.data or device_uuid not in self.data: + LOGGER.debug( + "WebSocket received status for unknown device: %s", device_uuid + ) + return + device_data = self.data[device_uuid] + device_data.setdefault("status", {}).update(status) + LOGGER.debug("WebSocket updated device status: %s", device_uuid) + self.async_set_updated_data(self.data) + + async def _handle_ws_token_expired(self) -> None: + """Handle WebSocket auth expiry, refresh token.""" + try: + await self._async_ensure_token_valid() + except ConfigEntryAuthFailed: + LOGGER.error("WebSocket token refresh failed") + return + new_token = self.config_entry.data[CONF_TOKEN] + self.client.token = new_token + if self.websocket: + self.websocket.token = new_token + LOGGER.info("WebSocket token refreshed successfully") diff --git a/homeassistant/components/xthings_cloud/entity.py b/homeassistant/components/xthings_cloud/entity.py new file mode 100644 index 00000000000..f32221efea3 --- /dev/null +++ b/homeassistant/components/xthings_cloud/entity.py @@ -0,0 +1,48 @@ +"""Base entity for Xthings Cloud.""" + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import XthingsCloudCoordinator + + +class XthingsCloudEntity(CoordinatorEntity[XthingsCloudCoordinator]): + """Xthings Cloud base entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: XthingsCloudCoordinator, + device_id: str, + device_data: dict[str, Any], + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=device_data["name"], + manufacturer="Xthings", + model=device_data["model"], + sw_version=device_data.get("version"), + ) + + @property + def device_data(self) -> dict[str, Any]: + """Return current device data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return whether device is available (online).""" + return ( + super().available + and self._device_id in self.coordinator.data + and self.device_data["online"] + ) diff --git a/homeassistant/components/xthings_cloud/light.py b/homeassistant/components/xthings_cloud/light.py new file mode 100644 index 00000000000..183eab7d502 --- /dev/null +++ b/homeassistant/components/xthings_cloud/light.py @@ -0,0 +1,197 @@ +"""Light platform for Xthings Cloud.""" + +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import XthingsCloudConfigEntry, XthingsCloudCoordinator +from .entity import XthingsCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: XthingsCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light platform.""" + coordinator = entry.runtime_data + entities: list[LightEntity] = [] + for device_id, device_data in coordinator.data.items(): + dev_type = device_data.get("type") + if dev_type == "light": + entities.append(XthingsCloudLight(coordinator, device_id, device_data)) + elif dev_type == "switch": + entities.append(XthingsCloudSwitch(coordinator, device_id, device_data)) + async_add_entities(entities) + + +class XthingsCloudBaseLight(XthingsCloudEntity, LightEntity): + """Xthings Cloud base light entity.""" + + @property + def is_on(self) -> bool: + """Return true if the light is on.""" + return self.device_data["status"]["on"] + + @property + def brightness(self) -> int | None: + """Return brightness (0-255).""" + level = self.device_data["status"].get("brightness") + if level is not None: + return round(level * 255 / 100) + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + raise NotImplementedError + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + raise NotImplementedError + + +class XthingsCloudLight(XthingsCloudBaseLight): + """Xthings Cloud native light entity.""" + + _attr_min_color_temp_kelvin = 2000 + _attr_max_color_temp_kelvin = 6500 + + def __init__( + self, + coordinator: XthingsCloudCoordinator, + device_id: str, + device_data: dict[str, Any], + ) -> None: + """Initialize the light entity.""" + super().__init__(coordinator, device_id, device_data) + # Determine supported color modes from device status + status = device_data["status"] + modes: set[ColorMode] = set() + if "hue" in status or "saturation" in status: + modes.add(ColorMode.HS) + if "temperature" in status: + modes.add(ColorMode.COLOR_TEMP) + if not modes and "brightness" in status: + modes.add(ColorMode.BRIGHTNESS) + if not modes: + modes.add(ColorMode.ONOFF) + self._attr_supported_color_modes = modes + + @property + def color_mode(self) -> ColorMode: + """Return current color mode.""" + status = self.device_data["status"] + color_type = status.get("color_type") + modes = self._attr_supported_color_modes or set() + if color_type == 0 and ColorMode.HS in modes: + return ColorMode.HS + if color_type == 1 and ColorMode.COLOR_TEMP in modes: + return ColorMode.COLOR_TEMP + if ColorMode.HS in modes: + return ColorMode.HS + if ColorMode.COLOR_TEMP in modes: + return ColorMode.COLOR_TEMP + if ColorMode.BRIGHTNESS in modes: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the HS color value.""" + status = self.device_data["status"] + hue = status.get("hue") + saturation = status.get("saturation") + if hue is not None and saturation is not None: + return (hue, saturation) + return None + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self.device_data["status"].get("temperature") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + client = self.coordinator.client + + if ATTR_HS_COLOR in kwargs: + hue, saturation = kwargs[ATTR_HS_COLOR] + status = self.device_data["status"] + lightness = status.get("lightness", 50) + cur_brightness = status.get("brightness", 100) + if ATTR_BRIGHTNESS in kwargs: + lightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) + cur_brightness = lightness + await client.async_brite_color( + self._device_id, + { + "colortype": 0, + "hue": round(hue), + "saturation": round(saturation), + "lightness": lightness, + "brightness": cur_brightness, + }, + ) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + status = self.device_data["status"] + cur_brightness = status.get("brightness", 100) + if ATTR_BRIGHTNESS in kwargs: + cur_brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) + await client.async_brite_color( + self._device_id, + { + "colortype": 1, + "temperature": kwargs[ATTR_COLOR_TEMP_KELVIN], + "brightness": cur_brightness, + }, + ) + elif ATTR_BRIGHTNESS in kwargs: + brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) + await client.async_brite_brightness(self._device_id, brightness) + else: + await client.async_brite_on(self._device_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.coordinator.client.async_brite_off(self._device_id) + + +class XthingsCloudSwitch(XthingsCloudBaseLight): + """Xthings Cloud switch device exposed as a light entity.""" + + def __init__( + self, + coordinator: XthingsCloudCoordinator, + device_id: str, + device_data: dict[str, Any], + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator, device_id, device_data) + status = device_data["status"] + if "brightness" in status: + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + else: + self._attr_supported_color_modes = {ColorMode.ONOFF} + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + client = self.coordinator.client + if ATTR_BRIGHTNESS in kwargs: + modes = self._attr_supported_color_modes or set() + if ColorMode.BRIGHTNESS in modes: + brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255) + await client.async_switch_brightness(self._device_id, brightness) + return + await client.async_switch_on(self._device_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self.coordinator.client.async_switch_off(self._device_id) diff --git a/homeassistant/components/xthings_cloud/lock.py b/homeassistant/components/xthings_cloud/lock.py new file mode 100644 index 00000000000..b7e55c33ca5 --- /dev/null +++ b/homeassistant/components/xthings_cloud/lock.py @@ -0,0 +1,47 @@ +"""Lock platform for Xthings Cloud.""" + +from typing import Any + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import XthingsCloudConfigEntry +from .entity import XthingsCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: XthingsCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lock platform.""" + coordinator = entry.runtime_data + entities = [ + XthingsCloudLock(coordinator, device_id, device_data) + for device_id, device_data in coordinator.data.items() + if device_data["type"] == "lock" + ] + async_add_entities(entities) + + +class XthingsCloudLock(XthingsCloudEntity, LockEntity): + """Xthings Cloud lock entity.""" + + @property + def is_locked(self) -> bool | None: + """Return true if lock is locked.""" + return self.device_data["status"].get("locked") + + @property + def is_jammed(self) -> bool | None: + """Return true if lock is jammed.""" + return self.device_data["status"].get("jammed") + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + await self.coordinator.client.async_lock_lock(self._device_id) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + await self.coordinator.client.async_lock_unlock(self._device_id) diff --git a/homeassistant/components/xthings_cloud/manifest.json b/homeassistant/components/xthings_cloud/manifest.json new file mode 100644 index 00000000000..4a89350ccd3 --- /dev/null +++ b/homeassistant/components/xthings_cloud/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "xthings_cloud", + "name": "Xthings Cloud", + "codeowners": ["@XthingsJacobs"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/xthings_cloud", + "integration_type": "hub", + "iot_class": "cloud_push", + "loggers": ["ha_xthings_cloud"], + "quality_scale": "bronze", + "requirements": ["ha-xthings-cloud==1.0.5"] +} diff --git a/homeassistant/components/xthings_cloud/quality_scale.yaml b/homeassistant/components/xthings_cloud/quality_scale.yaml new file mode 100644 index 00000000000..1705c7eb723 --- /dev/null +++ b/homeassistant/components/xthings_cloud/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No event-based entity setup. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: + status: done + comment: Offloaded to coordinator. + entity-unavailable: + status: done + comment: Offloaded to coordinator. + action-exceptions: + status: exempt + comment: No service actions implemented. + reauthentication-flow: todo + parallel-updates: todo + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + + # Gold + entity-translations: + status: exempt + comment: Entity uses has_entity_name with name set to None. + entity-device-class: + status: exempt + comment: No platform with device classes. + devices: done + entity-category: + status: exempt + comment: No diagnostic or configuration entities. + entity-disabled-by-default: + status: exempt + comment: No entities disabled by default. + discovery: todo + stale-devices: + status: exempt + comment: Single config entry, devices managed by coordinator. + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: Devices are fetched from cloud on each update. + discovery-update-info: + status: exempt + comment: No discoverable entities. + repair-issues: + status: exempt + comment: No repair issues implemented. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: + status: exempt + comment: No automation examples needed. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/xthings_cloud/strings.json b/homeassistant/components/xthings_cloud/strings.json new file mode 100644 index 00000000000..9d4c5595b05 --- /dev/null +++ b/homeassistant/components/xthings_cloud/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "device_not_found": "Device not found.", + "device_offline": "Device is offline.", + "email_empty": "Email cannot be empty.", + "email_invalid": "Invalid email format.", + "email_not_found": "Email does not exist.", + "password_empty": "Password cannot be empty.", + "password_wrong": "Incorrect password.", + "token_expired": "Token expired, please log in again.", + "token_invalid": "Invalid token, please log in again.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "user_disabled": "This account has been disabled.", + "user_not_activated": "This account has not been activated.", + "user_not_logged_in": "Session expired, please log in again." + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to register your Xthings Cloud account.", + "password": "Your Xthings Cloud account password." + }, + "description": "Please enter your Xthings Cloud account credentials.", + "title": "Xthings Cloud Login" + } + } + } +} diff --git a/homeassistant/components/xthings_cloud/switch.py b/homeassistant/components/xthings_cloud/switch.py new file mode 100644 index 00000000000..1c4cd42e75f --- /dev/null +++ b/homeassistant/components/xthings_cloud/switch.py @@ -0,0 +1,42 @@ +"""Switch platform for Xthings Cloud.""" + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import XthingsCloudConfigEntry +from .entity import XthingsCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: XthingsCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch platform.""" + coordinator = entry.runtime_data + entities = [ + XthingsCloudSwitch(coordinator, device_id, device_data) + for device_id, device_data in coordinator.data.items() + if device_data["type"] == "plug" + ] + async_add_entities(entities) + + +class XthingsCloudSwitch(XthingsCloudEntity, SwitchEntity): + """Xthings Cloud switch entity.""" + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.device_data["status"]["on"] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self.coordinator.client.async_plug_on(self._device_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self.coordinator.client.async_plug_off(self._device_id) diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index 07d348bc006..364de2390b8 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -1,7 +1,5 @@ """Support for Yale devices.""" -from __future__ import annotations - from pathlib import Path from typing import cast diff --git a/homeassistant/components/yale/binary_sensor.py b/homeassistant/components/yale/binary_sensor.py index bb9acb16644..196e62aff41 100644 --- a/homeassistant/components/yale/binary_sensor.py +++ b/homeassistant/components/yale/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Yale binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -166,7 +164,7 @@ class YaleDoorbellBinarySensor(YaleDescriptionEntity, BinarySensorEntity): self.async_write_ha_state() def _schedule_update_to_recheck_turn_off_sensor(self) -> None: - """Schedule an update to recheck the sensor to see if it is ready to turn off.""" + """Schedule an update to recheck if sensor is ready to turn off.""" # If the sensor is already off there is nothing to do if not self.is_on: return diff --git a/homeassistant/components/yale/camera.py b/homeassistant/components/yale/camera.py index acabba23b59..d7d8146cea8 100644 --- a/homeassistant/components/yale/camera.py +++ b/homeassistant/components/yale/camera.py @@ -1,7 +1,5 @@ """Support for Yale doorbell camera.""" -from __future__ import annotations - import logging from aiohttp import ClientSession diff --git a/homeassistant/components/yale/data.py b/homeassistant/components/yale/data.py index 12736f7733d..383f31d9358 100644 --- a/homeassistant/components/yale/data.py +++ b/homeassistant/components/yale/data.py @@ -1,7 +1,5 @@ """Support for Yale devices.""" -from __future__ import annotations - from yalexs.lock import LockDetail from yalexs.manager.data import YaleXSData from yalexs_ble import YaleXSBLEDiscovery diff --git a/homeassistant/components/yale/diagnostics.py b/homeassistant/components/yale/diagnostics.py index 7e7f6179e7a..b44c70ab51a 100644 --- a/homeassistant/components/yale/diagnostics.py +++ b/homeassistant/components/yale/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for yale.""" -from __future__ import annotations - from typing import Any from yalexs.const import Brand diff --git a/homeassistant/components/yale/event.py b/homeassistant/components/yale/event.py index 0ea7694be6d..0103cecd55a 100644 --- a/homeassistant/components/yale/event.py +++ b/homeassistant/components/yale/event.py @@ -1,7 +1,5 @@ """Support for yale events.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index edf368ed8d0..fafb9589267 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -1,7 +1,5 @@ """Support for Yale lock.""" -from __future__ import annotations - import logging from typing import Any @@ -131,7 +129,7 @@ class YaleLock(YaleEntity, RestoreEntity, LockEntity): ) async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + """Restore ATTR_CHANGED_BY on startup.""" await super().async_added_to_hass() if not (last_state := await self.async_get_last_state()): diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index d8eea99f1e9..29585c862fa 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -14,5 +14,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"] + "requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"] } diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py index 91ecbea704d..522d28abd53 100644 --- a/homeassistant/components/yale/sensor.py +++ b/homeassistant/components/yale/sensor.py @@ -1,7 +1,5 @@ """Support for Yale sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -167,7 +165,7 @@ class YaleOperatorSensor(YaleEntity, RestoreSensor): return attributes async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + """Restore ATTR_CHANGED_BY on startup.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() diff --git a/homeassistant/components/yale/util.py b/homeassistant/components/yale/util.py index 3462c576fd9..e5b88656efe 100644 --- a/homeassistant/components/yale/util.py +++ b/homeassistant/components/yale/util.py @@ -1,7 +1,5 @@ """Yale util functions.""" -from __future__ import annotations - from datetime import datetime, timedelta from functools import partial diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 5c481719cc9..d66e69087bc 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -1,7 +1,5 @@ """The yale_smart_alarm component.""" -from __future__ import annotations - from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index b443ba016d6..43097677c01 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Yale Alarm.""" -from __future__ import annotations - from typing import TYPE_CHECKING from yalesmartalarmclient.const import ( diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 20fe3648eed..d817d2cb468 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for Yale Alarm.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 0875ab4514d..51e5fa6d3f8 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -1,7 +1,5 @@ """Support for Yale Smart Alarm button.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index d8c1fc80f8f..946543c65cd 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Yale Smart Alarm integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index db63567fa92..b8d25acf991 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Yale integration.""" -from __future__ import annotations - from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -38,15 +36,21 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.locks: list[YaleLock] = [] + def _yale_setup(self) -> tuple[YaleSmartAlarmClient, list[YaleLock]]: + """Set up connection to Yale.""" + yale = YaleSmartAlarmClient( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + locks = yale.get_locks() + return yale, locks + async def _async_setup(self) -> None: """Set up connection to Yale.""" try: - self.yale = await self.hass.async_add_executor_job( - YaleSmartAlarmClient, - self.config_entry.data[CONF_USERNAME], - self.config_entry.data[CONF_PASSWORD], + self.yale, self.locks = await self.hass.async_add_executor_job( + self._yale_setup ) - self.locks = await self.hass.async_add_executor_job(self.yale.get_locks) except AuthenticationError as error: raise ConfigEntryAuthFailed from error except YALE_BASE_ERRORS as error: diff --git a/homeassistant/components/yale_smart_alarm/diagnostics.py b/homeassistant/components/yale_smart_alarm/diagnostics.py index eb7b2be9fb4..955a782b7e2 100644 --- a/homeassistant/components/yale_smart_alarm/diagnostics.py +++ b/homeassistant/components/yale_smart_alarm/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Yale Smart Alarm.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index f4fae531b67..095d1f886d6 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -1,7 +1,5 @@ """Lock for Yale Alarm.""" -from __future__ import annotations - from typing import Any from yalesmartalarmclient import YaleLock, YaleLockState diff --git a/homeassistant/components/yale_smart_alarm/select.py b/homeassistant/components/yale_smart_alarm/select.py index 0b443e762e6..3b2517800be 100644 --- a/homeassistant/components/yale_smart_alarm/select.py +++ b/homeassistant/components/yale_smart_alarm/select.py @@ -1,7 +1,5 @@ """Select for Yale Alarm.""" -from __future__ import annotations - from yalesmartalarmclient import YaleLock, YaleLockVolume from homeassistant.components.select import SelectEntity diff --git a/homeassistant/components/yale_smart_alarm/sensor.py b/homeassistant/components/yale_smart_alarm/sensor.py index 14301d0c6b5..989923a76be 100644 --- a/homeassistant/components/yale_smart_alarm/sensor.py +++ b/homeassistant/components/yale_smart_alarm/sensor.py @@ -1,7 +1,5 @@ """Sensors for Yale Alarm.""" -from __future__ import annotations - from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/yale_smart_alarm/switch.py b/homeassistant/components/yale_smart_alarm/switch.py index e4523a66802..d908432e1bf 100644 --- a/homeassistant/components/yale_smart_alarm/switch.py +++ b/homeassistant/components/yale_smart_alarm/switch.py @@ -1,7 +1,5 @@ """Switches for Yale Alarm.""" -from __future__ import annotations - from typing import Any from yalesmartalarmclient import YaleLock diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 8d3c298643c..539db81dc05 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,7 +1,5 @@ """The Yale Access Bluetooth integration.""" -from __future__ import annotations - from yalexs_ble import ( AuthError, ConnectionInfo, @@ -14,6 +12,7 @@ from yalexs_ble import ( ) from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback @@ -26,6 +25,7 @@ from .const import ( CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, + DOMAIN, ) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher @@ -85,7 +85,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> # If we are starting and the advertisement is not found, do not delay # the setup. We will wait for the advertisement to be found and then # discovery will trigger setup retry. - raise ConfigEntryNotReady("{local_name} ({address}) not advertising yet") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_not_advertising", + translation_placeholders={ + "local_name": local_name, + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, + ) entry.async_on_unload( bluetooth.async_register_callback( diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index dc924486df2..67f624e380b 100644 --- a/homeassistant/components/yalexs_ble/binary_sensor.py +++ b/homeassistant/components/yalexs_ble/binary_sensor.py @@ -1,7 +1,5 @@ """Support for yalexs ble binary sensors.""" -from __future__ import annotations - from yalexs_ble import ConnectionInfo, DoorStatus, LockInfo, LockState from homeassistant.components.binary_sensor import ( @@ -37,5 +35,5 @@ class YaleXSBLEDoorSensor(YALEXSBLEEntity, BinarySensorEntity): self, new_state: LockState, lock_info: LockInfo, connection_info: ConnectionInfo ) -> None: """Update the state.""" - self._attr_is_on = new_state.door == DoorStatus.OPENED + self._attr_is_on = new_state.door is DoorStatus.OPENED super()._async_update_state(new_state, lock_info, connection_info) diff --git a/homeassistant/components/yalexs_ble/config_cache.py b/homeassistant/components/yalexs_ble/config_cache.py index eccfbf3ea9e..f63afdba5e7 100644 --- a/homeassistant/components/yalexs_ble/config_cache.py +++ b/homeassistant/components/yalexs_ble/config_cache.py @@ -1,7 +1,5 @@ """The Yale Access Bluetooth integration.""" -from __future__ import annotations - from yalexs_ble import ValidatedLockConfig from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 01961553311..1e5623a223f 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Yale Access Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any, Self diff --git a/homeassistant/components/yalexs_ble/entity.py b/homeassistant/components/yalexs_ble/entity.py index afa80b8e313..15b583fc977 100644 --- a/homeassistant/components/yalexs_ble/entity.py +++ b/homeassistant/components/yalexs_ble/entity.py @@ -1,7 +1,5 @@ """The yalexs_ble integration entities.""" -from __future__ import annotations - from yalexs_ble import ConnectionInfo, LockInfo, LockState from homeassistant.components import bluetooth diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 1d70b2098e8..477ba7b5750 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -1,7 +1,5 @@ """Support for Yale Access Bluetooth locks.""" -from __future__ import annotations - from typing import Any from yalexs_ble import ConnectionInfo, LockInfo, LockState, LockStatus diff --git a/homeassistant/components/yalexs_ble/models.py b/homeassistant/components/yalexs_ble/models.py index cc6b3697e72..53b66994546 100644 --- a/homeassistant/components/yalexs_ble/models.py +++ b/homeassistant/components/yalexs_ble/models.py @@ -1,7 +1,5 @@ """The yalexs_ble integration models.""" -from __future__ import annotations - from dataclasses import dataclass from yalexs_ble import PushLock diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 01f0d1242a9..beb4937b9c2 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -1,7 +1,5 @@ """Support for yalexs ble sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c4e4210a0b6..59513010759 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -53,6 +53,11 @@ } } }, + "exceptions": { + "device_not_advertising": { + "message": "{local_name} ({address}) is not advertising yet: {reason}" + } + }, "options": { "step": { "device_options": { diff --git a/homeassistant/components/yalexs_ble/util.py b/homeassistant/components/yalexs_ble/util.py index 328aa2b6375..15ff53dd1d4 100644 --- a/homeassistant/components/yalexs_ble/util.py +++ b/homeassistant/components/yalexs_ble/util.py @@ -1,7 +1,5 @@ """The yalexs_ble integration models.""" -from __future__ import annotations - import platform from yalexs_ble import local_name_is_unique diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index c16433b3c37..0753b44335c 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -1,7 +1,5 @@ """Support for Yamaha Receivers.""" -from __future__ import annotations - import logging from typing import Any @@ -409,6 +407,7 @@ class YamahaDeviceZone(MediaPlayerEntity): """Set the current scene.""" try: self.zctrl.scene = scene + # pylint: disable-next=home-assistant-action-swallowed-exception except AssertionError: _LOGGER.warning("Scene '%s' does not exist!", scene) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index edc124890c5..c2418918f7c 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -1,20 +1,17 @@ """The MusicCast integration.""" -from __future__ import annotations - import logging from aiohttp import DummyCookieJar from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN -from .coordinator import MusicCastDataUpdateCoordinator +from .const import CONF_SERIAL, CONF_UPNP_DESC +from .coordinator import MusicCastConfigEntry, MusicCastDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] @@ -38,7 +35,7 @@ async def get_upnp_desc(hass: HomeAssistant, host: str): return upnp_desc -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MusicCastConfigEntry) -> bool: """Set up MusicCast from a config entry.""" if entry.data.get(CONF_UPNP_DESC) is None: @@ -60,8 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() coordinator.musiccast.build_capabilities() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await coordinator.musiccast.device.enable_polling() @@ -71,16 +67,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MusicCastConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][entry.entry_id].musiccast.device.disable_polling() - hass.data[DOMAIN].pop(entry.entry_id) + entry.runtime_data.musiccast.device.disable_polling() return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: MusicCastConfigEntry) -> None: """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 6a98c157001..de9590a9a89 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -1,7 +1,5 @@ """Config flow for MusicCast.""" -from __future__ import annotations - import logging from typing import Any from urllib.parse import urlparse @@ -97,7 +95,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location - # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment + # ssdp_location and hostname have been checked in + # check_yamaha_ssdp so it is safe to ignore type self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] await self.async_set_unique_id(self.serial_number) diff --git a/homeassistant/components/yamaha_musiccast/coordinator.py b/homeassistant/components/yamaha_musiccast/coordinator.py index 13afbe3aa5e..e0d1ee0f468 100644 --- a/homeassistant/components/yamaha_musiccast/coordinator.py +++ b/homeassistant/components/yamaha_musiccast/coordinator.py @@ -1,7 +1,5 @@ """The MusicCast integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING @@ -22,14 +20,19 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) +type MusicCastConfigEntry = ConfigEntry[MusicCastDataUpdateCoordinator] + class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): """Class to manage fetching data from the API.""" - config_entry: ConfigEntry + config_entry: MusicCastConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: MusicCastDevice + self, + hass: HomeAssistant, + config_entry: MusicCastConfigEntry, + client: MusicCastDevice, ) -> None: """Initialize.""" self.musiccast = client diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index 8023b13c10a..9a6a2c6a5cd 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -1,7 +1,5 @@ """The MusicCast integration.""" -from __future__ import annotations - from aiomusiccast.capabilities import Capability from homeassistant.const import ATTR_CONNECTIONS, ATTR_VIA_DEVICE @@ -65,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity): }, manufacturer=BRAND, model=self.coordinator.data.model_name, - sw_version=self.coordinator.data.system_version, + sw_version=str(self.coordinator.data.system_version), ) if self._zone_id == DEFAULT_ZONE: diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 33fb32fffa1..4abbd8706db 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -1,7 +1,5 @@ """Implementation of the musiccast media player.""" -from __future__ import annotations - import contextlib import logging from typing import Any @@ -21,7 +19,6 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity @@ -38,7 +35,7 @@ from .const import ( MEDIA_CLASS_MAPPING, NULL_GROUP, ) -from .coordinator import MusicCastDataUpdateCoordinator +from .coordinator import MusicCastConfigEntry from .entity import MusicCastDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -55,11 +52,11 @@ MUSIC_PLAYER_BASE_SUPPORT = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MusicCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" - coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data name = coordinator.data.network_name @@ -158,7 +155,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def source_mapping(self): - """Return a mapping of the actual source names to their labels configured in the MusicCast App.""" + """Return a mapping of source names to MusicCast App labels.""" ret = {} for inp in self.coordinator.data.zones[self._zone_id].input_list: label = self.coordinator.data.input_names.get(inp, "") @@ -374,6 +371,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): ] if add_media_source: + # pylint: disable-next=home-assistant-action-swallowed-exception with contextlib.suppress(BrowseError): item = await media_source.async_browse_media( self.hass, @@ -561,7 +559,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def is_network_server(self) -> bool: - """Return only true if the current entity is a network server and not a main zone with an attached zone2.""" + """Return true if entity is a network server, not a main zone with zone2.""" return ( self.coordinator.data.group_role == "server" and self.coordinator.data.group_id != NULL_GROUP @@ -597,7 +595,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def is_network_client(self) -> bool: - """Return True if the current entity is a network client and not just a main syncing entity.""" + """Return True if entity is a network client, not just a main sync.""" return ( self.coordinator.data.group_role == "client" and self.coordinator.data.group_id != NULL_GROUP @@ -614,22 +612,25 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): def get_all_mc_entities(self) -> list[MusicCastMediaPlayer]: """Return all media player entities of the musiccast system.""" + entries: list[MusicCastConfigEntry] = ( + self.hass.config_entries.async_loaded_entries(DOMAIN) + ) entities = [] - for coordinator in self.hass.data[DOMAIN].values(): + for entry in entries: entities += [ entity - for entity in coordinator.entities + for entity in entry.runtime_data.entities if isinstance(entity, MusicCastMediaPlayer) ] return entities def get_all_server_entities(self) -> list[MusicCastMediaPlayer]: - """Return all media player entities in the musiccast system, which are in server mode.""" + """Return all media player entities in server mode.""" entities = self.get_all_mc_entities() return [entity for entity in entities if entity.is_server] def get_distribution_num(self) -> int: - """Return the distribution_num (number of clients in the whole musiccast system).""" + """Return the distribution_num (number of clients).""" return sum( len(server.coordinator.data.group_client_list) for server in self.get_all_server_entities() @@ -666,9 +667,10 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def musiccast_group(self) -> list[MusicCastMediaPlayer]: - """Return all media players of the current group, if the media player is server.""" + """Return all media players of the current group.""" if self.is_client: - # If we are a client we can still share group information, but we will take them from the server. + # If we are a client we can still share group + # information, but we will take them from the server. if (server := self.group_server) != self: return server.musiccast_group @@ -681,9 +683,11 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def musiccast_zone_entity(self) -> MusicCastMediaPlayer: - """Return the entity of the zone, which is using MusicCast at the moment, if there is one, self else. + """Return the entity of the zone using MusicCast. - It is possible that multiple zones use MusicCast as client at the same time. In this case the first one is + It is possible that multiple zones use MusicCast + as client at the same time. In this case the first + one is returned. """ for entity in self.other_zones: @@ -694,7 +698,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): async def update_all_mc_entities(self, check_clients=False): """Update the whole musiccast system when group data change.""" - # First update all servers as they provide the group information for their clients + # First update all servers as they provide the + # group information for their clients for entity in self.get_all_server_entities(): if check_clients or self.coordinator.musiccast.group_reduce_by_source: await entity.async_check_client_list() @@ -728,7 +733,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): await self.async_turn_on() if not self.is_server and self.musiccast_zone_entity.is_server: - # The MusicCast Distribution Module of this device is already in use. To use it as a server, we first + # The MusicCast Distribution Module of this + # device is already in use. To use it as a server, + # we first # have to unjoin and wait until the servers are updated. await self.musiccast_zone_entity.async_server_close_group() elif self.musiccast_zone_entity.is_client: @@ -746,6 +753,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if client != self: try: network_join = await client.async_client_join(group, self) + # pylint: disable-next=home-assistant-action-swallowed-exception except MusicCastGroupException: _LOGGER.warning( ( @@ -845,7 +853,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): and self.coordinator.data.group_id == server.coordinator.data.group_id and self.coordinator.data.group_role == "client" ): - # The device is already part of this group (e.g. main zone is also a client of this group). + # The device is already part of this group + # (e.g. main zone is also a client of this group). # Just select mc_link as source await self.coordinator.musiccast.zone_join(self._zone_id) return False diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 0de14ef142d..00bd519984e 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -1,26 +1,22 @@ """Number entities for musiccast.""" -from __future__ import annotations - from aiomusiccast.capabilities import NumberSetter from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MusicCastDataUpdateCoordinator +from .coordinator import MusicCastConfigEntry, MusicCastDataUpdateCoordinator from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MusicCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast number entities based on a config entry.""" - coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data number_entities = [ NumberCapability(coordinator, capability) diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index 133cb4c4d7b..d6466eead8f 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -1,26 +1,23 @@ """The select entities for musiccast.""" -from __future__ import annotations - from aiomusiccast.capabilities import OptionSetter from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TRANSLATION_KEY_MAPPING -from .coordinator import MusicCastDataUpdateCoordinator +from .const import TRANSLATION_KEY_MAPPING +from .coordinator import MusicCastConfigEntry, MusicCastDataUpdateCoordinator from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MusicCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast select entities based on a config entry.""" - coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data select_entities = [ SelectableCapability(coordinator, capability) diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py index 148f09930f3..4506fe5b48e 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -5,22 +5,20 @@ from typing import Any from aiomusiccast.capabilities import BinarySetter from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MusicCastDataUpdateCoordinator +from .coordinator import MusicCastConfigEntry from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MusicCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" - coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data switch_entities = [ SwitchCapability(coordinator, capability) diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index e6ecc0ee0b8..269b81b55dc 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -1,7 +1,5 @@ """Service for obtaining information about closer bus from Transport Yandex Service.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index 3f0bf7c32d9..96daa42561b 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -1,16 +1,12 @@ """The Yardian integration.""" -from __future__ import annotations - from pyyardian import AsyncYardianClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import YardianUpdateCoordinator +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -19,27 +15,28 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YardianConfigEntry) -> bool: """Set up Yardian from a config entry.""" host = entry.data[CONF_HOST] access_token = entry.data[CONF_ACCESS_TOKEN] - controller = AsyncYardianClient(async_get_clientsession(hass), host, access_token) + # Change this line to use .create() + # This ensures the coordinator's controller knows if it is YP or YC + controller = await AsyncYardianClient.create( + async_get_clientsession(hass), host, token=access_token + ) + coordinator = YardianUpdateCoordinator(hass, entry, controller) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YardianConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yardian/binary_sensor.py b/homeassistant/components/yardian/binary_sensor.py index 12edcd02fb9..d8077cf5314 100644 --- a/homeassistant/components/yardian/binary_sensor.py +++ b/homeassistant/components/yardian/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors for Yardian integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -10,14 +8,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import YardianUpdateCoordinator +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianEntity, YardianZoneEntity @dataclass(kw_only=True, frozen=True) @@ -77,44 +73,36 @@ SENSOR_DESCRIPTIONS: tuple[YardianBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yardian binary sensors.""" - coordinator: YardianUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data + # 1. Global/Main device sensors entities: list[BinarySensorEntity] = [ YardianBinarySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS ] - zone_descriptions = [ - YardianBinarySensorEntityDescription( + # 2. Zone/Child device sensors + for zone_id in range(len(coordinator.data.zones)): + description = YardianBinarySensorEntityDescription( key=f"zone_enabled_{zone_id}", - translation_key="zone_enabled", + translation_key="enabled", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=_zone_value_factory(zone_id), - translation_placeholders={"zone": str(zone_id + 1)}, ) - for zone_id in range(len(coordinator.data.zones)) - ] - - entities.extend( - YardianBinarySensor(coordinator, description) - for description in zone_descriptions - ) + entities.append(YardianZoneBinarySensor(coordinator, description, zone_id)) async_add_entities(entities) -class YardianBinarySensor( - CoordinatorEntity[YardianUpdateCoordinator], BinarySensorEntity -): - """Representation of a Yardian binary sensor based on a description.""" +class YardianBinarySensor(YardianEntity, BinarySensorEntity): + """Representation of a Yardian binary sensor assigned to the main device.""" entity_description: YardianBinarySensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -125,7 +113,28 @@ class YardianBinarySensor( super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.yid}-{description.key}" - self._attr_device_info = coordinator.device_info + + @property + def is_on(self) -> bool | None: + """Return the current state based on the description's value function.""" + return self.entity_description.value_fn(self.coordinator) + + +class YardianZoneBinarySensor(YardianZoneEntity, BinarySensorEntity): + """Representation of a Yardian binary sensor assigned to a zone child device.""" + + entity_description: YardianBinarySensorEntityDescription + + def __init__( + self, + coordinator: YardianUpdateCoordinator, + description: YardianBinarySensorEntityDescription, + zone_id: int, + ) -> None: + """Initialize the Yardian zone binary sensor.""" + super().__init__(coordinator, zone_id) + self.entity_description = description + self._attr_unique_id = f"{coordinator.yid}-{description.key}" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py index 0a947537db0..b7863918346 100644 --- a/homeassistant/components/yardian/config_flow.py +++ b/homeassistant/components/yardian/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Yardian integration.""" -from __future__ import annotations - import logging from typing import Any @@ -36,10 +34,10 @@ class YardianConfigFlow(ConfigFlow, domain=DOMAIN): async def async_fetch_device_info(self, host: str, access_token: str) -> DeviceInfo: """Fetch device info from Yardian.""" - yarcli = AsyncYardianClient( + yarcli = await AsyncYardianClient.create( async_get_clientsession(self.hass), host, - access_token, + token=access_token, ) return await yarcli.fetch_device_info() diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py index b4e75f2367b..a4b5d5926fb 100644 --- a/homeassistant/components/yardian/const.py +++ b/homeassistant/components/yardian/const.py @@ -2,6 +2,6 @@ DOMAIN = "yardian" MANUFACTURER = "Aeon Matrix" -PRODUCT_NAME = "Yardian Smart Sprinkler" +PRODUCT_NAME = "Yardian Smart Sprinkler Controller" DEFAULT_WATERING_DURATION = 6 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index 8028377daf4..b8c2bbca176 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -1,7 +1,5 @@ """Update coordinators for Yardian.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass import datetime @@ -40,15 +38,18 @@ class YardianCoordinatorData: oper_info: OperationInfo +type YardianConfigEntry = ConfigEntry[YardianUpdateCoordinator] + + class YardianUpdateCoordinator(DataUpdateCoordinator[YardianCoordinatorData]): """Coordinator for Yardian API calls.""" - config_entry: ConfigEntry + config_entry: YardianConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: YardianConfigEntry, controller: AsyncYardianClient, ) -> None: """Initialize Yardian API communication.""" diff --git a/homeassistant/components/yardian/entity.py b/homeassistant/components/yardian/entity.py new file mode 100644 index 00000000000..a590d1cea71 --- /dev/null +++ b/homeassistant/components/yardian/entity.py @@ -0,0 +1,35 @@ +"""Base entities for Yardian integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import YardianUpdateCoordinator + + +class YardianEntity(CoordinatorEntity[YardianUpdateCoordinator]): + """Base class for Yardian entities assigned to the main device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: YardianUpdateCoordinator) -> None: + """Initialize the main device entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + + +class YardianZoneEntity(CoordinatorEntity[YardianUpdateCoordinator]): + """Base class for Yardian entities assigned to a zone child device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: YardianUpdateCoordinator, zone_id: int) -> None: + """Initialize the zone device entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.yid}_{zone_id}")}, + name=coordinator.data.zones[zone_id].name, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.yid), + ) diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json index 6023657277e..5a4f8525587 100644 --- a/homeassistant/components/yardian/manifest.json +++ b/homeassistant/components/yardian/manifest.json @@ -1,10 +1,10 @@ { "domain": "yardian", "name": "Yardian", - "codeowners": ["@h3l1o5"], + "codeowners": ["@aeon-matrix"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yardian", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyyardian==1.1.1"] + "requirements": ["pyyardian==1.4.0"] } diff --git a/homeassistant/components/yardian/sensor.py b/homeassistant/components/yardian/sensor.py index 3be0ddee76b..86bcb9ae3cf 100644 --- a/homeassistant/components/yardian/sensor.py +++ b/homeassistant/components/yardian/sensor.py @@ -1,7 +1,5 @@ """Sensors for Yardian integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -11,16 +9,14 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import YardianUpdateCoordinator +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianEntity # Values above this threshold indicate the API returned an absolute # timestamp instead of a relative delay, so convert to a remaining delta. @@ -56,6 +52,7 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, value_fn=lambda coordinator: coordinator.data.oper_info.get("iRainDelay"), ), YardianSensorEntityDescription( @@ -71,6 +68,7 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=_zone_delay_value, ), YardianSensorEntityDescription( @@ -80,6 +78,7 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=lambda coordinator: coordinator.data.oper_info.get( "iWaterHammerDuration" ), @@ -89,22 +88,21 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yardian sensors.""" - coordinator: YardianUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( YardianSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS ) -class YardianSensor(CoordinatorEntity[YardianUpdateCoordinator], SensorEntity): +class YardianSensor(YardianEntity, SensorEntity): """Representation of a Yardian sensor defined by description.""" entity_description: YardianSensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -115,7 +113,6 @@ class YardianSensor(CoordinatorEntity[YardianUpdateCoordinator], SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.yid}_{description.key}" - self._attr_device_info = coordinator.device_info @property def native_value(self) -> StateType: diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 1f20b0a37b6..50db7dd91cf 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -32,7 +32,7 @@ "name": "Watering running" }, "zone_enabled": { - "name": "Zone {zone} enabled" + "name": "Enabled" } }, "sensor": { @@ -44,7 +44,7 @@ "name": "Rain delay" }, "water_hammer_duration": { - "name": "Water hammer reduction" + "name": "Water hammer duration" }, "zone_delay": { "name": "Zone delay" diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index ba98fa2aaaa..4a57da991f6 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -1,21 +1,18 @@ """Support for Yardian integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_WATERING_DURATION, DOMAIN -from .coordinator import YardianUpdateCoordinator +from .const import DEFAULT_WATERING_DURATION +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianZoneEntity SERVICE_START_IRRIGATION = "start_irrigation" SERVICE_SCHEMA_START_IRRIGATION: VolDictType = { @@ -25,11 +22,11 @@ SERVICE_SCHEMA_START_IRRIGATION: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Yardian irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( YardianSwitch( coordinator, @@ -46,23 +43,15 @@ async def async_setup_entry( ) -class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): +class YardianSwitch(YardianZoneEntity, SwitchEntity): """Representation of a Yardian switch.""" - _attr_has_entity_name = True - _attr_translation_key = "switch" + _attr_name = None - def __init__(self, coordinator: YardianUpdateCoordinator, zone_id) -> None: + def __init__(self, coordinator: YardianUpdateCoordinator, zone_id: int) -> None: """Initialize a Yardian Switch Device.""" - super().__init__(coordinator) - self._zone_id = zone_id + super().__init__(coordinator, zone_id) self._attr_unique_id = f"{coordinator.yid}-{zone_id}" - self._attr_device_info = coordinator.device_info - - @property - def name(self) -> str: - """Return the zone name.""" - return self.coordinator.data.zones[self._zone_id].name @property def is_on(self) -> bool: diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index cb24edae1fd..fa1edc29826 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1,7 +1,5 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" -from __future__ import annotations - import logging import voluptuous as vol @@ -37,9 +35,7 @@ from .const import ( CONF_NIGHTLIGHT_SWITCH_TYPE, CONF_SAVE_ON_CHANGE, CONF_TRANSITION, - DATA_CONFIG_ENTRIES, - DATA_CUSTOM_EFFECTS, - DATA_DEVICE, + DATA_CUSTOM_EFFECTS_KEY, DEFAULT_MODE_MUSIC, DEFAULT_NAME, DEFAULT_NIGHTLIGHT_SWITCH, @@ -56,6 +52,8 @@ from .const import ( from .device import YeelightDevice, async_format_id from .scanner import YeelightScanner +type YeelightConfigEntry = ConfigEntry[YeelightDevice] + _LOGGER = logging.getLogger(__name__) @@ -116,10 +114,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" conf = config.get(DOMAIN, {}) - hass.data[DOMAIN] = { - DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), - DATA_CONFIG_ENTRIES: {}, - } + hass.data[DATA_CUSTOM_EFFECTS_KEY] = conf.get(CONF_CUSTOM_EFFECTS, []) # Make sure the scanner is always started in case we are # going to retry via ConfigEntryNotReady and the bulb has changed # ip @@ -141,13 +136,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_initialize( hass: HomeAssistant, - entry: ConfigEntry, + entry: YeelightConfigEntry, device: YeelightDevice, ) -> None: """Initialize a Yeelight device.""" - entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {} await device.async_setup() - entry_data[DATA_DEVICE] = device + entry.runtime_data = device if ( device.capabilities @@ -160,7 +154,9 @@ async def _async_initialize( @callback -def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _async_normalize_config_entry( + hass: HomeAssistant, entry: YeelightConfigEntry +) -> None: """Move options from data for imported entries. Initialize options with default values for other entries. @@ -203,7 +199,7 @@ def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> No ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YeelightConfigEntry) -> bool: """Set up Yeelight from a config entry.""" _async_normalize_config_entry(hass, entry) @@ -235,15 +231,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YeelightConfigEntry) -> bool: """Unload a config entry.""" - data_config_entries = hass.data[DOMAIN][DATA_CONFIG_ENTRIES] - data_config_entries.pop(entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_get_device( - hass: HomeAssistant, host: str, entry: ConfigEntry + hass: HomeAssistant, host: str, entry: YeelightConfigEntry ) -> YeelightDevice: # Get model from config and capabilities model = entry.options.get(CONF_MODEL) or entry.data.get(CONF_DETECTED_MODEL) diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 9d9657892f0..5da8e904523 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -3,12 +3,12 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN +from . import YeelightConfigEntry +from .const import DATA_UPDATED from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) @@ -16,11 +16,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" - device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] + device = config_entry.runtime_data if device.is_nightlight_supported: _LOGGER.debug("Adding nightlight mode sensor for %s", device.name) async_add_entities([YeelightNightlightModeSensor(device, config_entry)]) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index cc3ab35f684..1747cdd0e5c 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Yeelight integration.""" -from __future__ import annotations - import logging from typing import Any, Self from urllib.parse import urlparse @@ -13,7 +11,6 @@ from yeelight.main import get_known_models from homeassistant.components import onboarding from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -28,6 +25,7 @@ from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType +from . import YeelightConfigEntry from .const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, @@ -62,7 +60,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, ) -> OptionsFlowHandler: """Return the options flow.""" return OptionsFlowHandler() @@ -108,7 +106,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ID ): continue - reload = entry.state == ConfigEntryState.SETUP_RETRY + reload = entry.state is ConfigEntryState.SETUP_RETRY if entry.data.get(CONF_HOST) != self._discovered_ip: self.hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_HOST: self._discovered_ip} @@ -145,7 +143,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._discovered_ip == self._discovered_ip # noqa: SLF001 + return other_flow._discovered_ip == self._discovered_ip async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/yeelight/const.py b/homeassistant/components/yeelight/const.py index e9ba80bca95..2ef3c2471fc 100644 --- a/homeassistant/components/yeelight/const.py +++ b/homeassistant/components/yeelight/const.py @@ -1,10 +1,13 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from datetime import timedelta +from typing import Any from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "yeelight" +DATA_CUSTOM_EFFECTS_KEY: HassKey[list[dict[str, Any]]] = HassKey(DOMAIN) STATE_CHANGE_TIME = 0.40 # seconds @@ -43,12 +46,6 @@ CONF_CUSTOM_EFFECTS = "custom_effects" CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" -DATA_CONFIG_ENTRIES = "config_entries" -DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_DEVICE = "device" -DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" -DATA_PLATFORMS_LOADED = "platforms_loaded" - ATTR_COUNT = "count" ATTR_ACTION = "action" ATTR_TRANSITIONS = "transitions" diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index 09086dc91d9..e827f13742a 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -1,7 +1,5 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/yeelight/entity.py b/homeassistant/components/yeelight/entity.py index c0bc45f6a51..0584b5782e8 100644 --- a/homeassistant/components/yeelight/entity.py +++ b/homeassistant/components/yeelight/entity.py @@ -1,7 +1,5 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index b2eaed79917..56a0cd1dd35 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,5 @@ """Light platform support for yeelight.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging import math @@ -39,7 +37,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType from homeassistant.util import color as color_util -from . import YEELIGHT_FLOW_TRANSITION_SCHEMA +from . import YEELIGHT_FLOW_TRANSITION_SCHEMA, YeelightConfigEntry from .const import ( ACTION_RECOVER, ATTR_ACTION, @@ -51,11 +49,8 @@ from .const import ( CONF_NIGHTLIGHT_SWITCH, CONF_SAVE_ON_CHANGE, CONF_TRANSITION, - DATA_CONFIG_ENTRIES, - DATA_CUSTOM_EFFECTS, - DATA_DEVICE, + DATA_CUSTOM_EFFECTS_KEY, DATA_UPDATED, - DOMAIN, MODELS_WITH_DELAYED_ON_TRANSITION, POWER_STATE_CHANGE_TIME, ) @@ -220,7 +215,9 @@ def _transitions_config_parser(transitions): @callback -def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: +def _parse_custom_effects( + effects_config: list[dict[str, Any]], +) -> dict[str, dict[str, Any]]: effects = {} for config in effects_config: params = config[CONF_FLOW_PARAMS] @@ -278,13 +275,13 @@ def _async_cmd[_YeelightBaseLightT: YeelightBaseLight, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YeelightConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" - custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) + custom_effects = _parse_custom_effects(hass.data[DATA_CUSTOM_EFFECTS_KEY]) - device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] + device = config_entry.runtime_data _LOGGER.debug("Adding %s", device.name) nl_switch_light = device.config.get(CONF_NIGHTLIGHT_SWITCH) @@ -598,6 +595,7 @@ class YeelightBaseLight(YeelightEntity, LightEntity): """Set the music mode on or off.""" try: await self._async_set_music_mode(music_mode) + # pylint: disable-next=home-assistant-action-swallowed-exception except AssertionError as ex: _LOGGER.error("Unable to turn on music mode, consider disabling it: %s", ex) diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 75156ab019b..51ea2a408e9 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -1,7 +1,5 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" -from __future__ import annotations - import asyncio from collections.abc import ValuesView import contextlib diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 4cacd1def22..ceb7736bbde 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -1,7 +1,5 @@ """Support for Yeelight Sunflower color bulbs (not Yeelight Blue or WiFi).""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 10b84f933ef..49fed941560 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,7 +1,5 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" -from __future__ import annotations - import logging from aioftp import Client, StatusCodeError diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 54a903302d3..a1917c84787 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -1,9 +1,6 @@ """The yolink integration.""" -from __future__ import annotations - import asyncio -from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -13,7 +10,7 @@ from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome from yolink.message_listener import MessageListener -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -31,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import ATTR_LORA_INFO, DOMAIN, SUPPORTED_REMOTERS, YOLINK_EVENT -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator, YoLinkHomeStore from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS from .services import async_setup_services @@ -58,27 +55,23 @@ PLATFORMS = [ class YoLinkHomeMessageListener(MessageListener): """YoLink home message listener.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: YoLinkConfigEntry) -> None: """Init YoLink home message listener.""" self._hass = hass self._entry = entry def on_message(self, device: YoLinkDevice, msg_data: dict[str, Any]) -> None: """On YoLink home message received.""" - entry_data = self._hass.data[DOMAIN].get(self._entry.entry_id) - if not entry_data: - return - device_coordinators = entry_data.device_coordinators - if not device_coordinators: - return - device_coordinator: YoLinkCoordinator = device_coordinators.get( - device.device_id - ) - if device_coordinator is None: + if self._entry.state is not ConfigEntryState.LOADED or not ( + device_coordinator := self._entry.runtime_data.device_coordinators.get( + device.device_id + ) + ): return + device_coordinator.dev_online = True - if (loraInfo := msg_data.get(ATTR_LORA_INFO)) is not None: - device_coordinator.dev_net_type = loraInfo.get("devNetType") + if (lora_info := msg_data.get(ATTR_LORA_INFO)) is not None: + device_coordinator.dev_net_type = lora_info.get("devNetType") device_coordinator.async_set_updated_data(msg_data) # handling events if ( @@ -105,14 +98,6 @@ class YoLinkHomeMessageListener(MessageListener): self._hass.bus.async_fire(YOLINK_EVENT, event_data) -@dataclass -class YoLinkHomeStore: - """YoLink home store.""" - - home_instance: YoLinkHome - device_coordinators: dict[str, YoLinkCoordinator] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up YoLink.""" @@ -121,9 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bool: """Set up yolink from a config entry.""" - hass.data.setdefault(DOMAIN, {}) try: implementation = await async_get_config_entry_implementation(hass, entry) except ImplementationUnavailableError as err: @@ -174,9 +158,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Not failure by fetching device state device_coordinator.data = {} device_coordinators[device.device_id] = device_coordinator - hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( - yolink_home, device_coordinators - ) + entry.runtime_data = YoLinkHomeStore(yolink_home, device_coordinators) # Clean up yolink devices which are not associated to the account anymore. device_registry = dr.async_get(hass) @@ -204,9 +186,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hass.data[DOMAIN][entry.entry_id].home_instance.async_unload() - hass.data[DOMAIN].pop(entry.entry_id) + await entry.runtime_data.home_instance.async_unload() return unload_ok diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index cfec02ca3e2..d0f53f663d3 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -1,7 +1,5 @@ """YoLink BinarySensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -23,16 +21,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - DEV_MODEL_WATER_METER_YS5018_EC, - DEV_MODEL_WATER_METER_YS5018_UC, - DOMAIN, -) -from .coordinator import YoLinkCoordinator +from .const import DEV_MODEL_WATER_METER_YS5018_EC, DEV_MODEL_WATER_METER_YS5018_UC +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -181,11 +174,11 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators binary_sensor_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -208,7 +201,7 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkBinarySensorEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index 65253094fa9..4aa5dfb3812 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -1,7 +1,5 @@ """YoLink Thermostat.""" -from __future__ import annotations - from typing import Any from yolink.const import ATTR_DEVICE_THERMOSTAT @@ -19,13 +17,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity YOLINK_MODEL_2_HA = { @@ -46,11 +42,11 @@ YOLINK_ACTION_2_HA = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Thermostat from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators entities = [ YoLinkClimateEntity(config_entry, device_coordinator) for device_coordinator in device_coordinators.values() @@ -66,7 +62,7 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Thermostat.""" diff --git a/homeassistant/components/yolink/config_flow.py b/homeassistant/components/yolink/config_flow.py index 2e96dcf9f8c..c667fe4bd84 100644 --- a/homeassistant/components/yolink/config_flow.py +++ b/homeassistant/components/yolink/config_flow.py @@ -1,7 +1,5 @@ """Config flow for yolink.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 2c914e84a08..3a4057c4145 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -1,8 +1,7 @@ """YoLink DataUpdateCoordinator.""" -from __future__ import annotations - import asyncio +from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging from typing import Any @@ -10,6 +9,7 @@ from typing import Any from yolink.client_request import ClientRequest from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from yolink.home_manager import YoLinkHome from yolink.model import BRDP from homeassistant.config_entries import ConfigEntry @@ -22,15 +22,26 @@ from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIM _LOGGER = logging.getLogger(__name__) +@dataclass +class YoLinkHomeStore: + """YoLink home store.""" + + home_instance: YoLinkHome + device_coordinators: dict[str, YoLinkCoordinator] + + +type YoLinkConfigEntry = ConfigEntry[YoLinkHomeStore] + + class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" - config_entry: ConfigEntry + config_entry: YoLinkConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, device: YoLinkDevice, paired_device: YoLinkDevice | None = None, ) -> None: @@ -61,6 +72,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): device_reporttime = device_state_resp.data.get("reportAt") if device_reporttime is not None: rpt_time_delta = ( + # pylint: disable-next=home-assistant-enforce-utcnow datetime.now(tz=UTC).replace(tzinfo=None) - datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ") ).total_seconds() diff --git a/homeassistant/components/yolink/cover.py b/homeassistant/components/yolink/cover.py index b1cfc3681cc..4b18e4a4b9d 100644 --- a/homeassistant/components/yolink/cover.py +++ b/homeassistant/components/yolink/cover.py @@ -1,7 +1,5 @@ """YoLink Garage Door.""" -from __future__ import annotations - from typing import Any from yolink.client_request import ClientRequest @@ -12,22 +10,20 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink garage door from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators entities = [ YoLinkCoverEntity(config_entry, device_coordinator) for device_coordinator in device_coordinators.values() @@ -44,7 +40,7 @@ class YoLinkCoverEntity(YoLinkEntity, CoverEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink garage door entity.""" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py index 6f5ed8b24fa..cea946325fb 100644 --- a/homeassistant/components/yolink/device_trigger.py +++ b/homeassistant/components/yolink/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for YoLink.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index ecc42ad1a0e..70868b0d259 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -1,19 +1,16 @@ """Support for YoLink Device.""" -from __future__ import annotations - from abc import abstractmethod from typing import Any from yolink.client_request import ClientRequest -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): @@ -23,7 +20,7 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Entity.""" diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index 54470673fa5..abdc32c0f66 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -1,29 +1,25 @@ """YoLink Dimmer.""" -from __future__ import annotations - from typing import Any from yolink.client_request import ClientRequest from yolink.const import ATTR_DEVICE_DIMMER from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Dimmer from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators entities = [ YoLinkDimmerEntity(config_entry, device_coordinator) for device_coordinator in device_coordinators.values() @@ -41,7 +37,7 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Dimmer entity.""" diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index 5e244dd08f2..239f62c56c0 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -1,29 +1,25 @@ """YoLink Lock V1/V2.""" -from __future__ import annotations - from typing import Any from yolink.client_request import ClientRequest from yolink.const import ATTR_DEVICE_LOCK, ATTR_DEVICE_LOCK_V2 from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink lock from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators entities = [ YoLinkLockEntity(config_entry, device_coordinator) for device_coordinator in device_coordinators.values() @@ -40,7 +36,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Lock.""" diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 87dbb9282bf..4af9013dc4c 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/yolink", "integration_type": "hub", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.6.3"] + "requirements": ["yolink-api==0.6.5"] } diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py index c643a20d0ea..c6f80739c0a 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -1,7 +1,5 @@ """YoLink device number type config settings.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -15,12 +13,10 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity OPTIONS_VOLUME = "options_volume" @@ -55,7 +51,6 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] native_max_value=16, mode=NumberMode.SLIDER, native_step=1.0, - native_unit_of_measurement=None, exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES, should_update_entity=lambda value: value is not None, value=get_volume_value, @@ -65,11 +60,11 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device number type config option entity from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators config_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -94,7 +89,7 @@ class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkNumberTypeConfigEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/select.py b/homeassistant/components/yolink/select.py index 030b193edff..4c01b876a42 100644 --- a/homeassistant/components/yolink/select.py +++ b/homeassistant/components/yolink/select.py @@ -1,7 +1,5 @@ """YoLink select platform.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -12,12 +10,10 @@ from yolink.device import YoLinkDevice from yolink.message_resolver import sprinkler_message_resolve from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -66,11 +62,11 @@ SELECTOR_MAPPINGS: tuple[YoLinkSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink select from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators async_add_entities( YoLinkSelectEntity(config_entry, selector_device_coordinator, description) for selector_device_coordinator in device_coordinators.values() @@ -87,7 +83,7 @@ class YoLinkSelectEntity(YoLinkEntity, SelectEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSelectEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 67a9dd64a04..aae262bbc6c 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -1,7 +1,5 @@ """YoLink Sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -44,7 +42,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -76,9 +73,8 @@ from .const import ( DEV_MODEL_TH_SENSOR_YS8014_UC, DEV_MODEL_TH_SENSOR_YS8017_EC, DEV_MODEL_TH_SENSOR_YS8017_UC, - DOMAIN, ) -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -423,11 +419,11 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators sensor_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -452,7 +448,7 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index 5bc5f2f9660..5c6cc6e9629 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -40,12 +40,12 @@ def async_setup_services(hass: HomeAssistant) -> None: continue if entry.domain == DOMAIN: break - if entry is None or entry.state != ConfigEntryState.LOADED: + if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_config_entry", ) - home_store = hass.data[DOMAIN][entry.entry_id] + home_store = entry.runtime_data for identifier in device_entry.identifiers: if ( device_coordinator := home_store.device_coordinators.get( diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 9ff76b29a9a..15bcbd06c84 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -1,7 +1,5 @@ """YoLink Siren.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -15,12 +13,10 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -45,11 +41,11 @@ DEVICE_TYPE = [ATTR_DEVICE_SIREN] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink siren from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators siren_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -72,7 +68,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSirenEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 999ec6c1aba..07d22076618 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -1,7 +1,5 @@ """YoLink Switch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -21,12 +19,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN -from .coordinator import YoLinkCoordinator +from .const import DEV_MODEL_MULTI_OUTLET_YS6801 +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -121,11 +118,11 @@ DEVICE_TYPE = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink switch from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators switch_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -146,7 +143,7 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSwitchEntityDescription, ) -> None: diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 1683f600715..8b2538cddb6 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -1,7 +1,5 @@ """YoLink Valve.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -22,13 +20,12 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN -from .coordinator import YoLinkCoordinator +from .coordinator import YoLinkConfigEntry, YoLinkCoordinator from .entity import YoLinkEntity @@ -109,11 +106,11 @@ DEVICE_TYPE = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink valve from a config entry.""" - device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + device_coordinators = config_entry.runtime_data.device_coordinators valve_device_coordinators = [ device_coordinator for device_coordinator in device_coordinators.values() @@ -134,7 +131,7 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: YoLinkConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkValveEntityDescription, ) -> None: diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py new file mode 100644 index 00000000000..efa7898eec5 --- /dev/null +++ b/homeassistant/components/yoto/__init__.py @@ -0,0 +1,45 @@ +"""The Yoto integration.""" + +import aiohttp + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import DOMAIN +from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool: + """Set up Yoto from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + session = OAuth2Session(hass, entry, implementation) + + try: + await session.async_ensure_token_valid() + except (aiohttp.ClientError, OAuth2TokenRequestError) as err: + raise ConfigEntryNotReady from err + + coordinator = YotoDataUpdateCoordinator(hass, entry, session) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool: + """Unload a Yoto config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yoto/application_credentials.py b/homeassistant/components/yoto/application_credentials.py new file mode 100644 index 00000000000..816ba0b621a --- /dev/null +++ b/homeassistant/components/yoto/application_credentials.py @@ -0,0 +1,26 @@ +"""Application credentials platform for the Yoto integration.""" + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + +AUTHORIZE_URL = "https://login.yotoplay.com/authorize" +TOKEN_URL = "https://login.yotoplay.com/oauth/token" + + +async def async_get_auth_implementation( + hass: HomeAssistant, + auth_domain: str, + credential: ClientCredential, +) -> LocalOAuth2ImplementationWithPkce: + """Return a Yoto OAuth2 implementation with PKCE.""" + return LocalOAuth2ImplementationWithPkce( + hass, + auth_domain, + credential.client_id, + AUTHORIZE_URL, + TOKEN_URL, + credential.client_secret, + ) diff --git a/homeassistant/components/yoto/config_flow.py b/homeassistant/components/yoto/config_flow.py new file mode 100644 index 00000000000..19e5e104089 --- /dev/null +++ b/homeassistant/components/yoto/config_flow.py @@ -0,0 +1,43 @@ +"""Config flow for the Yoto integration.""" + +import logging +from typing import Any + +from yoto_api import YotoError, get_account_id + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES + + +class YotoOAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Authorize Home Assistant with a Yoto account using OAuth2.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return the logger used for the OAuth2 flow.""" + return _LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Append Yoto's audience and scopes to the authorize URL.""" + return { + "audience": YOTO_AUDIENCE, + "scope": " ".join(YOTO_SCOPES), + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Identify the Yoto account from the access token.""" + try: + user_id = get_account_id(data["token"]["access_token"]) + except YotoError: + return self.async_abort(reason="oauth_unauthorized") + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Yoto", data=data) diff --git a/homeassistant/components/yoto/const.py b/homeassistant/components/yoto/const.py new file mode 100644 index 00000000000..1ed7e25d416 --- /dev/null +++ b/homeassistant/components/yoto/const.py @@ -0,0 +1,26 @@ +"""Constants for the Yoto integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "yoto" + +_LOGGER = logging.getLogger(__package__) + +YOTO_AUDIENCE = "https://api.yotoplay.com" + +YOTO_SCOPES = [ + "offline_access", + "family:view", + "family:devices:view", + "family:devices:control", + "family:devices:manage", + "family:library:view", + "user:content:view", + "user:icons:manage", +] + +SCAN_INTERVAL = timedelta(minutes=5) +STATUS_PUSH_INTERVAL = timedelta(seconds=60) + +MANUFACTURER = "Yoto" diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py new file mode 100644 index 00000000000..972aa446088 --- /dev/null +++ b/homeassistant/components/yoto/coordinator.py @@ -0,0 +1,139 @@ +"""Coordinator for the Yoto integration.""" + +from datetime import datetime + +import aiohttp +from yoto_api import Token, YotoClient, YotoError, YotoPlayer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL + +type YotoConfigEntry = ConfigEntry[YotoDataUpdateCoordinator] + + +class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): + """Coordinator that drives the Yoto cloud polling cycle.""" + + config_entry: YotoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: YotoConfigEntry, + session: OAuth2Session, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._session = session + self.client = YotoClient(session=async_get_clientsession(hass)) + self._sync_token() + + def _sync_token(self) -> None: + """Sync the OAuth2 access token to the Yoto client.""" + token = self._session.token + self.client.token = Token( + access_token=token[CONF_ACCESS_TOKEN], + refresh_token=token.get("refresh_token", ""), + token_type=token.get("token_type", "Bearer"), + valid_until=dt_util.utc_from_timestamp(token["expires_at"]), + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.client.refresh() + except YotoError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + await self._async_load_library() + + try: + await self.client.connect_events( + list(self.client.players), self._mqtt_event + ) + except YotoError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + # The MQTT data/status topic is not pushed spontaneously; the firmware + # only emits it in response to a command/status/request publish. + self.config_entry.async_on_unload( + async_track_time_interval( + self.hass, self._async_status_push_tick, STATUS_PUSH_INTERVAL + ) + ) + + async def _async_update_data(self) -> dict[str, YotoPlayer]: + """Fetch fresh data from the Yoto cloud.""" + # _async_setup already populated the client; skip the duplicate first fetch. + if self.data is None: + return self.client.players + + try: + await self._session.async_ensure_token_valid() + except (aiohttp.ClientError, OAuth2TokenRequestError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + self._sync_token() + + try: + await self.client.refresh() + except YotoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + return self.client.players + + async def _async_load_library(self) -> None: + """Load the card library; failures only affect titles and artwork.""" + try: + await self.client.update_library() + except YotoError as err: + _LOGGER.warning("Could not load Yoto card library: %s", err) + + async def _async_status_push_tick(self, _now: datetime) -> None: + """Ask each player to push a fresh status snapshot over MQTT.""" + if not self.client.is_mqtt_connected: + return + # Fire-and-forget: the data/status response lands via the on_update + # callback later, which already triggers async_set_updated_data. + for device_id in list(self.client.players): + await self.client.request_status_push(device_id) + + def _mqtt_event(self, _player: YotoPlayer) -> None: + """Handle a real-time update pushed by the Yoto MQTT broker.""" + self.async_set_updated_data(self.client.players) + + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await self.client.disconnect_events() + await super().async_shutdown() diff --git a/homeassistant/components/yoto/entity.py b/homeassistant/components/yoto/entity.py new file mode 100644 index 00000000000..571d6d87d2c --- /dev/null +++ b/homeassistant/components/yoto/entity.py @@ -0,0 +1,46 @@ +"""Base entity for the Yoto integration.""" + +from yoto_api import YotoPlayer + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import YotoDataUpdateCoordinator + + +class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]): + """Base class for Yoto entities tied to a single player.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: YotoDataUpdateCoordinator, + player: YotoPlayer, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._player_id = player.id + device = player.device + mac = player.info.mac + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, player.id)}, + connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(), + manufacturer=MANUFACTURER, + model=player.model, + model_id=device.device_type, + hw_version=device.generation, + name=player.name, + sw_version=player.info.firmware_version, + ) + + @property + def player(self) -> YotoPlayer: + """Return the live player record from the client.""" + return self.coordinator.data[self._player_id] + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self._player_id in self.coordinator.data diff --git a/homeassistant/components/yoto/manifest.json b/homeassistant/components/yoto/manifest.json new file mode 100644 index 00000000000..d86dd751ce9 --- /dev/null +++ b/homeassistant/components/yoto/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "yoto", + "name": "Yoto", + "codeowners": ["@cdnninja", "@piitaya"], + "config_flow": true, + "dependencies": ["application_credentials"], + "dhcp": [{ "hostname": "yoto-*" }], + "documentation": "https://www.home-assistant.io/integrations/yoto", + "integration_type": "hub", + "iot_class": "cloud_push", + "loggers": ["yoto_api"], + "quality_scale": "bronze", + "requirements": ["yoto-api==3.2.0"] +} diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py new file mode 100644 index 00000000000..1a1bd670108 --- /dev/null +++ b/homeassistant/components/yoto/media_player.py @@ -0,0 +1,441 @@ +"""Media player platform for the Yoto integration.""" + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer + +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator +from .entity import YotoEntity + +URI_SCHEME = "yoto" +# The URI authority ("card") names the content type. Only cards exist today; +# reserving it leaves room for groups without breaking URIs. +URI_CARD = "card" + +PARALLEL_UPDATES = 0 + +# Yoto players expose 16 hardware volume steps. +VOLUME_STEP = 1 / 16 + +PLAYBACK_STATE_MAP = { + PlaybackStatus.PLAYING: MediaPlayerState.PLAYING, + PlaybackStatus.PAUSED: MediaPlayerState.PAUSED, + PlaybackStatus.STOPPED: MediaPlayerState.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: YotoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Yoto media player platform.""" + coordinator = entry.runtime_data + async_add_entities( + YotoMediaPlayer(coordinator, player) + for player in coordinator.client.players.values() + ) + + +class YotoMediaPlayer(YotoEntity, MediaPlayerEntity): + """Representation of a Yoto Player.""" + + _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_media_image_remotely_accessible = True + _attr_volume_step = VOLUME_STEP + _attr_supported_features = ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SEEK + ) + + def __init__( + self, + coordinator: YotoDataUpdateCoordinator, + player: YotoPlayer, + ) -> None: + """Initialize the media player.""" + super().__init__(coordinator, player) + self._attr_unique_id = player.id + + @property + def available(self) -> bool: + """Return whether the player is reachable through the Yoto cloud.""" + return super().available and bool(self.player.status.is_online) + + @property + def state(self) -> MediaPlayerState: + """Return the playback state.""" + status = self.player.last_event.playback_status + if status is None: + return MediaPlayerState.IDLE + return PLAYBACK_STATE_MAP.get(status, MediaPlayerState.IDLE) + + @property + def volume_level(self) -> float | None: + """Return the current volume level.""" + return self.player.last_event.volume_percentage + + @property + def media_duration(self) -> int | None: + """Return the current track duration in seconds.""" + return self.player.last_event.track_length + + @property + def media_position(self) -> int | None: + """Return the current playback position in seconds.""" + return self.player.last_event.position + + @property + def media_position_updated_at(self) -> datetime | None: + """Return the time the media position was last refreshed.""" + return self.player.last_event_received_at + + @property + def media_title(self) -> str | None: + """Return the title of the currently playing track.""" + event = self.player.last_event + return event.track_title or event.chapter_title + + @property + def media_album_name(self) -> str | None: + """Return the title of the active card.""" + card = self._current_card() + return card.title if card else None + + @property + def media_artist(self) -> str | None: + """Return the author of the active card.""" + card = self._current_card() + return card.author if card else None + + @property + def media_image_url(self) -> str | None: + """Return the cover image URL of the active card.""" + card = self._current_card() + return card.cover_image_large if card else None + + def _current_card(self) -> Card | None: + """Return the cached library card for the currently active media.""" + card_id = self.player.last_event.card_id + if not card_id: + return None + return self.coordinator.client.library.get(card_id) + + async def async_media_play(self) -> None: + """Resume playback.""" + await self._async_run(self.coordinator.client.resume, self._player_id) + + async def async_media_pause(self) -> None: + """Pause playback.""" + await self._async_run(self.coordinator.client.pause, self._player_id) + + async def async_media_stop(self) -> None: + """Stop playback.""" + await self._async_run(self.coordinator.client.stop, self._player_id) + + async def async_set_volume_level(self, volume: float) -> None: + """Set the playback volume (0.0 - 1.0).""" + await self._async_run( + self.coordinator.client.set_volume, + self._player_id, + round(volume * 100), + ) + + async def async_media_seek(self, position: float) -> None: + """Seek to ``position`` seconds in the active track.""" + await self._async_run( + self.coordinator.client.seek, self._player_id, int(position) + ) + + async def async_media_next_track(self) -> None: + """Skip to the next track on the active card.""" + await self._async_run(self.coordinator.client.next_track, self._player_id) + + async def async_media_previous_track(self) -> None: + """Skip to the previous track on the active card.""" + await self._async_run(self.coordinator.client.previous_track, self._player_id) + + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a Yoto card, chapter, or track from the browse tree.""" + try: + card_id, chapter_key, track_key = _parse_uri(media_id) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media_id", + translation_placeholders={"media_id": media_id}, + ) from err + + client = self.coordinator.client + card = client.library.get(card_id) + if card is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_card", + translation_placeholders={"card_id": card_id}, + ) + + if chapter_key is not None: + # Library list may not include chapters yet; fetch detail on demand. + if not card.chapters: + try: + await client.update_card_detail(card_id) + except YotoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="card_detail_failed", + translation_placeholders={"error": str(err)}, + ) from err + + chapter = card.chapters.get(chapter_key) + if chapter is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_chapter", + translation_placeholders={ + "chapter_key": chapter_key, + "card_id": card_id, + }, + ) + if track_key is not None and track_key not in chapter.tracks: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_track", + translation_placeholders={ + "track_key": track_key, + "card_id": card_id, + }, + ) + # A chapter plays from its first track. + if track_key is None and chapter.tracks: + track_key = next(iter(chapter.tracks)) + + # Targeted chapter/track plays start at 0; a card play keeps its resume point. + seconds_in = 0 if chapter_key is not None else None + try: + await client.play_card( + self._player_id, + card_id, + chapter_key=chapter_key, + track_key=track_key, + seconds_in=seconds_in, + ) + except YotoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="play_failed", + translation_placeholders={"error": str(err)}, + ) from err + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse the Yoto card library.""" + if not media_content_id: + return self._browse_root() + + try: + card_id, chapter_key, _ = _parse_uri(media_content_id) + except ValueError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="invalid_media_id", + translation_placeholders={"media_id": media_content_id}, + ) from err + + card = self.coordinator.client.library.get(card_id) + if card is None: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="unknown_card", + translation_placeholders={"card_id": card_id}, + ) + + if not card.chapters: + try: + await self.coordinator.client.update_card_detail(card_id) + except YotoError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="card_detail_failed", + translation_placeholders={"error": str(err)}, + ) from err + + if chapter_key is not None: + chapter = card.chapters.get(chapter_key) + if chapter is None: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="unknown_chapter", + translation_placeholders={ + "chapter_key": chapter_key, + "card_id": card_id, + }, + ) + return self._browse_chapter(card_id, chapter_key, chapter) + + return self._browse_card(card) + + def _browse_root(self) -> BrowseMedia: + """List every card in the user's library.""" + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=MediaType.MUSIC, + title="Yoto library", + can_play=False, + can_expand=True, + children=[ + self._card_node(card) + for card in self.coordinator.client.library.values() + ], + children_media_class=MediaClass.ALBUM, + ) + + def _browse_card(self, card: Card) -> BrowseMedia: + """List a card's chapters, collapsing single-chapter cards to tracks.""" + chapters = card.chapters + # Single-chapter cards expand straight to tracks (skip a one-item level). + if len(chapters) == 1: + chapter_key, chapter = next(iter(chapters.items())) + children = [ + self._track_node(card.id, chapter_key, track_key, track) + for track_key, track in chapter.tracks.items() + ] + else: + children = [ + self._chapter_node(card.id, chapter_key, chapter) + for chapter_key, chapter in chapters.items() + ] + node = self._card_node(card) + node.children = children + return node + + def _browse_chapter( + self, card_id: str, chapter_key: str, chapter: Chapter + ) -> BrowseMedia: + """List the tracks of a chapter.""" + node = self._chapter_node(card_id, chapter_key, chapter) + node.can_expand = True + node.children = [ + self._track_node(card_id, chapter_key, track_key, track) + for track_key, track in chapter.tracks.items() + ] + return node + + def _card_node(self, card: Card) -> BrowseMedia: + """Build a browse node for a card.""" + # MUSIC (not ALBUM) so children render in list view with thumbnails. + return BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id=_build_uri(card.id), + media_content_type=MediaType.MUSIC, + title=card.title or card.id, + can_play=True, + can_expand=True, + thumbnail=card.cover_image_large, + ) + + def _chapter_node( + self, card_id: str, chapter_key: str, chapter: Chapter + ) -> BrowseMedia: + """Build a browse node for a chapter.""" + # Single-track chapters aren't expandable: click plays the track. + return BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id=_build_uri(card_id, chapter_key), + media_content_type=MediaType.MUSIC, + title=chapter.title or chapter_key, + can_play=True, + can_expand=len(chapter.tracks) > 1, + thumbnail=chapter.icon, + ) + + def _track_node( + self, card_id: str, chapter_key: str, track_key: str, track: Track + ) -> BrowseMedia: + """Build a browse node for a track.""" + return BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id=_build_uri(card_id, chapter_key, track_key), + media_content_type=MediaType.MUSIC, + title=track.title or track_key, + can_play=True, + can_expand=False, + thumbnail=track.icon, + ) + + async def _async_run( + self, func: Callable[..., Awaitable[Any]], /, *args: Any + ) -> None: + """Await a Yoto command and surface failures as HA errors.""" + try: + await func(*args) + except YotoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"error": str(err)}, + ) from err + + +def _build_uri( + card_id: str, + chapter_key: str | None = None, + track_key: str | None = None, +) -> str: + """Build a yoto://card/... URI from card/chapter/track parts.""" + segments = [URI_CARD, card_id] + if chapter_key is not None: + segments.append(chapter_key) + if track_key is not None: + segments.append(track_key) + return f"{URI_SCHEME}://{'/'.join(segments)}" + + +def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: + """Parse a yoto://card/... URI into card/chapter/track parts. + + Parsed manually because URL parsers lower-case the authority and Yoto + IDs are case-sensitive. + """ + prefix = f"{URI_SCHEME}://{URI_CARD}/" + if not media_id.startswith(prefix): + raise ValueError(f"Not a Yoto media identifier: {media_id}") + parts = media_id[len(prefix) :].split("/") + if not parts or len(parts) > 3 or any(not segment for segment in parts): + raise ValueError(f"Not a Yoto media identifier: {media_id}") + card_id = parts[0] + chapter_key = parts[1] if len(parts) > 1 else None + track_key = parts[2] if len(parts) > 2 else None + return card_id, chapter_key, track_key diff --git a/homeassistant/components/yoto/quality_scale.yaml b/homeassistant/components/yoto/quality_scale.yaml new file mode 100644 index 00000000000..bfbeeed255d --- /dev/null +++ b/homeassistant/components/yoto/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register custom service actions. + appropriate-polling: + status: done + comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Real-time updates are dispatched through the coordinator, not via per-entity event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register custom service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: exempt + comment: Only the media_player entity ships in this PR; no diagnostic entities yet. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: Only the media_player entity ships in this PR; no entities are disabled by default. + entity-translations: + status: exempt + comment: The media_player uses the device name; no translatable strings yet. + exception-translations: done + icon-translations: + status: exempt + comment: No custom icon translations are needed yet. + reconfiguration-flow: + status: exempt + comment: Authorization is the only configuration; reauth covers re-linking the account. + repair-issues: + status: exempt + comment: No repair issues are raised yet. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json new file mode 100644 index 00000000000..a8c673ee61c --- /dev/null +++ b/homeassistant/components/yoto/strings.json @@ -0,0 +1,62 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "oauth_discovery": { + "description": "Home Assistant has found a Yoto player on your network. Press **Submit** to continue setting up Yoto." + }, + "pick_implementation": { + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + }, + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + }, + "exceptions": { + "card_detail_failed": { + "message": "Could not load Yoto card details: {error}" + }, + "command_failed": { + "message": "Yoto command failed: {error}" + }, + "invalid_media_id": { + "message": "Not a Yoto media identifier: {media_id}" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, + "play_failed": { + "message": "Failed to play Yoto media: {error}" + }, + "unknown_card": { + "message": "Unknown Yoto card: {card_id}" + }, + "unknown_chapter": { + "message": "Unknown chapter {chapter_key} on card {card_id}" + }, + "unknown_track": { + "message": "Unknown track {track_key} on card {card_id}" + }, + "update_error": { + "message": "Error communicating with Yoto: {error}" + } + } +} diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index af14d597b79..a2e61a324b2 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -5,20 +5,18 @@ from urllib.error import URLError from youless_api import YoulessAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import YouLessCoordinator +from .coordinator import YouLessConfigEntry, YouLessCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YouLessConfigEntry) -> bool: """Set up youless from a config entry.""" api = YoulessAPI(entry.data[CONF_HOST]) @@ -30,17 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: youless_coordinator = YouLessCoordinator(hass, entry, api) await youless_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = youless_coordinator + entry.runtime_data = youless_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YouLessConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/youless/config_flow.py b/homeassistant/components/youless/config_flow.py index 40f09ad3af7..157f55620c6 100644 --- a/homeassistant/components/youless/config_flow.py +++ b/homeassistant/components/youless/config_flow.py @@ -1,7 +1,5 @@ """Config flow for youless integration.""" -from __future__ import annotations - import logging from typing import Any from urllib.error import HTTPError, URLError diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py index 81e4b3a4c76..a798a807989 100644 --- a/homeassistant/components/youless/coordinator.py +++ b/homeassistant/components/youless/coordinator.py @@ -11,14 +11,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type YouLessConfigEntry = ConfigEntry[YouLessCoordinator] + class YouLessCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching YouLess data.""" - config_entry: ConfigEntry + config_entry: YouLessConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: YoulessAPI + self, hass: HomeAssistant, config_entry: YouLessConfigEntry, device: YoulessAPI ) -> None: """Initialize global YouLess data provider.""" super().__init__( diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 6a1e0ceea0a..ff6f15ba33e 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -1,7 +1,5 @@ """The sensor entity for the Youless integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, UnitOfElectricCurrent, @@ -26,8 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DOMAIN -from .coordinator import YouLessCoordinator +from .const import DOMAIN +from .coordinator import YouLessConfigEntry, YouLessCoordinator from .entity import YouLessEntity @@ -303,13 +300,13 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: YouLessConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the integration.""" - coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data device = entry.data[CONF_DEVICE] - if (device := entry.data[CONF_DEVICE]) is None: + if device is None: device = entry.entry_id async_add_entities( diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 32863f5a772..cb3a9bf0370 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -1,10 +1,7 @@ """Support for YouTube.""" -from __future__ import annotations - from aiohttp.client_exceptions import ClientError, ClientResponseError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -16,13 +13,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .api import AsyncConfigEntryAuth -from .const import AUTH, COORDINATOR, DOMAIN -from .coordinator import YouTubeDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import YouTubeConfigEntry, YouTubeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> bool: """Set up YouTube from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -49,25 +46,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await delete_devices(hass, entry, coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATOR: coordinator, - AUTH: auth, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def delete_devices( - hass: HomeAssistant, entry: ConfigEntry, coordinator: YouTubeDataUpdateCoordinator + hass: HomeAssistant, + entry: YouTubeConfigEntry, + coordinator: YouTubeDataUpdateCoordinator, ) -> None: """Delete all devices created by integration.""" channel_ids = list(coordinator.data) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 76d74965b34..cce85221cb7 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -1,7 +1,5 @@ """Config flow for YouTube integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -10,12 +8,7 @@ import voluptuous as vol from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow @@ -33,6 +26,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import YouTubeConfigEntry class OAuth2FlowHandler( @@ -50,7 +44,7 @@ class OAuth2FlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: YouTubeConfigEntry, ) -> YouTubeOptionsFlowHandler: """Get the options flow for this handler.""" return YouTubeOptionsFlowHandler() diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index da5a554f364..9dd5dd4427f 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -9,8 +9,6 @@ CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" CONF_CHANNELS = "channels" CONF_UPLOAD_PLAYLIST = "upload_playlist_id" -COORDINATOR = "coordinator" -AUTH = "auth" LOGGER = logging.getLogger(__package__) @@ -22,3 +20,4 @@ ATTR_DESCRIPTION = "description" ATTR_THUMBNAIL = "thumbnail" ATTR_VIDEO_ID = "video_id" ATTR_PUBLISHED_AT = "published_at" +ATTR_VIDEO_COUNT = "video_count" diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 476e5bb4022..a78e0b5222f 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the YouTube integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any @@ -14,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import AsyncConfigEntryAuth +from .api import AsyncConfigEntryAuth from .const import ( ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, @@ -23,20 +21,26 @@ from .const import ( ATTR_THUMBNAIL, ATTR_TITLE, ATTR_TOTAL_VIEWS, + ATTR_VIDEO_COUNT, ATTR_VIDEO_ID, CONF_CHANNELS, DOMAIN, LOGGER, ) +type YouTubeConfigEntry = ConfigEntry[YouTubeDataUpdateCoordinator] + class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A YouTube Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: YouTubeConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, auth: AsyncConfigEntryAuth + self, + hass: HomeAssistant, + config_entry: YouTubeConfigEntry, + auth: AsyncConfigEntryAuth, ) -> None: """Initialize the YouTube data coordinator.""" self._auth = auth @@ -63,7 +67,9 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ATTR_PUBLISHED_AT: video.snippet.added_at, ATTR_TITLE: video.snippet.title, ATTR_DESCRIPTION: video.snippet.description, - ATTR_THUMBNAIL: video.snippet.thumbnails.get_highest_quality().url, + ATTR_THUMBNAIL: ( + video.snippet.thumbnails.get_highest_quality().url + ), ATTR_VIDEO_ID: video.content_details.video_id, } res[channel.channel_id] = { @@ -73,6 +79,7 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ATTR_LATEST_VIDEO: latest_video, ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, ATTR_TOTAL_VIEWS: channel.statistics.view_count, + ATTR_VIDEO_COUNT: channel.statistics.video_count, } except UnauthorizedError as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 9a898b7e2de..f0689341053 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -1,25 +1,26 @@ """Diagnostics support for YouTube.""" -from __future__ import annotations - from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, COORDINATOR, DOMAIN -from .coordinator import YouTubeDataUpdateCoordinator +from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO +from .coordinator import YouTubeConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: YouTubeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): - channel_data.get(ATTR_LATEST_VIDEO, {}).pop(ATTR_DESCRIPTION) - sensor_data[channel_id] = channel_data + channel_copy = dict(channel_data) + if latest_video := channel_copy.get(ATTR_LATEST_VIDEO): + channel_copy[ATTR_LATEST_VIDEO] = { + key: value + for key, value in latest_video.items() + if key != ATTR_DESCRIPTION + } + sensor_data[channel_id] = channel_copy return sensor_data diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 698b14fa6a7..32830ee9821 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -1,7 +1,5 @@ """Entity representing a YouTube account.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 224ace3d405..e47e8fbad49 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -1,7 +1,5 @@ """Support for YouTube Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -11,13 +9,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import YouTubeDataUpdateCoordinator from .const import ( ATTR_LATEST_VIDEO, ATTR_PUBLISHED_AT, @@ -25,10 +21,10 @@ from .const import ( ATTR_THUMBNAIL, ATTR_TITLE, ATTR_TOTAL_VIEWS, + ATTR_VIDEO_COUNT, ATTR_VIDEO_ID, - COORDINATOR, - DOMAIN, ) +from .coordinator import YouTubeConfigEntry from .entity import YouTubeChannelEntity @@ -74,18 +70,27 @@ SENSOR_TYPES = [ entity_picture_fn=lambda channel: channel[ATTR_ICON], attributes_fn=None, ), + YouTubeSensorEntityDescription( + key="videos", + translation_key="videos", + native_unit_of_measurement="videos", + state_class=SensorStateClass.TOTAL, + available_fn=lambda _: True, + value_fn=lambda channel: channel[ATTR_VIDEO_COUNT], + entity_picture_fn=lambda _: None, + attributes_fn=None, + icon="mdi:filmstrip-box-multiple", + ), ] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: YouTubeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the YouTube sensor.""" - coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( YouTubeSensor(coordinator, sensor_type, channel_id) for channel_id in coordinator.data diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 0ecd24e09c9..9bdc2de0ed8 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -42,6 +42,7 @@ } }, "subscribers": { "name": "Subscribers" }, + "videos": { "name": "Videos" }, "views": { "name": "Views" } } }, diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 27d7e71d8d9..13874ac4b8b 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -1,7 +1,5 @@ """Support for Zabbix sensors.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/zamg/__init__.py b/homeassistant/components/zamg/__init__.py index f6241e53fbe..ecdcb4ca035 100644 --- a/homeassistant/components/zamg/__init__.py +++ b/homeassistant/components/zamg/__init__.py @@ -1,19 +1,16 @@ """The zamg component.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import CONF_STATION_ID, DOMAIN, LOGGER -from .coordinator import ZamgDataUpdateCoordinator +from .const import CONF_STATION_ID, LOGGER +from .coordinator import ZamgConfigEntry, ZamgDataUpdateCoordinator PLATFORMS = (Platform.SENSOR, Platform.WEATHER) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ZamgConfigEntry) -> bool: """Set up Zamg from config entry.""" await _async_migrate_entries(hass, entry) @@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.zamg.set_default_station(station_id) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -30,15 +27,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ZamgConfigEntry) -> bool: """Unload ZAMG config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_migrate_entries( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ZamgConfigEntry ) -> bool: """Migrate old entry.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/zamg/config_flow.py b/homeassistant/components/zamg/config_flow.py index 24045ba8f4e..15521fe714a 100644 --- a/homeassistant/components/zamg/config_flow.py +++ b/homeassistant/components/zamg/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for the zamg integration.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py index a88c97ad267..0e1f17be0bb 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -1,7 +1,5 @@ """Data Update coordinator for ZAMG weather data.""" -from __future__ import annotations - from zamg import ZamgData as ZamgDevice from zamg.exceptions import ZamgError, ZamgNoDataError @@ -12,11 +10,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION_ID, DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES +type ZamgConfigEntry = ConfigEntry[ZamgDataUpdateCoordinator] + class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): """Class to manage fetching ZAMG weather data.""" - config_entry: ConfigEntry + config_entry: ZamgConfigEntry data: dict = {} api_fields: list[str] | None = None @@ -24,7 +24,7 @@ class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: ZamgConfigEntry, ) -> None: """Initialize global ZAMG data updater.""" self.zamg = ZamgDevice(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 6caa0741c1b..f8015b241b2 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -1,7 +1,5 @@ """Sensor for the zamg integration.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass @@ -11,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -36,7 +33,7 @@ from .const import ( DOMAIN, MANUFACTURER_URL, ) -from .coordinator import ZamgDataUpdateCoordinator +from .coordinator import ZamgConfigEntry, ZamgDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -174,11 +171,11 @@ API_FIELDS: list[str] = [desc.para_name for desc in SENSOR_TYPES] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ZamgConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ZamgSensor(coordinator, entry.title, entry.data[CONF_STATION_ID], description) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 85301d6186e..64d2af666f2 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -1,9 +1,6 @@ """Sensor for the zamg integration.""" -from __future__ import annotations - from homeassistant.components.weather import WeatherEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfPrecipitationDepth, UnitOfPressure, @@ -16,16 +13,16 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL -from .coordinator import ZamgDataUpdateCoordinator +from .coordinator import ZamgConfigEntry, ZamgDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ZamgConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG weather platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ZamgWeather(coordinator, entry.title, entry.data[CONF_STATION_ID])] ) diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index ccb6733c650..987a69a31aa 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -1,7 +1,5 @@ """Support for Zengge lights.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.light import PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 82317d06205..6644315abb5 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -1,7 +1,5 @@ """Support for exposing Home Assistant via Zeroconf.""" -from __future__ import annotations - from contextlib import suppress from ipaddress import IPv4Address, IPv6Address import logging @@ -22,13 +20,12 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass +from homeassistant.loader import async_get_homekit, async_get_zeroconf from homeassistant.setup import async_when_setup_or_start from . import websocket_api -from .const import DOMAIN, ZEROCONF_TYPE +from .const import DATA_DISCOVERY, DATA_INSTANCE, DOMAIN, ZEROCONF_TYPE from .discovery import ( # noqa: F401 - DATA_DISCOVERY, ZeroconfDiscovery, build_homekit_model_lookups, info_from_service, @@ -68,13 +65,11 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: """Get or create the shared HaZeroconf instance.""" return cast(HaZeroconf, (_async_get_instance(hass)).zeroconf) -@bind_hass async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf: """Get or create the shared HaAsyncZeroconf instance.""" return _async_get_instance(hass) @@ -91,8 +86,8 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: - if DOMAIN in hass.data: - return cast(HaAsyncZeroconf, hass.data[DOMAIN]) + if DATA_INSTANCE in hass.data: + return hass.data[DATA_INSTANCE] zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) @@ -106,7 +101,7 @@ def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: # Wait to the close event to shutdown zeroconf to give # integrations time to send a good bye message hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_zeroconf) - hass.data[DOMAIN] = aio_zc + hass.data[DATA_INSTANCE] = aio_zc return aio_zc diff --git a/homeassistant/components/zeroconf/const.py b/homeassistant/components/zeroconf/const.py index 6267d18642c..14b7a5619bd 100644 --- a/homeassistant/components/zeroconf/const.py +++ b/homeassistant/components/zeroconf/const.py @@ -1,7 +1,18 @@ """Zeroconf constants.""" +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .discovery import ZeroconfDiscovery + from .models import HaAsyncZeroconf + DOMAIN = "zeroconf" ZEROCONF_TYPE = "_home-assistant._tcp.local." REQUEST_TIMEOUT = 10000 # 10 seconds + +DATA_INSTANCE: HassKey[HaAsyncZeroconf] = HassKey(DOMAIN) +DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey(f"{DOMAIN}_discovery") diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py index 1158f8a2fdb..20393666730 100644 --- a/homeassistant/components/zeroconf/discovery.py +++ b/homeassistant/components/zeroconf/discovery.py @@ -1,7 +1,5 @@ """Zeroconf discovery for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable import contextlib from fnmatch import translate @@ -15,6 +13,7 @@ from zeroconf import BadTypeInNameException, IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo from homeassistant import config_entries +from homeassistant.const import ATTR_DOMAIN, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.discovery_flow import DiscoveryKey @@ -24,12 +23,9 @@ from homeassistant.helpers.service_info.zeroconf import ( ZeroconfServiceInfo as _ZeroconfServiceInfo, ) from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher -from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, REQUEST_TIMEOUT - -if TYPE_CHECKING: - from .models import HaZeroconf +from .models import HaZeroconf _LOGGER = logging.getLogger(__name__) @@ -46,16 +42,11 @@ HOMEKIT_PAIRED_STATUS_FLAG = "sf" HOMEKIT_MODEL_LOWER = "md" HOMEKIT_MODEL_UPPER = "MD" -ATTR_DOMAIN: Final = "domain" -ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" DUPLICATE_INSTANCE_ID_ISSUE_ID = "duplicate_instance_id" -DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") - - def build_homekit_model_lookups( homekit_models: dict[str, HomeKitDiscoveredIntegration], ) -> tuple[ @@ -472,7 +463,8 @@ class ZeroconfDiscovery: # Conflict detected, create repair issue _joined_ips = ", ".join(str(ip_address) for ip_address in discovered_ips) _LOGGER.warning( - "Discovered another Home Assistant instance with the same instance ID (%s) at %s", + "Discovered another Home Assistant instance" + " with the same instance ID (%s) at %s", discovered_instance_id, _joined_ips, ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index f3da07eeeb5..a29ef3dcad9 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.148.0"] + "requirements": ["zeroconf==0.149.16"] } diff --git a/homeassistant/components/zeroconf/repairs.py b/homeassistant/components/zeroconf/repairs.py index 2af53ff4625..4946ef6f57f 100644 --- a/homeassistant/components/zeroconf/repairs.py +++ b/homeassistant/components/zeroconf/repairs.py @@ -1,13 +1,10 @@ """Repairs for the zeroconf integration.""" -from __future__ import annotations - -from homeassistant import data_entry_flow from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_HOMEASSISTANT_RESTART, ) -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import instance_id, issue_registry as ir @@ -24,13 +21,13 @@ class DuplicateInstanceIDRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the initial step.""" return await self.async_step_confirm_recreate() async def async_step_confirm_recreate( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step.""" if user_input is not None: await instance_id.async_recreate(self.hass) diff --git a/homeassistant/components/zeroconf/websocket_api.py b/homeassistant/components/zeroconf/websocket_api.py index 3a1881e6f4e..41e5b719d7a 100644 --- a/homeassistant/components/zeroconf/websocket_api.py +++ b/homeassistant/components/zeroconf/websocket_api.py @@ -1,7 +1,5 @@ """The zeroconf integration websocket apis.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from functools import partial @@ -17,8 +15,8 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes -from .const import DOMAIN, REQUEST_TIMEOUT -from .discovery import DATA_DISCOVERY, ZeroconfDiscovery +from .const import DATA_DISCOVERY, DATA_INSTANCE, REQUEST_TIMEOUT +from .discovery import ZeroconfDiscovery from .models import HaAsyncZeroconf _LOGGER = logging.getLogger(__name__) @@ -157,7 +155,7 @@ async def ws_subscribe_discovery( ) -> None: """Handle subscribe advertisements websocket command.""" discovery = hass.data[DATA_DISCOVERY] - aiozc: HaAsyncZeroconf = hass.data[DOMAIN] + aiozc = hass.data[DATA_INSTANCE] await _DiscoverySubscription( hass, connection, msg["id"], aiozc, discovery ).async_start() diff --git a/homeassistant/components/zerproc/__init__.py b/homeassistant/components/zerproc/__init__.py index 953720038cc..a0c44ad5931 100644 --- a/homeassistant/components/zerproc/__init__.py +++ b/homeassistant/components/zerproc/__init__.py @@ -1,4 +1,5 @@ """Zerproc lights integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 19175ae3084..8c0ec60a4f9 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -1,6 +1,5 @@ """Zerproc light platform.""" - -from __future__ import annotations +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta import logging @@ -152,4 +151,4 @@ class ZerprocLight(LightEntity): self._attr_is_on = state.is_on hsv = color_util.color_RGB_to_hsv(*state.color) self._attr_hs_color = hsv[:2] - self._attr_brightness = int(round((hsv[2] / 100) * 255)) + self._attr_brightness = round((hsv[2] / 100) * 255) diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index 0b1039186b7..ad6abb0cd03 100644 --- a/homeassistant/components/zestimate/manifest.json +++ b/homeassistant/components/zestimate/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zestimate", "iot_class": "cloud_polling", "quality_scale": "legacy", - "requirements": ["xmltodict==1.0.2"] + "requirements": ["xmltodict==1.0.4"] } diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index c776cce2ca0..65926d86929 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -1,7 +1,5 @@ """Support for zestimate data from zillow.com.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/zeversolar/__init__.py b/homeassistant/components/zeversolar/__init__.py index cb48579367b..d49ed054fb8 100644 --- a/homeassistant/components/zeversolar/__init__.py +++ b/homeassistant/components/zeversolar/__init__.py @@ -1,26 +1,21 @@ """The Zeversolar integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import ZeversolarCoordinator +from .const import PLATFORMS +from .coordinator import ZeversolarConfigEntry, ZeversolarCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ZeversolarConfigEntry) -> bool: """Set up Zeversolar from a config entry.""" coordinator = ZeversolarCoordinator(hass=hass, entry=entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ZeversolarConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/zeversolar/config_flow.py b/homeassistant/components/zeversolar/config_flow.py index 1f2357c224f..5f25b46da5d 100644 --- a/homeassistant/components/zeversolar/config_flow.py +++ b/homeassistant/components/zeversolar/config_flow.py @@ -1,7 +1,5 @@ """Config flow for zeversolar integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index ec68cf4b56f..66d3317a095 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -1,7 +1,5 @@ """Zeversolar coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging @@ -10,19 +8,21 @@ import zeversolar from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type ZeversolarConfigEntry = ConfigEntry[ZeversolarCoordinator] + class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): """Data update coordinator.""" - config_entry: ConfigEntry + config_entry: ZeversolarConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ZeversolarConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -35,4 +35,7 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): async def _async_update_data(self) -> zeversolar.ZeverSolarData: """Fetch the latest data from the source.""" - return await self.hass.async_add_executor_job(self._client.get_data) + try: + return await self.hass.async_add_executor_job(self._client.get_data) + except zeversolar.ZeverSolarError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/zeversolar/diagnostics.py b/homeassistant/components/zeversolar/diagnostics.py index 6e6ed262f51..dc8730866a6 100644 --- a/homeassistant/components/zeversolar/diagnostics.py +++ b/homeassistant/components/zeversolar/diagnostics.py @@ -4,23 +4,19 @@ from typing import Any from zeversolar import ZeverSolarData -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN -from .coordinator import ZeversolarCoordinator +from .coordinator import ZeversolarConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ZeversolarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" + data: ZeverSolarData = config_entry.runtime_data.data - coordinator: ZeversolarCoordinator = hass.data[DOMAIN][config_entry.entry_id] - data: ZeverSolarData = coordinator.data - - payload: dict[str, Any] = { + return { "wifi_enabled": data.wifi_enabled, "serial_or_registry_id": data.serial_or_registry_id, "registry_key": data.registry_key, @@ -36,24 +32,20 @@ async def async_get_config_entry_diagnostics( "meter_status": data.meter_status.value, } - return payload - async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ZeversolarConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" - coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] - - updateInterval = ( - None - if coordinator.update_interval is None - else coordinator.update_interval.total_seconds() - ) + coordinator = entry.runtime_data return { "name": coordinator.name, "always_update": coordinator.always_update, "last_update_success": coordinator.last_update_success, - "update_interval": updateInterval, + "update_interval": ( + None + if coordinator.update_interval is None + else coordinator.update_interval.total_seconds() + ), } diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index 3e085d952ca..8b01032e398 100644 --- a/homeassistant/components/zeversolar/entity.py +++ b/homeassistant/components/zeversolar/entity.py @@ -1,7 +1,5 @@ """Base Entity for Zeversolar sensors.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,5 +25,7 @@ class ZeversolarEntity( identifiers={(DOMAIN, coordinator.data.serial_number)}, name="Zeversolar Sensor", manufacturer="Zeversolar", + hw_version=coordinator.data.hardware_version, + sw_version=coordinator.data.software_version, serial_number=coordinator.data.serial_number, ) diff --git a/homeassistant/components/zeversolar/manifest.json b/homeassistant/components/zeversolar/manifest.json index 18bab34c04e..5b040218e95 100644 --- a/homeassistant/components/zeversolar/manifest.json +++ b/homeassistant/components/zeversolar/manifest.json @@ -1,10 +1,11 @@ { "domain": "zeversolar", "name": "Zeversolar", - "codeowners": ["@kvanzuijlen"], + "codeowners": ["@kvanzuijlen", "@mhuiskes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zeversolar", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "bronze", "requirements": ["zeversolar==0.3.2"] } diff --git a/homeassistant/components/zeversolar/quality_scale.yaml b/homeassistant/components/zeversolar/quality_scale.yaml new file mode 100644 index 00000000000..44c860b0497 --- /dev/null +++ b/homeassistant/components/zeversolar/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration use DataUpdateCoordinator and do not + explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide configuration options (no options flow). + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: + status: todo + comment: | + Planned improvements: move init_integration to a conftest fixture with a + unique_id matching what the config flow sets; update mock patch targets to + the use site (homeassistant.components.zeversolar.coordinator); use the + shared config_entry fixture in test_abort_already_configured. + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: | + The inverter uses a locally administered MAC address (not tied to a + registered IEEE OUI), so DHCP-based discovery is not feasible. The device + does not advertise via mDNS, SSDP, or any other discovery protocol. + Manual host entry is the only viable setup path. + discovery-update-info: + status: exempt + comment: | + Same rationale as discovery: the device uses a locally administered MAC + address and does not advertise via any network discovery protocol. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration represents a single device per config entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration represents a single device per config entry. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index 330e5bb72d8..0d32283d4a7 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -1,7 +1,5 @@ """Support for the Zeversolar platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,13 +11,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower +from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ZeversolarCoordinator +from .coordinator import ZeversolarConfigEntry, ZeversolarCoordinator from .entity import ZeversolarEntity @@ -33,10 +29,8 @@ class ZeversolarEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( ZeversolarEntityDescription( key="pac", - translation_key="pac", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.POWER, value_fn=lambda data: data.pac, ), @@ -53,11 +47,11 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ZeversolarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zeversolar sensor.""" - coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ZeversolarSensor( description=description, diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 335a0939b05..99f745e4248 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,6 +5,7 @@ import logging from zoneinfo import ZoneInfo import voluptuous as vol +from yarl import URL from zha.application.const import BAUD_RATES, RadioType from zha.application.gateway import Gateway from zha.application.helpers import ZHAData @@ -32,6 +33,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from . import homeassistant_hardware, repairs, websocket_api +from .config_flow import ZhaConfigFlowHandler from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -43,6 +45,7 @@ from .const import ( CONF_ZIGPY, DATA_ZHA, DOMAIN, + LEGACY_ZEROCONF_PORT, ) from .helpers import ( SIGNAL_ADD_ENTITIES, @@ -301,7 +304,15 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > ZhaConfigFlowHandler.VERSION: + # This means the user has downgraded from a future major version + return False if config_entry.version == 1: data = { @@ -361,5 +372,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> version=5, ) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 5 and config_entry.minor_version < 2: + data = {**config_entry.data, CONF_DEVICE: {**config_entry.data[CONF_DEVICE]}} + device_path = data[CONF_DEVICE][CONF_DEVICE_PATH] + + if device_path.startswith(("socket://", "tcp://")): + url = URL(device_path) + if url.explicit_port is None: + data[CONF_DEVICE][CONF_DEVICE_PATH] = str( + url.with_port(LEGACY_ZEROCONF_PORT) + ) + + hass.config_entries.async_update_entry( + config_entry, data=data, version=5, minor_version=2 + ) + + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index ff61ce07d23..140d0304dbd 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -1,7 +1,5 @@ """Alarm control panels on Zigbee Home Automation networks.""" -from __future__ import annotations - import functools from zha.application.platforms.alarm_control_panel.const import ( @@ -82,31 +80,31 @@ class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity): """Whether the code is required for arm actions.""" return self.entity_data.entity.code_arm_required - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.entity_data.entity.async_alarm_disarm(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.entity_data.entity.async_alarm_arm_home(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.entity_data.entity.async_alarm_arm_away(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.entity_data.entity.async_alarm_arm_night(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" await self.entity_data.entity.async_alarm_trigger(code) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index e48313bef72..f4ba2e226ac 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,7 +1,5 @@ """API for Zigbee Home Automation.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Literal from zha.application.const import RadioType diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index f8146026384..48457fcc565 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensors on Zigbee Home Automation networks.""" -from __future__ import annotations - import functools from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index dd90bcd29b1..619b2de151f 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -1,7 +1,5 @@ """Support for ZHA button.""" -from __future__ import annotations - import functools import logging @@ -54,7 +52,7 @@ class ZHAButton(ZHAEntity, ButtonEntity): self.entity_data.entity.info_object.device_class ) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_press(self) -> None: """Send out a update command.""" await self.entity_data.entity.async_press() diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index a3f60420a38..21be4a82e13 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -4,8 +4,6 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/zha.climate/ """ -from __future__ import annotations - from collections.abc import Mapping import functools from typing import Any @@ -205,25 +203,25 @@ class Thermostat(ZHAEntity, ClimateEntity): ) super()._handle_entity_events(event) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" await self.entity_data.entity.async_set_fan_mode(fan_mode=fan_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self.entity_data.entity.async_set_hvac_mode(hvac_mode=hvac_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self.entity_data.entity.async_set_temperature( diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 2342023540b..f5e18addef5 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,15 +1,11 @@ """Config flow for ZHA.""" -from __future__ import annotations - from abc import abstractmethod import asyncio -import collections from contextlib import suppress from enum import StrEnum import json import logging -import os from typing import Any import voluptuous as vol @@ -20,13 +16,9 @@ from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetwork from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file -from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( ZigbeeFlowStrategy, ) -from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware -from homeassistant.components.usb import SerialDevice, USBDevice, scan_serial_ports from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -42,13 +34,23 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + SerialPortSelector, + SerialPortSelectorConfig, +) from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util -from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN +from .const import ( + CONF_BAUDRATE, + CONF_FLOW_CONTROL, + CONF_RADIO_TYPE, + DOMAIN, + LEGACY_ZEROCONF_PORT, +) from .helpers import get_config_entry_unique_id, get_zha_gateway from .radio_manager import ( DEVICE_SCHEMA, @@ -60,13 +62,13 @@ from .radio_manager import ( _LOGGER = logging.getLogger(__name__) -CONF_MANUAL_PATH = "Enter Manually" DECONZ_DOMAIN = "deconz" # The ZHA config flow takes different branches depending on if you are migrating to a # new adapter via discovery or setting it up from scratch -# For the fast path, we automatically migrate everything and restore the most recent backup +# For the fast path, we automatically migrate everything +# and restore the most recent backup MIGRATION_STRATEGY_RECOMMENDED = "migration_strategy_recommended" MIGRATION_STRATEGY_ADVANCED = "migration_strategy_advanced" @@ -91,7 +93,6 @@ UPLOADED_BACKUP_FILE = "uploaded_backup_file" REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" -LEGACY_ZEROCONF_PORT = 6638 LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053 ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local." @@ -103,12 +104,6 @@ ZEROCONF_PROPERTIES_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# USB devices to ignore in serial port selection (non-Zigbee devices) -# Format: (manufacturer, description) -IGNORED_USB_DEVICES = { - ("Nabu Casa", "ZWA-2"), -} - class OptionsMigrationIntent(StrEnum): """Zigbee options flow intents.""" @@ -134,83 +129,12 @@ def _format_backup_choice( return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" -def _format_serial_port_choice( - serial_port: USBDevice | SerialDevice, resolved_paths: dict[str, str] -) -> str: - """Format a serial port selector entry into a line of text.""" - text = resolved_paths[serial_port.device] - - if serial_port.description: - text += f" - {serial_port.description}" - - if serial_port.serial_number: - text += f", s/n: {serial_port.serial_number}" - - if serial_port.manufacturer: - text += f" - {serial_port.manufacturer}" - - return text - - -async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: - """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports: list[USBDevice | SerialDevice] = [] - ports.extend(await hass.async_add_executor_job(scan_serial_ports)) - - # Add useful info to the Yellow's serial port selection screen - try: - yellow_hardware.async_info(hass) - except HomeAssistantError: - pass - else: - # PySerial does not properly handle the Yellow's serial port with the CM5 - # so we manually include it - port = SerialDevice( - device="/dev/ttyAMA1", - serial_number=None, - manufacturer="Nabu Casa", - description="Yellow Zigbee module", - ) - - ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] - ports.insert(0, port) - - if is_hassio(hass): - # Present the multi-PAN addon as a setup option, if it's available - multipan_manager = ( - await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass) - ) - - try: - addon_info = await multipan_manager.async_get_addon_info() - except AddonError, KeyError: - addon_info = None - - if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = SerialDevice( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - serial_number=None, - manufacturer="Nabu Casa", - description="Silicon Labs Multiprotocol add-on", - ) - - ports.append(addon_port) - - # Filter out ignored USB devices - return [ - port - for port in ports - if (port.manufacturer, port.description) not in IGNORED_USB_DEVICES - ] - - class BaseZhaFlow(ConfigEntryBaseFlow): """Mixin for common ZHA flow steps and forms.""" _flow_strategy: ZigbeeFlowStrategy | None = None _overwrite_ieee_during_restore: bool = False _hass: HomeAssistant - _title: str def __init__(self) -> None: """Initialize flow instance.""" @@ -268,29 +192,9 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose a serial port.""" - ports = await list_serial_ports(self.hass) - - # The full `/dev/serial/by-id/` path is too verbose to show - resolved_paths = { - p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device) - for p in ports - } - - list_of_ports = [_format_serial_port_choice(p, resolved_paths) for p in ports] - - if not list_of_ports: - return await self.async_step_manual_pick_radio_type() - - list_of_ports.append(CONF_MANUAL_PATH) - if user_input is not None: - user_selection = user_input[CONF_DEVICE_PATH] - - if user_selection == CONF_MANUAL_PATH: - return await self.async_step_manual_pick_radio_type() - - port = ports[list_of_ports.index(user_selection)] - self._radio_mgr.device_path = port.device + device_path = user_input[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path probe_result = await self._radio_mgr.detect_radio_type() if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: @@ -299,34 +203,25 @@ class BaseZhaFlow(ConfigEntryBaseFlow): description_placeholders={"repair_url": REPAIR_MY_URL}, ) if probe_result == ProbeResult.PROBING_FAILED: - # Did not autodetect anything, proceed to manual selection + # Did not autodetect anything, proceed to manual radio type return await self.async_step_manual_pick_radio_type() - self._title = ( - f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" - f" - {port.manufacturer}" - if port.manufacturer - else "" - ) - return await self.async_step_verify_radio() - # Pre-select the currently configured port - default_port: vol.Undefined | str = vol.UNDEFINED - - if self._radio_mgr.device_path is not None: - for description, port in zip(list_of_ports, ports, strict=False): - if port.device == self._radio_mgr.device_path: - default_port = description - break - else: - default_port = CONF_MANUAL_PATH - + default_path = self._radio_mgr.device_path or vol.UNDEFINED schema = vol.Schema( { - vol.Required(CONF_DEVICE_PATH, default=default_port): vol.In( - list_of_ports - ) + vol.Required( + CONF_DEVICE_PATH, default=default_path + ): SerialPortSelector( + SerialPortSelectorConfig( + extra_recommended_domains=[ + "homeassistant_yellow", + "homeassistant_sky_connect", + "homeassistant_connect_zbt2", + ] + ) + ), } ) return self.async_show_form(step_id="choose_serial_port", data_schema=schema) @@ -341,7 +236,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow): ) return await self.async_step_manual_port_config() - # Pre-select the current radio type + # Preselect the current radio type default: vol.Undefined | str = vol.UNDEFINED if self._radio_mgr.radio_type is not None: @@ -364,7 +259,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow): errors = {} if user_input is not None: - self._title = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_settings = DEVICE_SCHEMA( { @@ -754,23 +648,10 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose an automatic backup.""" - if self.show_advanced_options: - # Always show the PAN IDs when in advanced mode - choices = [ - _format_backup_choice(backup, pan_ids=True) - for backup in self._radio_mgr.backups - ] - else: - # Only show the PAN IDs for multiple backups taken on the same day - num_backups_on_date = collections.Counter( - backup.backup_time.date() for backup in self._radio_mgr.backups - ) - choices = [ - _format_backup_choice( - backup, pan_ids=(num_backups_on_date[backup.backup_time.date()] > 1) - ) - for backup in self._radio_mgr.backups - ] + choices = [ + _format_backup_choice(backup, pan_ids=True) + for backup in self._radio_mgr.backups + ] if user_input is not None: index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP]) @@ -869,6 +750,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 5 + MINOR_VERSION = 2 async def _set_unique_id_and_update_ignored_flow( self, unique_id: str, device_path: str @@ -974,7 +856,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm", - description_placeholders={CONF_NAME: self._title}, + description_placeholders={ + CONF_NAME: self.context.get("title_placeholders", {}).get( + CONF_NAME, self._radio_mgr.device_path or "" + ) + }, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: @@ -1000,15 +886,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return self.async_abort(reason="not_zha_device") self._radio_mgr.device_path = dev_path - self._title = description or usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = {CONF_NAME: self._title} + self.context["title_placeholders"] = { + CONF_NAME: description + or usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + } return await self.async_step_confirm() async def async_step_zeroconf( @@ -1067,7 +955,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): ) self.context["title_placeholders"] = {CONF_NAME: title} - self._title = title self._radio_mgr.device_path = device_path self._radio_mgr.radio_type = radio_type self._radio_mgr.device_settings = DEVICE_SCHEMA( @@ -1100,7 +987,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): device_path=device_path, ) - self._title = name self._radio_mgr.radio_type = radio_type self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings @@ -1121,7 +1007,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( entry=zha_config_entries[0], - title=self._title, data=data, reload_even_if_entry_is_unchanged=True, reason="reconfigure_successful", @@ -1136,10 +1021,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): ) await self.async_set_unique_id(unique_id) - return self.async_create_entry( - title=self._title, - data=data, - ) + return self.async_create_entry(title="", data=data) # This should never be reached return self.async_abort(reason="single_instance_allowed") @@ -1155,7 +1037,6 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] - self._title = config_entry.title async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 0428e6f1660..f27fc8583c3 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -13,7 +13,6 @@ ATTR_ENDPOINT_NAMES = "endpoint_names" ATTR_IEEE = "ieee" ATTR_LAST_SEEN = "last_seen" ATTR_LQI = "lqi" -ATTR_MANUFACTURER = "manufacturer" ATTR_MANUFACTURER_CODE = "manufacturer_code" ATTR_NEIGHBORS = "neighbors" ATTR_NWK = "nwk" @@ -64,6 +63,8 @@ DEVICE_PAIRING_STATUS = "pairing_status" DOMAIN = "zha" +LEGACY_ZEROCONF_PORT = 6638 + GROUP_ID = "group_id" @@ -74,3 +75,7 @@ MFG_CLUSTER_ID_START = 0xFC00 ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" + +# Dispatcher signal carrying device reconfigure progress events (bind result, +# attribute reporting result, configure complete) to the websocket subscriber. +SIGNAL_DEVICE_RECONFIGURE_EVENT = "zha_device_reconfigure_event" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 213d5d11150..f6e9aa60a3a 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -1,7 +1,5 @@ """Support for ZHA covers.""" -from __future__ import annotations - import functools import logging from typing import Any @@ -124,31 +122,31 @@ class ZhaCover(ZHAEntity, CoverEntity): """Return the current tilt position of the cover.""" return self.entity_data.entity.current_cover_tilt_position - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.entity_data.entity.async_open_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self.entity_data.entity.async_open_cover_tilt() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.entity_data.entity.async_close_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self.entity_data.entity.async_close_cover_tilt() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.entity_data.entity.async_set_cover_position( @@ -156,7 +154,7 @@ class ZhaCover(ZHAEntity, CoverEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" await self.entity_data.entity.async_set_cover_tilt_position( @@ -164,13 +162,13 @@ class ZhaCover(ZHAEntity, CoverEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.entity_data.entity.async_stop_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self.entity_data.entity.async_stop_cover_tilt() diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 92c4af0ff33..3c532713156 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -1,40 +1,28 @@ """Provides device actions for ZHA devices.""" -from __future__ import annotations - from typing import Any import voluptuous as vol -from zha.exceptions import ZHAException -from zha.zigbee.cluster_handlers.const import ( - CLUSTER_HANDLER_IAS_WD, - CLUSTER_HANDLER_INOVELLI, -) -from zha.zigbee.cluster_handlers.manufacturerspecific import ( - AllLEDEffectType, - SingleLEDEffectType, -) +from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zigpy.zcl.clusters.security import IasWd from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import DOMAIN -from .helpers import async_get_zha_device_proxy +from .helpers import async_get_zha_device_proxy, convert_zha_error_to_ha_error from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics +INOVELLI_CLUSTER_ID = 0xFC31 + ACTION_SQUAWK = "squawk" ACTION_WARN = "warn" -ATTR_DATA = "data" ATTR_IEEE = "ieee" -CONF_ZHA_ACTION_TYPE = "zha_action_type" -ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" -ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command" INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect" INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect" @@ -75,24 +63,18 @@ ACTION_SCHEMA = vol.Any( DEFAULT_ACTION_SCHEMA, ) -DEVICE_ACTIONS = { - CLUSTER_HANDLER_IAS_WD: [ +# Maps a cluster_id the device must expose to the available actions. +DEVICE_ACTIONS_BY_CLUSTER_ID: dict[int, list[dict[str, str]]] = { + IasWd.cluster_id: [ {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, ], - CLUSTER_HANDLER_INOVELLI: [ + INOVELLI_CLUSTER_ID: [ {CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, ], } -DEVICE_ACTION_TYPES = { - ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL, - ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL, - INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, - INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, -} - DEVICE_ACTION_SCHEMAS = { INOVELLI_ALL_LED_EFFECT: vol.Schema( { @@ -118,11 +100,6 @@ SERVICE_NAMES = { ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, } -CLUSTER_HANDLER_MAPPINGS = { - INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, - INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, -} - async def async_call_action_from_config( hass: HomeAssistant, @@ -131,9 +108,9 @@ async def async_call_action_from_config( context: Context | None, ) -> None: """Perform an action based on configuration.""" - await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]]( - hass, config, variables, context - ) + action_type = config[CONF_TYPE] + handler = ACTION_HANDLERS[action_type] + await handler(hass, config, context) async def async_validate_action_config( @@ -152,19 +129,18 @@ async def async_get_actions( zha_device = async_get_zha_device_proxy(hass, device_id).device except KeyError, AttributeError: return [] - cluster_handlers = [ - ch.name - for endpoint in zha_device.endpoints.values() - for ch in endpoint.claimed_cluster_handlers.values() - ] - actions = [ - action - for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items() - for action in cluster_handler_actions - if cluster_handler in cluster_handlers - ] - for action in actions: - action[CONF_DEVICE_ID] = device_id + cluster_ids = { + cluster_id + for ep_id, endpoint in zha_device.device.endpoints.items() + if ep_id != 0 + for cluster_id in endpoint.in_clusters + } + actions: list[dict[str, str]] = [] + for required_cluster_id, cluster_actions in DEVICE_ACTIONS_BY_CLUSTER_ID.items(): + if required_cluster_id in cluster_ids: + actions.extend( + {**action, CONF_DEVICE_ID: device_id} for action in cluster_actions + ) return actions @@ -177,65 +153,75 @@ async def async_get_action_capabilities( return {"extra_fields": fields} -async def _execute_service_based_action( +async def _execute_siren_service( hass: HomeAssistant, config: dict[str, Any], - variables: TemplateVarsType, context: Context | None, ) -> None: - action_type = config[CONF_TYPE] - service_name = SERVICE_NAMES[action_type] try: zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device except KeyError, AttributeError: return - - service_data = {ATTR_IEEE: str(zha_device.ieee)} - await hass.services.async_call( - DOMAIN, service_name, service_data, blocking=True, context=context + DOMAIN, + SERVICE_NAMES[config[CONF_TYPE]], + {ATTR_IEEE: str(zha_device.ieee)}, + blocking=True, + context=context, ) -async def _execute_cluster_handler_command_based_action( - hass: HomeAssistant, - config: dict[str, Any], - variables: TemplateVarsType, - context: Context | None, -) -> None: - action_type = config[CONF_TYPE] - cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type] +def _find_inovelli_cluster(hass: HomeAssistant, config: dict[str, Any]) -> Any: try: zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device - except KeyError, AttributeError: - return - - action_cluster_handler = None - for endpoint in zha_device.endpoints.values(): - for cluster_handler in endpoint.all_cluster_handlers.values(): - if cluster_handler.name == cluster_handler_name: - action_cluster_handler = cluster_handler - break - - if action_cluster_handler is None: + except (KeyError, AttributeError) as err: raise InvalidDeviceAutomationConfig( - f"Unable to execute cluster handler action - cluster handler: {cluster_handler_name} action:" - f" {action_type}" - ) - - if not hasattr(action_cluster_handler, action_type): - raise InvalidDeviceAutomationConfig( - f"Unable to execute cluster handler - cluster handler: {cluster_handler_name} action:" - f" {action_type}" - ) - + f"ZHA device {config[CONF_DEVICE_ID]} not found" + ) from err try: - await getattr(action_cluster_handler, action_type)(**config) - except ZHAException as err: - raise HomeAssistantError(err) from err + return zha_device.device.find_cluster(cluster_id=INOVELLI_CLUSTER_ID) + except ValueError as err: + raise InvalidDeviceAutomationConfig( + f"Device does not expose Inovelli cluster 0x{INOVELLI_CLUSTER_ID:04x}" + ) from err -ZHA_ACTION_TYPES = { - ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, - ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: _execute_cluster_handler_command_based_action, +async def _execute_inovelli_all_led_effect( + hass: HomeAssistant, + config: dict[str, Any], + context: Context | None, +) -> None: + cluster = _find_inovelli_cluster(hass, config) + + async with convert_zha_error_to_ha_error(): + await cluster.led_effect( + led_effect=config["effect_type"], + led_color=config["color"], + led_level=config["level"], + led_duration=config["duration"], + ) + + +async def _execute_inovelli_individual_led_effect( + hass: HomeAssistant, + config: dict[str, Any], + context: Context | None, +) -> None: + cluster = _find_inovelli_cluster(hass, config) + + async with convert_zha_error_to_ha_error(): + await cluster.individual_led_effect( + led_effect=config["effect_type"], + led_color=config["color"], + led_level=config["level"], + led_duration=config["duration"], + led_number=config["led_number"], + ) + + +ACTION_HANDLERS = { + ACTION_SQUAWK: _execute_siren_service, + ACTION_WARN: _execute_siren_service, + INOVELLI_ALL_LED_EFFECT: _execute_inovelli_all_led_effect, + INOVELLI_INDIVIDUAL_LED_EFFECT: _execute_inovelli_individual_led_effect, } diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index c86bb3352b5..84ae2c8b257 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -1,7 +1,5 @@ """Support for the ZHA platform.""" -from __future__ import annotations - import functools from homeassistant.components.device_tracker import ScannerEntity diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 4383aa52afa..7c4b547f6fa 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for ZHA.""" -from __future__ import annotations - import dataclasses from importlib.metadata import version from typing import Any diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index f3a0d0584c2..a885320a335 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -1,7 +1,5 @@ """Entity for Zigbee Home Automation.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from functools import partial @@ -27,7 +25,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN -from .helpers import SIGNAL_REMOVE_ENTITIES, EntityData, convert_zha_error_to_ha_error +from .helpers import ( + SIGNAL_REMOVE_ENTITIES, + SIGNAL_REMOVE_ENTITY, + EntityData, + convert_zha_error_to_ha_error, +) _LOGGER = logging.getLogger(__name__) @@ -163,6 +166,16 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): partial(self.async_remove, force_remove=True), ) ) + self._unsubs.append( + async_dispatcher_connect( + self.hass, + ( + f"{SIGNAL_REMOVE_ENTITY}_" + f"{self.entity_data.entity.PLATFORM}_{self.unique_id}" + ), + self.async_remove, + ) + ) self.entity_data.device_proxy.gateway_proxy.register_entity_reference( self.entity_id, self.entity_data, @@ -189,10 +202,11 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): for unsub in self._unsubs[:]: unsub() self._unsubs.remove(unsub) + self.entity_data.device_proxy.gateway_proxy.remove_entity_reference(self) await super().async_will_remove_from_hass() self.remove_future.set_result(True) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_update(self) -> None: """Update the entity.""" await self.entity_data.entity.async_update() diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 81206f8819e..c37b2d06335 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -1,7 +1,5 @@ """Fans on Zigbee Home Automation networks.""" -from __future__ import annotations - import functools from typing import Any @@ -94,7 +92,7 @@ class ZhaFan(FanEntity, ZHAEntity): """Return the number of speeds the fan supports.""" return self.entity_data.entity.speed_count - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on( self, percentage: int | None = None, @@ -107,19 +105,19 @@ class ZhaFan(FanEntity, ZHAEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" await self.entity_data.entity.async_set_percentage(percentage=percentage) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 436e95f8ef9..3d4d7f2deec 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1,26 +1,24 @@ """Helper functions for the ZHA integration.""" -from __future__ import annotations - import asyncio import collections -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager import copy import dataclasses import enum -import functools import itertools import logging import queue import re import time from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast +from typing import TYPE_CHECKING, Any, NamedTuple, cast from zoneinfo import ZoneInfo import voluptuous as vol +from zha.application import Platform as ZhaPlatform from zha.application.const import ( - ATTR_CLUSTER_ID, ATTR_DEVICE_IEEE, ATTR_TYPE, ATTR_UNIQUE_ID, @@ -30,11 +28,6 @@ from zha.application.const import ( CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, - ZHA_CLUSTER_HANDLER_CFG_DONE, - ZHA_CLUSTER_HANDLER_MSG, - ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA, ZHA_EVENT, ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -73,10 +66,13 @@ from zha.application.platforms import GroupEntity, PlatformEntity from zha.event import EventBase from zha.exceptions import ZHAException from zha.mixins import LogMixin -from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent from zha.zigbee.device import ( - ClusterHandlerConfigurationComplete, + ClusterBindEvent, + ClusterConfigureReportingEvent, Device, + DeviceConfiguredEvent, + DeviceEntityAddedEvent, + DeviceEntityRemovedEvent, DeviceFirmwareInfoUpdatedEvent, ZHAEvent, ) @@ -107,6 +103,7 @@ from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, Platform, @@ -125,16 +122,13 @@ from homeassistant.util.logging import HomeAssistantQueueHandler from .const import ( ATTR_ACTIVE_COORDINATOR, - ATTR_ATTRIBUTES, ATTR_AVAILABLE, - ATTR_CLUSTER_NAME, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_NAMES, ATTR_EXPOSES_FEATURES, ATTR_IEEE, ATTR_LAST_SEEN, ATTR_LQI, - ATTR_MANUFACTURER, ATTR_MANUFACTURER_CODE, ATTR_NEIGHBORS, ATTR_NWK, @@ -144,7 +138,6 @@ from .const import ( ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, - ATTR_SUCCESS, CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, @@ -168,6 +161,7 @@ from .const import ( DEFAULT_DATABASE_NAME, DEVICE_PAIRING_STATUS, DOMAIN, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -206,6 +200,7 @@ DEBUG_RELAY_LOGGERS = [DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, DEBUG_LIB_ZHA] ZHA_GW_MSG_LOG_ENTRY = "log_entry" ZHA_GW_MSG_LOG_OUTPUT = "log_output" SIGNAL_REMOVE_ENTITIES = "zha_remove_entities" +SIGNAL_REMOVE_ENTITY = "zha_remove_entity" GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] SIGNAL_ADD_ENTITIES = "zha_add_entities" ENTITIES = "entities" @@ -449,51 +444,88 @@ class ZHADeviceProxy(EventBase): ) @callback - def handle_zha_channel_configure_reporting( + def handle_zha_cluster_bind(self, event: ClusterBindEvent) -> None: + """Forward a cluster bind result to the reconfigure websocket.""" + async_dispatcher_send( + self.gateway_proxy.hass, + SIGNAL_DEVICE_RECONFIGURE_EVENT, + { + "type": "zha_channel_bind", + "zha_channel_msg_data": { + "cluster_name": event.cluster_name, + "cluster_id": event.cluster_id, + "success": event.success, + }, + }, + ) + + @callback + def handle_zha_cluster_configure_reporting( self, event: ClusterConfigureReportingEvent ) -> None: - """Handle a ZHA cluster configure reporting event.""" + """Forward a cluster reporting-configured result to the reconfigure websocket.""" async_dispatcher_send( self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, + SIGNAL_DEVICE_RECONFIGURE_EVENT, { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - ATTR_CLUSTER_NAME: event.cluster_name, - ATTR_CLUSTER_ID: event.cluster_id, - ATTR_ATTRIBUTES: event.attributes, + "type": "zha_channel_configure_reporting", + "zha_channel_msg_data": { + "cluster_name": event.cluster_name, + "cluster_id": event.cluster_id, + "attributes": event.attributes, }, }, ) @callback - def handle_zha_channel_cfg_done( - self, event: ClusterHandlerConfigurationComplete + def handle_zha_device_configured(self, event: DeviceConfiguredEvent) -> None: + """Forward the device configuration-complete signal to the reconfigure websocket.""" + async_dispatcher_send( + self.gateway_proxy.hass, + SIGNAL_DEVICE_RECONFIGURE_EVENT, + {"type": "zha_channel_cfg_done"}, + ) + + @callback + def handle_zha_device_entity_added_event( + self, event: DeviceEntityAddedEvent ) -> None: - """Handle a ZHA cluster configure reporting event.""" - async_dispatcher_send( - self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_CFG_DONE, - }, + """Handle a new entity being added to a device at runtime.""" + if event.platform is ZhaPlatform.VIRTUAL: + return + + key = (event.platform, event.unique_id) + if (entity := self.device.platform_entities.get(key)) is None: + return + ha_zha_data = get_zha_data(self.gateway_proxy.hass) + ha_zha_data.platforms[Platform(event.platform)].append( + EntityData(entity=entity, device_proxy=self, group_proxy=None) ) + async_dispatcher_send(self.gateway_proxy.hass, SIGNAL_ADD_ENTITIES) @callback - def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None: - """Handle a ZHA cluster bind event.""" - async_dispatcher_send( - self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - ATTR_CLUSTER_NAME: event.cluster_name, - ATTR_CLUSTER_ID: event.cluster_id, - ATTR_SUCCESS: event.success, - }, - }, - ) + def handle_zha_device_entity_removed_event( + self, event: DeviceEntityRemovedEvent + ) -> None: + """Handle an entity being removed from a device at runtime.""" + if event.platform is ZhaPlatform.VIRTUAL: + return + + if not event.remove: + # Soft remove: signal the entity to unload; registry entry stays + async_dispatcher_send( + self.gateway_proxy.hass, + f"{SIGNAL_REMOVE_ENTITY}_{event.platform}_{event.unique_id}", + ) + return + + # Hard remove: delete from registry, also works without a live entity loaded + entity_registry = er.async_get(self.gateway_proxy.hass) + domain = Platform(event.platform) + if entity_id := entity_registry.async_get_entity_id( + domain, DOMAIN, event.unique_id + ): + entity_registry.async_remove(entity_id) class EntityReference(NamedTuple): @@ -814,13 +846,12 @@ class ZHAGatewayProxy(EventBase): def remove_entity_reference(self, entity: ZHAEntity) -> None: """Remove entity reference for given entity_id if found.""" - if entity.zha_device.ieee in self.ha_entity_refs: - entity_refs = self.ha_entity_refs.get(entity.zha_device.ieee) - self.ha_entity_refs[entity.zha_device.ieee] = [ - e - for e in entity_refs # type: ignore[union-attr] - if e.ha_entity_id != entity.entity_id - ] + ieee = entity.entity_data.device_proxy.device.ieee + if (entity_refs := self._ha_entity_refs.get(ieee)) is None: + return + self._ha_entity_refs[ieee] = [ + e for e in entity_refs if e.ha_entity_id != entity.entity_id + ] def _async_get_or_create_device_proxy(self, zha_device: Device) -> ZHADeviceProxy: """Get or create a ZHA device.""" @@ -876,6 +907,9 @@ class ZHAGatewayProxy(EventBase): if isinstance(proxy_object, ZHADeviceProxy): for entity in proxy_object.device.platform_entities.values(): + if entity.PLATFORM is ZhaPlatform.VIRTUAL: + continue + ha_zha_data.platforms[Platform(entity.PLATFORM)].append( EntityData( entity=entity, device_proxy=proxy_object, group_proxy=None @@ -883,6 +917,9 @@ class ZHAGatewayProxy(EventBase): ) else: for entity in proxy_object.group.group_entities.values(): + if entity.PLATFORM is ZhaPlatform.VIRTUAL: + continue + ha_zha_data.platforms[Platform(entity.PLATFORM)].append( EntityData( entity=entity, @@ -894,8 +931,9 @@ class ZHAGatewayProxy(EventBase): def _cleanup_group_entity_registry_entries( self, zha_group_proxy: ZHAGroupProxy ) -> None: - """Remove entity registry entries for group entities when the groups are removed from HA.""" - # first we collect the potential unique ids for entities that could be created from this group + """Remove entity registry entries for removed group entities.""" + # first we collect the potential unique ids for + # entities that could be created from this group possible_entity_unique_ids = [ f"{domain}_zha_group_0x{zha_group_proxy.group.group_id:04x}" for domain in GROUP_ENTITY_DOMAINS @@ -1214,9 +1252,12 @@ def async_add_entities( for entity_data in entities: try: entities_to_add.append(entity_class(entity_data)) - # broad exception to prevent a single entity from preventing an entire platform from loading - # this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the - # alternative is adding try/catch to each entity class __init__ method with a specific exception + # broad exception to prevent a single entity from + # preventing an entire platform from loading. + # this can potentially be caused by a misbehaving + # device or a bad quirk. Not ideal but the + # alternative is adding try/catch to each entity + # class __init__ method with a specific exception except Exception: _LOGGER.exception( "Error while adding entity from entity data: %s", entity_data @@ -1228,19 +1269,6 @@ def async_add_entities( entities.clear() -def _clean_serial_port_path(path: str) -> str: - """Clean the serial port path, applying corrections where necessary.""" - - if path.startswith("socket://"): - path = path.strip() - - # Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4) - if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path): - path = path.replace("[", "").replace("]", "") - - return path - - CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( @@ -1279,18 +1307,6 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: assert ha_zha_data.config_entry is not None assert ha_zha_data.yaml_config is not None - # Remove brackets around IP addresses, this no longer works in CPython 3.11.4 - # This will be removed in 2023.11.0 - path = ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - cleaned_path = _clean_serial_port_path(path) - - if path != cleaned_path: - _LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path) - ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path - hass.config_entries.async_update_entry( - ha_zha_data.config_entry, data=ha_zha_data.config_entry.data - ) - # deep copy the yaml config to avoid modifying the original and to safely # pass it to the ZHA library app_config = copy.deepcopy(ha_zha_data.yaml_config.get(CONF_ZIGPY, {})) @@ -1372,19 +1388,24 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: ) -def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity]( - func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], -) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: +@asynccontextmanager +async def convert_zha_error_to_ha_error() -> AsyncGenerator[None]: """Decorate ZHA commands and re-raises ZHAException as HomeAssistantError.""" + try: + yield + except TimeoutError as exc: + raise HomeAssistantError( + "Failed to send request: device did not respond" + ) from exc + except zigpy.exceptions.ZigbeeException as exc: + message = "Failed to send request" - @functools.wraps(func) - async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: - try: - return await func(self, *args, **kwargs) - except ZHAException as err: - raise HomeAssistantError(err) from err + if str(exc): + message = f"{message}: {exc}" - return handler + raise HomeAssistantError(message) from exc + except ZHAException as err: + raise HomeAssistantError(err) from err def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]: diff --git a/homeassistant/components/zha/homeassistant_hardware.py b/homeassistant/components/zha/homeassistant_hardware.py index 18057d3b64d..c4f1e161776 100644 --- a/homeassistant/components/zha/homeassistant_hardware.py +++ b/homeassistant/components/zha/homeassistant_hardware.py @@ -1,7 +1,5 @@ """Home Assistant Hardware firmware utilities.""" -from __future__ import annotations - from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 79927e66ed7..9c9c1aea6d0 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1,7 +1,5 @@ """Lights on Zigbee Home Automation networks.""" -from __future__ import annotations - from collections.abc import Mapping import functools import logging @@ -173,7 +171,7 @@ class Light(LightEntity, ZHAEntity): """Return the current effect.""" return self.entity_data.entity.effect - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" color_temp = ( @@ -191,7 +189,7 @@ class Light(LightEntity, ZHAEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off( diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index dc27ec7a6fa..53c959a7851 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -94,19 +94,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity): """Return true if entity is locked.""" return self.entity_data.entity.is_locked - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.entity_data.entity.async_lock() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self.entity_data.entity.async_unlock() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: """Set the user_code to index X on the lock.""" await self.entity_data.entity.async_set_lock_user_code( @@ -114,19 +114,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_enable_lock_user_code(self, code_slot: int) -> None: """Enable user_code at index X on the lock.""" await self.entity_data.entity.async_enable_lock_user_code(code_slot=code_slot) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_disable_lock_user_code(self, code_slot: int) -> None: """Disable user_code at index X on the lock.""" await self.entity_data.entity.async_disable_lock_user_code(code_slot=code_slot) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_clear_lock_user_code(self, code_slot: int) -> None: """Clear the user_code at index X on the lock.""" await self.entity_data.entity.async_clear_lock_user_code(code_slot=code_slot) diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 595351046ca..8dd7bd1d740 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -1,7 +1,5 @@ """Describe ZHA logbook events.""" -from __future__ import annotations - from collections.abc import Callable from typing import TYPE_CHECKING diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 34afaecac7f..6353c4d8abb 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.1", "serialx==1.1.1"], + "requirements": ["zha==1.4.1"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 4df9c7611bc..b6bca9a3ccc 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -1,7 +1,5 @@ """Support for ZHA AnalogOutput cluster.""" -from __future__ import annotations - import functools import logging from typing import Any @@ -80,7 +78,7 @@ class ZhaNumber(ZHAEntity, RestoreNumber): """Return the unit the value is expressed in.""" return self.entity_data.entity.native_unit_of_measurement - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self.entity_data.entity.async_set_native_value(value=value) diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 7bfeda2c215..92321030f3a 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -1,7 +1,5 @@ """ZHA radio manager.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator import contextlib @@ -409,7 +407,7 @@ class ZhaMultiPANMigrationHelper: create_backup=True ) break - except OSError as err: + except (OSError, HomeAssistantError) as err: if retry >= BACKUP_RETRIES - 1: raise @@ -450,7 +448,7 @@ class ZhaMultiPANMigrationHelper: try: await self._radio_mgr.restore_backup(overwrite_ieee=True) break - except OSError as err: + except (OSError, HomeAssistantError) as err: if retry >= MIGRATION_RETRIES - 1: raise diff --git a/homeassistant/components/zha/repairs/__init__.py b/homeassistant/components/zha/repairs/__init__.py index 3fcbdb66bbc..45a6cab23bf 100644 --- a/homeassistant/components/zha/repairs/__init__.py +++ b/homeassistant/components/zha/repairs/__init__.py @@ -1,7 +1,5 @@ """ZHA repairs for common environmental and device problems.""" -from __future__ import annotations - from typing import Any, cast from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py index ca5918c5cbb..3cdb6a92954 100644 --- a/homeassistant/components/zha/repairs/network_settings_inconsistent.py +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -1,16 +1,13 @@ """ZHA repair for inconsistent network settings.""" -from __future__ import annotations - import logging from typing import Any from zigpy.backups import NetworkBackup -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads_object @@ -124,7 +121,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( step_id="init", @@ -136,7 +133,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow): async def async_step_use_new_settings( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Step to use the new settings found on the radio.""" async with self._radio_mgr.create_zigpy_app(connect=False) as app: app.backups.add_backup(self._new_state) @@ -146,7 +143,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow): async def async_step_restore_old_settings( self, user_input: dict[str, str] | None = None - ) -> FlowResult: + ) -> RepairsFlowResult: """Step to restore the most recent backup.""" await self._radio_mgr.restore_backup(self._old_state) diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py index a727b9dc19d..9bbdac23588 100644 --- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -1,7 +1,5 @@ """ZHA repairs for common environmental and device problems.""" -from __future__ import annotations - import enum import logging diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 4a38738b7dd..d0e97155b10 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -1,7 +1,5 @@ """Support for ZHA controls using the select platform.""" -from __future__ import annotations - import functools import logging from typing import Any @@ -60,7 +58,7 @@ class ZHAEnumSelectEntity(ZHAEntity, SelectEntity): """Return the selected entity option to represent the entity state.""" return self.entity_data.entity.current_option - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_data.entity.async_select_option(option=option) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 73d773b1640..b22726ed022 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,7 +1,5 @@ """Sensors on Zigbee Home Automation networks.""" -from __future__ import annotations - from collections.abc import Mapping import functools import logging @@ -96,7 +94,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -# pylint: disable-next=hass-invalid-inheritance # needs fixing +# pylint: disable-next=home-assistant-invalid-inheritance # needs fixing class Sensor(ZHAEntity, SensorEntity): """ZHA sensor.""" diff --git a/homeassistant/components/zha/silabs_multiprotocol.py b/homeassistant/components/zha/silabs_multiprotocol.py index aec52b4ac75..f09cc8dd5d0 100644 --- a/homeassistant/components/zha/silabs_multiprotocol.py +++ b/homeassistant/components/zha/silabs_multiprotocol.py @@ -1,7 +1,5 @@ """Silicon Labs Multiprotocol support.""" -from __future__ import annotations - import asyncio import contextlib diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 0c8b447cb37..e88a0053a9a 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -1,19 +1,12 @@ """Support for ZHA sirens.""" -from __future__ import annotations - import functools from typing import Any -from zha.application.const import ( - WARNING_DEVICE_MODE_BURGLAR, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_MODE_EMERGENCY_PANIC, - WARNING_DEVICE_MODE_FIRE, - WARNING_DEVICE_MODE_FIRE_PANIC, - WARNING_DEVICE_MODE_POLICE_PANIC, +from zha.application.platforms.siren import ( + SirenEntityFeature as ZHASirenEntityFeature, + WarningMode, ) -from zha.application.platforms.siren import SirenEntityFeature as ZHASirenEntityFeature from homeassistant.components.siren import ( ATTR_DURATION, @@ -61,12 +54,12 @@ class ZHASiren(ZHAEntity, SirenEntity): """Representation of a ZHA siren.""" _attr_available_tones: list[int | str] | dict[int, str] | None = { - WARNING_DEVICE_MODE_BURGLAR: "Burglar", - WARNING_DEVICE_MODE_FIRE: "Fire", - WARNING_DEVICE_MODE_EMERGENCY: "Emergency", - WARNING_DEVICE_MODE_POLICE_PANIC: "Police Panic", - WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", - WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", + WarningMode.Burglar: "Burglar", + WarningMode.Fire: "Fire", + WarningMode.Emergency: "Emergency", + WarningMode.Police_Panic: "Police Panic", + WarningMode.Fire_Panic: "Fire Panic", + WarningMode.Emergency_Panic: "Emergency Panic", } def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: @@ -94,7 +87,7 @@ class ZHASiren(ZHAEntity, SirenEntity): """Return True if entity is on.""" return self.entity_data.entity.is_on - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" await self.entity_data.entity.async_turn_on( @@ -104,7 +97,7 @@ class ZHASiren(ZHAEntity, SirenEntity): ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" await self.entity_data.entity.async_turn_off() diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index dc150e2407d..c86f82bbdcf 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -1,7 +1,5 @@ """Switches on Zigbee Home Automation networks.""" -from __future__ import annotations - import functools import logging from typing import Any @@ -51,13 +49,13 @@ class Switch(ZHAEntity, SwitchEntity): """Return if the switch is on based on the statemachine.""" return self.entity_data.entity.is_on - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.entity_data.entity.async_turn_on() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off() diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 867e4ff2dd3..d5364fccb50 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -1,7 +1,5 @@ """Representation of ZHA updates.""" -from __future__ import annotations - import functools import logging from typing import Any @@ -75,7 +73,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-class-module +class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=home-assistant-enforce-class-module """Firmware update coordinator that broadcasts updates network-wide.""" def __init__( @@ -183,7 +181,7 @@ class ZHAFirmwareUpdateEntity( return self.entity_data.entity.release_url # We explicitly convert ZHA exceptions to HA exceptions here so there is no need to - # use the `@convert_zha_error_to_ha_error` decorator. + # use the `@convert_zha_error_to_ha_error()` decorator. async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 431a567e408..bbca47f09de 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -1,7 +1,5 @@ """Web socket API for Zigbee Home Automation devices.""" -from __future__ import annotations - import asyncio import logging from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast @@ -31,12 +29,6 @@ from zha.application.const import ( CLUSTER_COMMANDS_SERVER, CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_CLUSTER_HANDLER_MSG, ZHA_GW_MSG, ) from zha.application.gateway import Gateway @@ -46,8 +38,14 @@ from zha.application.helpers import ( get_matched_clusters, qr_to_install_code, ) -from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD -from zha.zigbee.device import Device +from zha.application.platforms.siren import ( + BaseSiren, + SirenLevel, + SquawkMode, + Strobe, + StrobeLevel, + WarningMode, +) from zha.zigbee.group import GroupMemberReference import zigpy.backups from zigpy.config import CONF_DEVICE @@ -62,7 +60,7 @@ import zigpy.zdo.types as zdo_types from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -82,6 +80,7 @@ from .const import ( GROUP_IDS, GROUP_NAME, MFG_CLUSTER_ID_START, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -183,13 +182,13 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = { { vol.Required(ATTR_IEEE): IEEE_SCHEMA, vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ATTR_WARNING_DEVICE_MODE, default=SquawkMode.Armed ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe ): cv.positive_int, vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ATTR_LEVEL, default=SirenLevel.High_level_sound ): cv.positive_int, } ), @@ -197,20 +196,21 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = { { vol.Required(ATTR_IEEE): IEEE_SCHEMA, vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ATTR_WARNING_DEVICE_MODE, default=WarningMode.Emergency ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe ): cv.positive_int, vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ATTR_LEVEL, default=SirenLevel.High_level_sound ): cv.positive_int, vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, vol.Optional( ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + default=StrobeLevel.High_level_strobe, ): cv.positive_int, } ), @@ -427,10 +427,7 @@ async def websocket_get_groupable_devices( ), } for entity_ref in entity_refs - if list(entity_ref.entity_data.entity.cluster_handlers.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id + if entity_ref.entity_data.entity.endpoint.id == ep_id ], "device": device.zha_device_info, } @@ -635,17 +632,24 @@ async def websocket_remove_group_members( async def websocket_reconfigure_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" + """Reconfigure a ZHA node by its ieee address with a prior re-interview.""" zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] - device: Device | None = zha_gateway.get_device(ieee) + + if zha_gateway.get_device(ieee) is None: + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return async def forward_messages(data): """Forward events to websocket.""" connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages + hass, SIGNAL_DEVICE_RECONFIGURE_EVENT, forward_messages ) @callback @@ -655,9 +659,8 @@ async def websocket_reconfigure_node( connection.subscriptions[msg["id"]] = async_cleanup - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) + _LOGGER.debug("Re-interview node with ieee_address: %s", ieee) + hass.async_create_task(zha_gateway.async_reinterview_device(ieee)) @websocket_api.require_admin @@ -1477,15 +1480,6 @@ def async_load_api(hass: HomeAssistant) -> None: schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], ) - def _get_ias_wd_cluster_handler(zha_device): - """Get the IASWD cluster handler for a device.""" - cluster_handlers = { - ch.name: ch - for endpoint in zha_device.endpoints.values() - for ch in endpoint.claimed_cluster_handlers.values() - } - return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD) - async def warning_device_squawk(service: ServiceCall) -> None: """Issue the squawk command for an IAS warning device.""" ieee: EUI64 = service.data[ATTR_IEEE] @@ -1493,30 +1487,10 @@ def async_load_api(hass: HomeAssistant) -> None: strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] level: int = service.data[ATTR_LEVEL] - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if cluster_handler := _get_ias_wd_cluster_handler(zha_device): - await cluster_handler.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD cluster handler!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_squawk(mode=mode, strobe=strobe, squawk_level=level) async_register_admin_service( hass, @@ -1536,31 +1510,16 @@ def async_load_api(hass: HomeAssistant) -> None: duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if cluster_handler := _get_ias_wd_cluster_handler(zha_device): - await cluster_handler.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD cluster handler!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_turn_on( + tone=mode, + volume_level=level, + duration=duration, + strobe=strobe, + strobe_duty_cycle=duty_mode, + strobe_intensity=intensity, ) async_register_admin_service( diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index d02c91f77b5..226e9b45a30 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -1,7 +1,5 @@ """Support for ZhongHong HVAC Controller.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index fe180208801..181ccb1dd52 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -1,7 +1,5 @@ """Support for interface with a Ziggo Mediabox XL.""" -from __future__ import annotations - import logging import socket diff --git a/homeassistant/components/zimi/__init__.py b/homeassistant/components/zimi/__init__.py index 37244bb49e9..2f92429729e 100644 --- a/homeassistant/components/zimi/__init__.py +++ b/homeassistant/components/zimi/__init__.py @@ -1,7 +1,5 @@ """The zcc integration.""" -from __future__ import annotations - import logging from zcc import ControlPoint, ControlPointError diff --git a/homeassistant/components/zimi/config_flow.py b/homeassistant/components/zimi/config_flow.py index 1037a05a2ce..3a1fa706943 100644 --- a/homeassistant/components/zimi/config_flow.py +++ b/homeassistant/components/zimi/config_flow.py @@ -1,7 +1,5 @@ """Config flow for zcc integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py index e39011ae0b9..e3cab68698b 100644 --- a/homeassistant/components/zimi/cover.py +++ b/homeassistant/components/zimi/cover.py @@ -1,7 +1,5 @@ """Platform for cover integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py index 12d8f336bf0..c43709d3c39 100644 --- a/homeassistant/components/zimi/entity.py +++ b/homeassistant/components/zimi/entity.py @@ -1,7 +1,5 @@ """Base entity for zimi integrations.""" -from __future__ import annotations - import logging from zcc import ControlPoint @@ -44,7 +42,7 @@ class ZimiEntity(Entity): @property def available(self) -> bool: - """Return True if Home Assistant is able to read the state and control the underlying device.""" + """Return True if HA can read state and control the device.""" return self._device.is_connected async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/zimi/fan.py b/homeassistant/components/zimi/fan.py index 19c51371d1a..653b9df5b5d 100644 --- a/homeassistant/components/zimi/fan.py +++ b/homeassistant/components/zimi/fan.py @@ -1,7 +1,5 @@ """Platform for fan integration.""" -from __future__ import annotations - import logging import math from typing import Any diff --git a/homeassistant/components/zimi/helpers.py b/homeassistant/components/zimi/helpers.py index 81d9a986f46..f2c3f392645 100644 --- a/homeassistant/components/zimi/helpers.py +++ b/homeassistant/components/zimi/helpers.py @@ -1,7 +1,5 @@ """The zcc integration helpers.""" -from __future__ import annotations - import logging from zcc import ControlPoint, ControlPointDescription diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py index d5b7e10d9b3..d449ffe9c10 100644 --- a/homeassistant/components/zimi/light.py +++ b/homeassistant/components/zimi/light.py @@ -1,7 +1,5 @@ """Light platform for zcc integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index eea74330970..fef7b764a99 100644 --- a/homeassistant/components/zimi/manifest.json +++ b/homeassistant/components/zimi/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["zcc-helper==3.7"] + "requirements": ["zcc-helper==3.8"] } diff --git a/homeassistant/components/zimi/sensor.py b/homeassistant/components/zimi/sensor.py index 2c681f8e69e..d0477715d37 100644 --- a/homeassistant/components/zimi/sensor.py +++ b/homeassistant/components/zimi/sensor.py @@ -1,7 +1,5 @@ """Platform for sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/zimi/switch.py b/homeassistant/components/zimi/switch.py index a5292602a6e..71f43edd299 100644 --- a/homeassistant/components/zimi/switch.py +++ b/homeassistant/components/zimi/switch.py @@ -1,7 +1,5 @@ """Switch platform for zcc integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zinvolt/__init__.py b/homeassistant/components/zinvolt/__init__.py index ff8b7fdfe90..c06ee0e424c 100644 --- a/homeassistant/components/zinvolt/__init__.py +++ b/homeassistant/components/zinvolt/__init__.py @@ -1,7 +1,5 @@ """The Zinvolt integration.""" -from __future__ import annotations - import asyncio from zinvolt import ZinvoltClient @@ -17,6 +15,7 @@ from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/zinvolt/config_flow.py b/homeassistant/components/zinvolt/config_flow.py index f16b26917a4..76601a12050 100644 --- a/homeassistant/components/zinvolt/config_flow.py +++ b/homeassistant/components/zinvolt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Zinvolt integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zinvolt/coordinator.py b/homeassistant/components/zinvolt/coordinator.py index c2471f162da..c16843e98fb 100644 --- a/homeassistant/components/zinvolt/coordinator.py +++ b/homeassistant/components/zinvolt/coordinator.py @@ -54,7 +54,7 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): _LOGGER, config_entry=config_entry, name=f"Zinvolt {battery.identifier}", - update_interval=timedelta(minutes=5), + update_interval=timedelta(seconds=30), ) self.battery = battery self.client = client diff --git a/homeassistant/components/zinvolt/diagnostics.py b/homeassistant/components/zinvolt/diagnostics.py index 40e2dc49d5c..984c4de7052 100644 --- a/homeassistant/components/zinvolt/diagnostics.py +++ b/homeassistant/components/zinvolt/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Zinvolt.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index 53e7b74ed00..a73f18e6c80 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.4.1"] + "requirements": ["zinvolt==0.4.3"] } diff --git a/homeassistant/components/zinvolt/select.py b/homeassistant/components/zinvolt/select.py new file mode 100644 index 00000000000..efa2bcbba1f --- /dev/null +++ b/homeassistant/components/zinvolt/select.py @@ -0,0 +1,56 @@ +"""Select platform for Zinvolt integration.""" + +from zinvolt.models import SmartMode + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + +MODE_MAP = { + SmartMode.DYNAMIC: "dynamic", + SmartMode.SELF_USE: "self_use", + SmartMode.PERFORMANCE: "fast_discharge", + SmartMode.CHARGED: "fast_charge", + SmartMode.FEED: "connected_solar_panels", +} + +HA_TO_MODE = {v: k for k, v in MODE_MAP.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryMode(coordinator) for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryMode(ZinvoltEntity, SelectEntity): + """Zinvolt select.""" + + _attr_options = list(HA_TO_MODE.keys()) + _attr_translation_key = "battery_mode" + + def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None: + """Initialize the select.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.battery.serial_number}.mode" + + @property + def current_option(self) -> str | None: + """Return the current battery mode.""" + return MODE_MAP.get(self.coordinator.data.battery.smart_mode) + + async def async_select_option(self, option: str) -> None: + """Set battery mode.""" + await self.coordinator.client.set_smart_mode( + self.coordinator.battery.identifier, HA_TO_MODE[option] + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/zinvolt/strings.json b/homeassistant/components/zinvolt/strings.json index d4bc22a1247..4612949a7a9 100644 --- a/homeassistant/components/zinvolt/strings.json +++ b/homeassistant/components/zinvolt/strings.json @@ -61,6 +61,18 @@ "upper_threshold": { "name": "Maximum charge level" } + }, + "select": { + "battery_mode": { + "name": "Mode", + "state": { + "connected_solar_panels": "Connected solar panels", + "dynamic": "Dynamic", + "fast_charge": "Fast charge", + "fast_discharge": "Fast discharge", + "self_use": "Self-use" + } + } } }, "exceptions": { diff --git a/homeassistant/components/zodiac/config_flow.py b/homeassistant/components/zodiac/config_flow.py index a9ed49568ca..1f974565215 100644 --- a/homeassistant/components/zodiac/config_flow.py +++ b/homeassistant/components/zodiac/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Zodiac integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index 41f200366ae..997d3bb8964 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -1,7 +1,5 @@ """Support for tracking the zodiac sign.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index ca707c02f9a..f827b4aaf8e 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,7 +1,5 @@ """Support for the definition of zones.""" -from __future__ import annotations - from collections.abc import Callable import logging from operator import attrgetter @@ -24,10 +22,7 @@ from homeassistant.const import ( CONF_RADIUS, EVENT_CORE_CONFIG_UPDATE, SERVICE_RELOAD, - STATE_HOME, - STATE_NOT_HOME, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import ( Event, @@ -46,7 +41,6 @@ from homeassistant.helpers import ( storage, ) from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance @@ -183,7 +177,63 @@ def async_in_zones( return (closest, [itm[0] for itm in zones]) -@bind_hass +def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[str]: + """Find zones which fully contain the given zone. + + Returns a list of zone entity_ids whose interior contains the given zone + (``zone_dist + input_radius <= other_zone_radius``); a zone whose edge + touches another zone's edge from the inside counts as contained. Passive + zones are included. The queried zone itself is excluded from the result. + The list is sorted by radius then distance, so the smallest enclosing zone + is first. + + Returns an empty list if the zone does not exist or is unavailable. + + This method must be run in the event loop. + """ + if ( + not (input_zone := hass.states.get(zone_entity_id)) + or input_zone.state == STATE_UNAVAILABLE + ): + return [] + input_attrs = input_zone.attributes + input_latitude: float = input_attrs[ATTR_LATITUDE] + input_longitude: float = input_attrs[ATTR_LONGITUDE] + input_radius: float = input_attrs[ATTR_RADIUS] + + zones: list[tuple[str, float, float]] = [] + + # This can be called before async_setup by device tracker + zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ()) + + for entity_id in zone_entity_ids: + if entity_id == zone_entity_id: + continue + if ( + not (zone := hass.states.get(entity_id)) + # Skip unavailable zones + or zone.state == STATE_UNAVAILABLE + ): + continue + zone_attrs = zone.attributes + if ( + zone_dist := distance( + input_latitude, + input_longitude, + zone_attrs[ATTR_LATITUDE], + zone_attrs[ATTR_LONGITUDE], + ) + ) is None: + continue + zone_radius = zone_attrs[ATTR_RADIUS] + if not zone_dist + input_radius <= zone_radius: + continue + zones.append((zone.entity_id, zone_dist, zone_radius)) + + zones.sort(key=lambda x: (x[2], x[1])) + return [itm[0] for itm in zones] + + def async_active_zone( hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 ) -> State | None: @@ -379,7 +429,6 @@ class Zone(collection.CollectionEntity): config = self._config name: str = config[CONF_NAME] self._attr_name = name - self._case_folded_name = name.casefold() self._attr_unique_id = config.get(CONF_ID) self._attr_icon = config.get(CONF_ICON) @@ -461,16 +510,13 @@ class Zone(collection.CollectionEntity): @callback def _state_is_in_zone(self, state: State | None) -> bool: """Return if given state is in zone.""" + + from homeassistant.components.device_tracker import ( # noqa: PLC0415 + ATTR_IN_ZONES, + ) + return ( state is not None - and state.state - not in ( - STATE_NOT_HOME, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ) - and ( - state.state.casefold() == self._case_folded_name - or (state.state == STATE_HOME and self.entity_id == ENTITY_ID_HOME) - ) + and ATTR_IN_ZONES in state.attributes + and self.entity_id in state.attributes[ATTR_IN_ZONES] ) diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index ee3f286c660..e22688a7fdf 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -1,17 +1,22 @@ """Offer zone automation rules.""" -from __future__ import annotations - from typing import Any, Unpack, cast import voluptuous as vol +from homeassistant.components.device_tracker import ( + ATTR_IN_ZONES, + DOMAIN as DEVICE_TRACKER_DOMAIN, +) +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -19,16 +24,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.condition import ( + ATTR_BEHAVIOR, + BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, - ConditionChecker, ConditionCheckParams, ConditionConfig, + EntityConditionBase, ) from homeassistant.helpers.typing import ConfigType from . import in_zone +from .const import DOMAIN _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -36,6 +48,8 @@ _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { } _CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) +_IN_ZONES_DOMAINS = {DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN} + def zone( hass: HomeAssistant, @@ -72,6 +86,14 @@ def zone( ): return False + # Prefer the in_zones attribute reported by the entity (e.g. person, + # device_tracker) over recomputing membership from coordinates. + if ( + entity.domain in _IN_ZONES_DOMAINS + and (in_zones := entity.attributes.get(ATTR_IN_ZONES)) is not None + ): + return zone_ent.entity_id in in_zones + latitude = entity.attributes.get(ATTR_LATITUDE) longitude = entity.attributes.get(ATTR_LONGITUDE) @@ -117,51 +139,161 @@ class ZoneCondition(Condition): super().__init__(hass, config) assert config.options is not None self._options = config.options + self._entity_ids = self._options.get(CONF_ENTITY_ID, []) + self._zone_entity_ids = self._options.get(CONF_ZONE, []) - async def async_get_checker(self) -> ConditionChecker: - """Wrap action method with zone based condition.""" - entity_ids = self._options.get(CONF_ENTITY_ID, []) - zone_entity_ids = self._options.get(CONF_ZONE, []) + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test if condition.""" + errors = [] - def if_in_zone(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Test if condition.""" - errors = [] - - all_ok = True - for entity_id in entity_ids: - entity_ok = False - for zone_entity_id in zone_entity_ids: - try: - if zone(self._hass, zone_entity_id, entity_id): - entity_ok = True - except ConditionErrorMessage as ex: - errors.append( - ConditionErrorMessage( - "zone", - ( - f"error matching {entity_id} with {zone_entity_id}:" - f" {ex.message}" - ), - ) + all_ok = True + for entity_id in self._entity_ids: + entity_ok = False + for zone_entity_id in self._zone_entity_ids: + try: + if zone(self._hass, zone_entity_id, entity_id): + entity_ok = True + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + ( + f"error matching {entity_id} with {zone_entity_id}:" + f" {ex.message}" + ), ) + ) - if not entity_ok: - all_ok = False + if not entity_ok: + all_ok = False - # Raise the errors only if no definitive result was found - if errors and not all_ok: - raise ConditionErrorContainer("zone", errors=errors) + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) - return all_ok + return all_ok - return if_in_zone + +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(value_source=ATTR_IN_ZONES), + "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), +} + +_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + }, + } +) + + +class _ZoneTargetConditionBase(EntityConditionBase): + """Base for zone-target conditions on person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the condition.""" + super().__init__(hass, config) + assert config.options is not None + self._zone: str = config.options[CONF_ZONE] + + def _in_target_zone(self, entity_state: State) -> bool: + """Check if the entity is currently in the selected zone.""" + in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class InZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is in the selected zone.""" + return self._in_target_zone(entity_state) + + +class NotInZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is not in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is not in the selected zone.""" + return not self._in_target_zone(entity_state) + + +_OCCUPANCY_CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyConditionBase(EntityConditionBase): + """Base for zone occupancy conditions (single zone, no behavior).""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _OCCUPANCY_CONDITION_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE] + config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]} + # `behavior` is needed by `EntityConditionBase.__init__` + config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY + return config + + @staticmethod + def _occupancy_count(entity_state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(entity_state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, entity_state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(entity_state) + return count is not None and count >= 1 + + +class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is occupied.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(entity_state) + + +class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is empty.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(entity_state) == 0 CONDITIONS: dict[str, type[Condition]] = { "_": ZoneCondition, + "in_zone": InZoneCondition, + "not_in_zone": NotInZoneCondition, + "occupancy_is_detected": OccupancyIsDetectedCondition, + "occupancy_is_not_detected": OccupancyIsNotDetectedCondition, } async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the sun conditions.""" + """Return the zone conditions.""" return CONDITIONS diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml new file mode 100644 index 00000000000..2294ecd2c2b --- /dev/null +++ b/homeassistant/components/zone/conditions.yaml @@ -0,0 +1,42 @@ +.condition_zone: &condition_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +in_zone: *condition_zone +not_in_zone: *condition_zone + +.condition_occupancy: &condition_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_is_detected: *condition_occupancy +occupancy_is_not_detected: *condition_occupancy diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index a9829425570..5ff8e494431 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -1,7 +1,35 @@ { + "conditions": { + "in_zone": { + "condition": "mdi:map-marker-check" + }, + "not_in_zone": { + "condition": "mdi:map-marker-remove" + }, + "occupancy_is_detected": { + "condition": "mdi:account-group" + }, + "occupancy_is_not_detected": { + "condition": "mdi:account-off" + } + }, "services": { "reload": { "service": "mdi:reload" } + }, + "triggers": { + "entered": { + "trigger": "mdi:map-marker-plus" + }, + "left": { + "trigger": "mdi:map-marker-minus" + }, + "occupancy_cleared": { + "trigger": "mdi:account-off" + }, + "occupancy_detected": { + "trigger": "mdi:account-group" + } } } diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 346a43e5b26..912cbff16e7 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -1,8 +1,138 @@ { + "common": { + "condition_behavior_name": "Check when", + "condition_for_name": "For at least", + "condition_zone_description": "The zone to test against.", + "condition_zone_name": "Zone", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", + "trigger_zone_description": "The zone to trigger on.", + "trigger_zone_name": "Zone" + }, + "conditions": { + "in_zone": { + "description": "Tests if one or more persons or device trackers are in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is in zone" + }, + "not_in_zone": { + "description": "Tests if one or more persons or device trackers are not in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is not in zone" + }, + "occupancy_is_detected": { + "description": "Tests if a zone is occupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is detected" + }, + "occupancy_is_not_detected": { + "description": "Tests if a zone is empty.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is not detected" + } + }, "services": { "reload": { "description": "Reloads zones from the YAML-configuration.", "name": "Reload zones" } + }, + "triggers": { + "entered": { + "description": "Triggers when one or more persons or device trackers enter a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::trigger_zone_description%]", + "name": "[%key:component::zone::common::trigger_zone_name%]" + } + }, + "name": "Entered zone" + }, + "left": { + "description": "Triggers when one or more persons or device trackers leave a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::trigger_zone_description%]", + "name": "[%key:component::zone::common::trigger_zone_name%]" + } + }, + "name": "Left zone" + }, + "occupancy_cleared": { + "description": "Triggers when a zone transitions from occupied to unoccupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]", + "name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]" + } + }, + "name": "Zone occupancy cleared" + }, + "occupancy_detected": { + "description": "Triggers when a zone transitions to an occupied state.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "Zone" + } + }, + "name": "Zone occupancy detected" + } } } diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 59e0f2f8821..da6e35c8dea 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -1,24 +1,26 @@ """Offer zone automation rules.""" -from __future__ import annotations - import logging +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol +from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, - CONF_PLATFORM, + CONF_FOR, + CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, ) from homeassistant.core import ( CALLBACK_TYPE, Event, EventStateChangedData, - HassJob, HomeAssistant, + State, callback, ) from homeassistant.helpers import ( @@ -26,11 +28,23 @@ from homeassistant.helpers import ( entity_registry as er, location, ) +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, + EntityTriggerBase, + Trigger, + TriggerActionRunner, + TriggerConfig, +) from homeassistant.helpers.typing import ConfigType from . import condition +from .condition import _IN_ZONES_DOMAINS +from .const import DOMAIN EVENT_ENTER = "enter" EVENT_LEAVE = "leave" @@ -40,90 +54,249 @@ _LOGGER = logging.getLogger(__name__) _EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"} -_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + +def _state_has_zone_info(state: State) -> bool: + """Return True if the state can be matched against a zone. + + For device_tracker and person entities an ``in_zones`` attribute is + sufficient even when the state has no coordinates (e.g. a scanner-based + tracker); other entities are matched by their coordinates. + """ + return location.has_location(state) or ( + state.domain in _IN_ZONES_DOMAINS and ATTR_IN_ZONES in state.attributes + ) + + +_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = { + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, + vol.Required(CONF_ZONE): cv.entity_id, + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE), +} + +_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema( { - vol.Required(CONF_PLATFORM): "zone", - vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, - vol.Required(CONF_ZONE): cv.entity_id, - vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( - EVENT_ENTER, EVENT_LEAVE - ), + vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA, + }, +) + +# New-style zone trigger schema +_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + }, } ) - -async def async_validate_trigger_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate trigger config.""" - config = _TRIGGER_SCHEMA(config) - registry = er.async_get(hass) - config[CONF_ENTITY_ID] = er.async_validate_entity_ids( - registry, config[CONF_ENTITY_ID] - ) - return config +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(), + "device_tracker": DomainSpec(), +} -async def async_attach_trigger( - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - *, - platform_type: str = "zone", -) -> CALLBACK_TYPE: - """Listen for state changes based on configuration.""" - trigger_data = trigger_info["trigger_data"] - entity_id: list[str] = config[CONF_ENTITY_ID] - zone_entity_id: str = config[CONF_ZONE] - event: str = config[CONF_EVENT] - job = HassJob(action) +class LegacyZoneTrigger(Trigger): + """Legacy zone trigger (platform: zone).""" - @callback - def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: - """Listen for state changes and calls action.""" - entity = zone_event.data["entity_id"] - from_s = zone_event.data["old_state"] - to_s = zone_event.data["new_state"] + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config, migrating legacy format to options.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _LEGACY_OPTIONS_SCHEMA + ) + return await super().async_validate_complete_config(hass, complete_config) - if (from_s and not location.has_location(from_s)) or ( - to_s and not location.has_location(to_s) - ): - return + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config)) + registry = er.async_get(hass) + config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids( + registry, config[CONF_OPTIONS][CONF_ENTITY_ID] + ) + return config - if not (zone_state := hass.states.get(zone_entity_id)): - _LOGGER.warning( - ( - "Automation '%s' is referencing non-existing zone '%s' in a zone" - " trigger" - ), - trigger_info["name"], - zone_entity_id, + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._options = config.options + + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + entity_id: list[str] = self._options[CONF_ENTITY_ID] + zone_entity_id: str = self._options[CONF_ZONE] + event: str = self._options[CONF_EVENT] + + @callback + def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: + """Listen for state changes and calls action.""" + entity = zone_event.data["entity_id"] + from_s = zone_event.data["old_state"] + to_s = zone_event.data["new_state"] + + if (from_s and not _state_has_zone_info(from_s)) or ( + to_s and not _state_has_zone_info(to_s) + ): + return + + if not (zone_state := self._hass.states.get(zone_entity_id)): + _LOGGER.warning( + "Non-existing zone '%s' in a zone trigger", + zone_entity_id, + ) + return + + from_match = ( + condition.zone(self._hass, zone_state, from_s) if from_s else False ) - return + to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False - from_match = condition.zone(hass, zone_state, from_s) if from_s else False - to_match = condition.zone(hass, zone_state, to_s) if to_s else False - - if (event == EVENT_ENTER and not from_match and to_match) or ( - event == EVENT_LEAVE and from_match and not to_match - ): - description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": platform_type, + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match + ): + description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" + run_action( + { "entity_id": entity, "from_state": from_s, "to_state": to_s, "zone": zone_state, "event": event, - "description": description, - } - }, - to_s.context if to_s else None, - ) + }, + description, + to_s.context if to_s else None, + ) - return async_track_state_change_event(hass, entity_id, zone_automation_listener) + return async_track_state_change_event( + self._hass, entity_id, zone_automation_listener + ) + + +class ZoneTriggerBase(EntityTriggerBase): + """Base for zone-based triggers targeting person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + self._zone: str = self._options[CONF_ZONE] + + def _in_target_zone(self, state: State) -> bool: + """Check if the entity is in the selected zone.""" + in_zones = state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class EnteredZoneTrigger(ZoneTriggerBase): + """Trigger when an entity enters the selected zone.""" + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the entity was not already in the selected zone.""" + return not self._in_target_zone(from_state) + + def is_valid_state(self, state: State) -> bool: + """Check that the entity is now in the selected zone.""" + return self._in_target_zone(state) + + +class LeftZoneTrigger(ZoneTriggerBase): + """Trigger when an entity leaves the selected zone.""" + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the entity was previously in the selected zone.""" + return self._in_target_zone(from_state) + + 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) + + +_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyTriggerBase(EntityTriggerBase): + """Base for zone occupancy triggers (single zone, no behavior).""" + + _domain_specs = {"zone": DomainSpec()} + _schema = _OCCUPANCY_TRIGGER_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]} + return config + + @staticmethod + def _occupancy_count(state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(state) + return count is not None and count >= 1 + + +class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions to an occupied state.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(state) + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously not occupied.""" + return not self._is_occupied(from_state) + + +class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions from occupied to unoccupied.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(state) == 0 + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously occupied.""" + return self._is_occupied(from_state) + + +TRIGGERS: dict[str, type[Trigger]] = { + "_": LegacyZoneTrigger, + "entered": EnteredZoneTrigger, + "left": LeftZoneTrigger, + "occupancy_detected": OccupancyDetectedTrigger, + "occupancy_cleared": OccupancyClearedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for zones.""" + return TRIGGERS diff --git a/homeassistant/components/zone/triggers.yaml b/homeassistant/components/zone/triggers.yaml new file mode 100644 index 00000000000..81526345b79 --- /dev/null +++ b/homeassistant/components/zone/triggers.yaml @@ -0,0 +1,42 @@ +.trigger_zone: &trigger_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +entered: *trigger_zone +left: *trigger_zone + +.trigger_occupancy: &trigger_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_detected: *trigger_occupancy +occupancy_cleared: *trigger_occupancy diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py index f26f2351b5a..a6f8c6183d7 100644 --- a/homeassistant/components/zoneminder/binary_sensor.py +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -1,7 +1,5 @@ """Support for ZoneMinder binary sensors.""" -from __future__ import annotations - from zoneminder.zm import ZoneMinder from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 851b7492e06..a3fd4e4746b 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -1,7 +1,5 @@ """Support for ZoneMinder camera streaming.""" -from __future__ import annotations - import logging from zoneminder.monitor import Monitor diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 5663da0b308..46edbf7eb0e 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -1,7 +1,5 @@ """Support for ZoneMinder sensors.""" -from __future__ import annotations - import logging import voluptuous as vol diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 7ab6f786cfb..0836cb48245 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -1,7 +1,5 @@ """Support for ZoneMinder switches.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index ca8c761b3b8..71d1a5b9219 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -1,7 +1,5 @@ """The Z-Wave JS integration.""" -from __future__ import annotations - import asyncio from collections import defaultdict import contextlib @@ -1209,14 +1207,14 @@ async def async_ensure_addon_running( if addon_has_esphome and socket_path is not None: addon_config[CONF_ADDON_SOCKET] = socket_path - if addon_state == AddonState.NOT_INSTALLED: + if addon_state is AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( addon_config, catch_error=True, ) raise ConfigEntryNotReady - if addon_state == AddonState.NOT_RUNNING: + if addon_state is AddonState.NOT_RUNNING: addon_manager.async_schedule_setup_addon( addon_config, catch_error=True, diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 12d81146c03..c24b6174467 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -1,18 +1,68 @@ """Provide add-on management.""" -from __future__ import annotations +from typing import Any -from homeassistant.components.hassio import AddonManager +from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.redact import async_redact_data from homeassistant.helpers.singleton import singleton -from .const import ADDON_SLUG, DOMAIN, LOGGER +from .const import ( + ADDON_SLUG, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + DOMAIN, + LOGGER, +) DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" +REDACT_ADDON_OPTION_KEYS = { + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, +} + + +def _redact_sensitive_option_values(message: str, config: dict[str, Any]) -> str: + """Redact sensitive add-on option values in an error string.""" + redacted_config = async_redact_data(config, REDACT_ADDON_OPTION_KEYS) + + for key in REDACT_ADDON_OPTION_KEYS: + option_value = config.get(key) + if not isinstance(option_value, str) or not option_value: + continue + redacted_value = redacted_config.get(key) + if not isinstance(redacted_value, str): + continue + message = message.replace(option_value, redacted_value) + + return message + + +class ZwaveAddonManager(AddonManager): + """Addon manager for Z-Wave JS with redacted option errors.""" + + async def async_set_addon_options(self, config: dict[str, Any]) -> None: + """Set add-on options.""" + try: + await super().async_set_addon_options(config) + except AddonError as err: + raise AddonError( + _redact_sensitive_option_values(str(err), config) + ) from None @singleton(DATA_ADDON_MANAGER) @callback def get_addon_manager(hass: HomeAssistant) -> AddonManager: """Get the add-on manager.""" - return AddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) + return ZwaveAddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 835ba41b433..81fa9e7fa35 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,7 +1,5 @@ """Websocket API for Z-Wave JS.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from contextlib import suppress import dataclasses @@ -671,7 +669,8 @@ async def websocket_node_alerts( "comments": [ { "level": "info", - "text": "This device has been provisioned but is not yet included in the " + "text": "This device has been provisioned" + " but is not yet included in the " "network.", } ], @@ -688,7 +687,9 @@ async def websocket_node_alerts( comments.append( { "level": "warning", - "text": "This device is currently being interviewed and may not be fully operational.", + "text": "This device is currently being" + " interviewed and may not be fully" + " operational.", } ) connection.send_result( @@ -1099,13 +1100,7 @@ async def websocket_provision_smart_start_node( ) return - provisioning_info = ProvisioningEntry( - dsk=qr_info.dsk, - security_classes=qr_info.security_classes, - requested_security_classes=qr_info.requested_security_classes, - protocol=msg.get(PROTOCOL), - additional_properties=qr_info.additional_properties, - ) + additional_properties = qr_info.additional_properties or {} device = None # Create an empty device if device_name is provided @@ -1141,12 +1136,17 @@ async def websocket_provision_smart_start_node( dev_reg.async_update_device( device.id, area_id=msg.get(AREA_ID), name_by_user=device_name ) + additional_properties["device_id"] = device.id - if provisioning_info.additional_properties is None: - provisioning_info.additional_properties = {} - provisioning_info.additional_properties["device_id"] = device.id - - await driver.controller.async_provision_smart_start_node(provisioning_info) + await driver.controller.async_provision_smart_start_node( + ProvisioningEntry( + dsk=qr_info.dsk, + security_classes=qr_info.security_classes, + requested_security_classes=qr_info.requested_security_classes, + protocol=msg.get(PROTOCOL), + additional_properties=additional_properties, + ) + ) if device: connection.send_result(msg[ID], device.id) else: diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 9ec546be756..ab4275cd07a 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -1,7 +1,5 @@ """Representation of Z-Wave binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum @@ -47,6 +45,7 @@ from .helpers import ( is_opening_state_notification_value, ) from .models import ( + FirmwareVersionRange, NewZWaveDiscoverySchema, ValueType, ZwaveDiscoveryInfo, @@ -136,7 +135,7 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @dataclass(frozen=True, kw_only=True) class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): - """Describe an Access Control binary sensor that derives state from Opening state.""" + """Describe an Access Control binary sensor from Opening state.""" state_key: int parse_opening_state: Callable[[OpeningState], bool] @@ -1308,7 +1307,8 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ entity_category=EntityCategory.DIAGNOSTIC, not_states={ 0, - # Lock state values (Lock state schemas consume the value when state 11 is + # Lock state values (Lock state schemas + # consume the value when state 11 is # available, but may not when state 11 is absent) 1, 2, @@ -1327,7 +1327,8 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ # ------------------------------------------------------------------- NewZWaveDiscoverySchema( # Hoppe eHandle ConnectSense (0x0313:0x0701:0x0002) - window tilt sensor. - # The window tilt state is exposed as a binary sensor that is disabled by default + # The window tilt state is exposed as a binary + # sensor that is disabled by default # instead of a notification sensor. We enable that sensor and give it a name # that is more consistent with the other window related entities. platform=Platform.BINARY_SENSOR, @@ -1346,6 +1347,38 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ ), entity_class=ZWaveBooleanBinarySensor, ), + NewZWaveDiscoverySchema( + # Fibaro FGMS001 Motion Sensor: + # On firmware <= 2.8 the device supports Binary Sensor CC v1, which + # does not give us any information about the type of the sensor. + # As a result it is exposed via the generic "Any" sensor type, + # which fits no other discovery schema. + platform=Platform.BINARY_SENSOR, + manufacturer_id={0x010F}, + product_type={0x0800, 0x0801, 0x8800}, + product_id={ + 0x1001, + 0x1002, + 0x2001, + 0x2002, + 0x3001, + 0x3002, + 0x4001, + 0x4002, + 0x6001, + }, + firmware_version_range=FirmwareVersionRange(max="2.8"), + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + property={"Any"}, + type={ValueType.BOOLEAN}, + ), + entity_description=BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + entity_class=ZWaveBooleanBinarySensor, + ), NewZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, primary_value=ZWaveValueDiscoverySchema( diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 36bca858b50..b487eb7934e 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -1,7 +1,5 @@ """Representation of Z-Wave buttons.""" -from __future__ import annotations - from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode @@ -11,10 +9,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .entity import ZWaveBaseEntity -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import ZWaveBaseEntity, ZWaveNodeBaseEntity from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -80,54 +77,16 @@ class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): await self._async_set_value(self.info.primary_value, True) -class ZWaveNodePingButton(ButtonEntity): +class ZWaveNodePingButton(ZWaveNodeBaseEntity, ButtonEntity): """Representation of a ping button entity.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.CONFIG - _attr_has_entity_name = True _attr_translation_key = "ping" def __init__(self, driver: Driver, node: ZwaveNode) -> None: """Initialize a ping Z-Wave device button entity.""" - self.node = node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) self._attr_unique_id = f"{self._base_unique_id}.ping" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) - - async def async_added_to_hass(self) -> None: - """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - - # we don't listen for `remove_entity_on_ready_node` signal because this entity - # is created when the node is added which occurs before ready. It only needs to - # be removed if the node is removed from the network. - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 648b0109e3c..17094625441 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -1,7 +1,5 @@ """Representation of Z-Wave thermostats.""" -from __future__ import annotations - from typing import Any, cast from zwave_js_server.const import CommandClass @@ -567,7 +565,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): class DynamicCurrentTempClimate(ZWaveClimate): - """Representation of a thermostat that can dynamically use a different Zwave Value for current temp.""" + """Thermostat that dynamically uses a different Zwave Value for current temp.""" def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index b22d1af3c56..b19902f4b52 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Z-Wave JS integration.""" -from __future__ import annotations - import asyncio import base64 from contextlib import suppress @@ -11,7 +9,6 @@ from pathlib import Path from typing import Any from awesomeversion import AwesomeVersion -from serial.tools import list_ports import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.exceptions import FailedCommand @@ -160,30 +157,22 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: raise InvalidInput("cannot_connect") from err -def get_usb_ports() -> dict[str, str]: +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = list_ports.comports() port_descriptions = {} - for port in ports: + for port in await usb.async_scan_serial_ports(hass): if (port.manufacturer, port.description) in IGNORED_USB_DEVICES: continue - vid: str | None = None - pid: str | None = None - if port.vid is not None and port.pid is not None: - usb_device = usb.usb_device_from_port(port) - vid = usb_device.vid - pid = usb_device.pid - dev_path = usb.get_serial_by_id(port.device) human_name = usb.human_readable_device_name( - dev_path, + port.device, port.serial_number, port.manufacturer, port.description, - vid, - pid, + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name # Filter out "n/a" descriptions only if there are other ports available non_na_ports = { @@ -196,11 +185,6 @@ def get_usb_ports() -> dict[str, str]: return non_na_ports or port_descriptions -async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: - """Return a dict of USB ports and their friendly names.""" - return await hass.async_add_executor_job(get_usb_ports) - - class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Z-Wave JS.""" @@ -284,7 +268,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # If the RF region is not set, we need to ask the user to select it. return await self.async_step_rf_region() if config_updates := self._addon_config_updates: - # If we have updates to the add-on config, set them before starting the add-on. + # If we have updates to the add-on config, + # set them before starting the add-on. self._addon_config_updates = {} await self._async_set_addon_config(config_updates) @@ -397,7 +382,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): if new_addon_config == addon_config: return - if addon_info.state == AddonState.RUNNING: + if addon_info.state is AddonState.RUNNING: self.restart_addon = True # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) @@ -747,7 +732,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_info = await self._async_get_addon_info() - if addon_info.state == AddonState.RUNNING: + if addon_info.state is AddonState.RUNNING: addon_config = addon_info.options # Use the options set by USB/ESPHome discovery if not self._adapter_discovered: @@ -772,7 +757,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_finish_addon_setup_user() - if addon_info.state == AddonState.NOT_RUNNING: + if addon_info.state is AddonState.NOT_RUNNING: return await self.async_step_configure_addon_user() return await self.async_step_install_addon() @@ -1245,7 +1230,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): addon_info = await self._async_get_addon_info() - if addon_info.state == AddonState.NOT_INSTALLED: + if addon_info.state is AddonState.NOT_INSTALLED: return await self.async_step_install_addon() return await self.async_step_configure_addon_reconfigure() @@ -1283,7 +1268,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): await self._async_set_addon_config(addon_config_updates) - if addon_info.state == AddonState.RUNNING and not self.restart_addon: + if addon_info.state is AddonState.RUNNING and not self.restart_addon: return await self.async_step_finish_addon_setup_reconfigure() if ( @@ -1425,7 +1410,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): step_id="restore_failed", description_placeholders={ "file_path": str(self.backup_filepath), - "file_url": f"data:application/octet-stream;base64,{base64.b64encode(self.backup_data).decode('ascii')}", + "file_url": ( + "data:application/octet-stream;base64," + f"{base64.b64encode(self.backup_data).decode('ascii')}" + ), "file_name": self.backup_filepath.name, }, ) @@ -1572,8 +1560,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_configured") - # We are not aborting if home ID configured here, we just want to make sure that it's set - # We will update a USB based config entry automatically in `async_step_finish_addon_setup_user` + # We are not aborting if home ID configured + # here, we just want to make sure that it's set + # We will update a USB based config entry + # automatically in + # `async_step_finish_addon_setup_user` await self.async_set_unique_id( str(discovery_info.zwave_home_id), raise_on_progress=False ) @@ -1728,7 +1719,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Get the driver from the config entry.""" config_entry = self._reconfigure_config_entry assert config_entry is not None - if config_entry.state != ConfigEntryState.LOADED: + if config_entry.state is not ConfigEntryState.LOADED: raise AbortFlow("Configuration entry is not loaded") client: Client = config_entry.runtime_data.client assert client.driver is not None diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py index 2615bfc72b3..0ec138b81f2 100644 --- a/homeassistant/components/zwave_js/config_validation.py +++ b/homeassistant/components/zwave_js/config_validation.py @@ -3,6 +3,7 @@ from typing import Any import voluptuous as vol +from zwave_js_server.const import CommandClass from homeassistant.helpers import config_validation as cv @@ -18,6 +19,10 @@ BITMASK_SCHEMA = vol.All( lambda value: int(value, 16), ) +COMMAND_CLASS_SCHEMA = vol.All( + vol.Coerce(int), vol.In([cc.value for cc in CommandClass]) +) + def boolean(value: Any) -> bool: """Validate and coerce a boolean value.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 0c8cb785081..d9da60eb1d8 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,7 +1,5 @@ """Constants for the Z-Wave JS integration.""" -from __future__ import annotations - import logging from awesomeversion import AwesomeVersion @@ -115,6 +113,47 @@ SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" SERVICE_SET_LOCK_CONFIGURATION = "set_lock_configuration" SERVICE_SET_VALUE = "set_value" +# credential management attribute constants +ATTR_USER_ACTIVE = "active" +ATTR_CREDENTIAL_DATA = "credential_data" +ATTR_CREDENTIAL_SLOT = "credential_slot" +ATTR_CREDENTIAL_TYPE = "credential_type" +ATTR_CREDENTIAL_RULE = "credential_rule" +ATTR_USER_ID = "user_id" +ATTR_USER_NAME = "user_name" +ATTR_USER_TYPE = "user_type" + +# credential type string values +CREDENTIAL_TYPE_BLE = "ble" +CREDENTIAL_TYPE_DESFIRE = "desfire" +CREDENTIAL_TYPE_EYE_BIOMETRIC = "eye_biometric" +CREDENTIAL_TYPE_FACE_BIOMETRIC = "face_biometric" +CREDENTIAL_TYPE_FINGER_BIOMETRIC = "finger_biometric" +CREDENTIAL_TYPE_HAND_BIOMETRIC = "hand_biometric" +CREDENTIAL_TYPE_NFC = "nfc" +CREDENTIAL_TYPE_PASSWORD = "password" +CREDENTIAL_TYPE_PIN_CODE = "pin_code" +CREDENTIAL_TYPE_RFID_CODE = "rfid_code" +CREDENTIAL_TYPE_UNSPECIFIED_BIOMETRIC = "unspecified_biometric" +CREDENTIAL_TYPE_UWB = "uwb" + +# writable credential types (for set/clear services) +WRITABLE_CREDENTIAL_TYPES = {CREDENTIAL_TYPE_PIN_CODE, CREDENTIAL_TYPE_PASSWORD} + +# user type string values +USER_TYPE_GENERAL = "general" +USER_TYPE_PROGRAMMING = "programming" +USER_TYPE_NON_ACCESS = "non_access" +USER_TYPE_DURESS = "duress" +USER_TYPE_DISPOSABLE = "disposable" +USER_TYPE_EXPIRING = "expiring" +USER_TYPE_REMOTE_ONLY = "remote_only" + +# credential rule string values +CREDENTIAL_RULE_SINGLE = "single" +CREDENTIAL_RULE_DUAL = "dual" +CREDENTIAL_RULE_TRIPLE = "triple" + ATTR_NODES = "nodes" # config parameter ATTR_CONFIG_PARAMETER = "parameter" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4f537968422..e7368d63f26 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -1,7 +1,5 @@ """Support for Z-Wave cover devices.""" -from __future__ import annotations - from typing import Any, cast from zwave_js_server.const import ( @@ -169,7 +167,7 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): @property def current_cover_position(self) -> int | None: - """Return the current position of cover where 0 means closed and 100 is fully open.""" + """Return current position of cover (0=closed, 100=open).""" if ( self._current_position_value is None or self._current_position_value.value is None @@ -234,7 +232,8 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): assert self._stop_position_value # Stop the cover, will stop regardless of the actual direction of travel. result = await self._async_set_value(self._stop_position_value, False) - # When stopping is successful (or unsupervised), we can assume the cover has stopped moving. + # When stopping is successful (or unsupervised), + # we can assume the cover has stopped moving. if result is not None and result.status in ( SetValueStatus.SUCCESS, SetValueStatus.SUCCESS_UNSUPERVISED, @@ -495,18 +494,44 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_open = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_open_position + ) result = await self._async_set_value(self._up_value, True) - # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + # StartLevelChange: SUCCESS means the device started + # moving in the desired direction + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_open + ): self._attr_is_opening = True self._attr_is_closing = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_closed = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_closed_position + ) result = await self._async_set_value(self._down_value, True) - # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + # StartLevelChange: SUCCESS means the device started + # moving in the desired direction + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_closed + ): self._attr_is_opening = False self._attr_is_closing = True self.async_write_ha_state() @@ -514,7 +539,8 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" result = await self._async_set_value(self._up_value, False) - # When stopping is successful (or unsupervised), we can assume the cover has stopped moving. + # When stopping is successful (or unsupervised), + # we can assume the cover has stopped moving. if result is not None and result.status in ( SetValueStatus.SUCCESS, SetValueStatus.SUCCESS_UNSUPERVISED, diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index bec9c8e55ab..ceaf50e9c00 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Z-Wave JS.""" -from __future__ import annotations - from collections import defaultdict import re from typing import Any @@ -30,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from .config_validation import VALUE_SCHEMA +from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA from .const import ( ATTR_COMMAND_CLASS, ATTR_CONFIG_PARAMETER, @@ -122,7 +120,7 @@ SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SET_VALUE, - vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA, vol.Required(ATTR_PROPERTY): vol.Any(int, str), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), @@ -334,7 +332,7 @@ async def async_get_action_capabilities( { vol.Required(ATTR_COMMAND_CLASS): vol.In( { - CommandClass(cc.id).value: cc.name + str(CommandClass(cc.id).value): cc.name for cc in sorted( node.command_classes, key=lambda cc: cc.name ) diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 27c9ff2bd34..68d61578690 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -1,7 +1,5 @@ """Provides helpers for Z-Wave JS device automations.""" -from __future__ import annotations - from zwave_js_server.model.value import ConfigurationValue from homeassistant.config_entries import ConfigEntryState @@ -46,7 +44,7 @@ def async_bypass_dynamic_config_validation(hass: HomeAssistant, device_id: str) config_entry for config_entry in hass.config_entries.async_entries(DOMAIN) if config_entry.entry_id in device.config_entries - and config_entry.state == ConfigEntryState.LOADED + and config_entry.state is ConfigEntryState.LOADED ), None, ) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 8a50c838eec..44a6d714fb2 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -1,7 +1,5 @@ """Provide the device conditions for Z-Wave JS.""" -from __future__ import annotations - from typing import cast import voluptuous as vol @@ -15,7 +13,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from .config_validation import VALUE_SCHEMA +from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA from .const import ( ATTR_COMMAND_CLASS, ATTR_ENDPOINT, @@ -65,7 +63,7 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): VALUE_TYPE, - vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA, vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), @@ -221,7 +219,7 @@ async def async_get_condition_capabilities( { vol.Required(ATTR_COMMAND_CLASS): vol.In( { - CommandClass(cc.id).value: cc.name + str(CommandClass(cc.id).value): cc.name for cc in sorted( node.command_classes, key=lambda cc: cc.name ) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index bfc37328bfb..7292dc10de9 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Z-Wave JS.""" -from __future__ import annotations - import asyncio from typing import Any @@ -31,7 +29,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from .config_validation import VALUE_SCHEMA +from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA from .const import ( ATTR_COMMAND_CLASS, ATTR_DATA_TYPE, @@ -91,7 +89,7 @@ NOTIFICATION_EVENT_CC_MAPPINGS = ( # Event based trigger schemas BASE_EVENT_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA, } ) @@ -162,7 +160,7 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( # zwave_js.value_updated based trigger schemas BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA, vol.Required(ATTR_PROPERTY): vol.Any(int, str), vol.Optional(ATTR_PROPERTY_KEY): vol.Any(None, vol.Coerce(int), str), vol.Optional(ATTR_ENDPOINT, default=0): vol.Any(None, vol.Coerce(int)), @@ -558,7 +556,7 @@ async def async_get_trigger_capabilities( { vol.Required(ATTR_COMMAND_CLASS): vol.In( { - CommandClass(cc.id).value: cc.name + str(CommandClass(cc.id).value): cc.name for cc in sorted( node.command_classes, key=lambda cc: cc.name ) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index b6364fdda91..349ecfd4350 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Z-Wave JS.""" -from __future__ import annotations - from copy import deepcopy from typing import Any diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 36838f53ecf..7ef370b0d69 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1,7 +1,5 @@ """Map Z-Wave nodes and values to Home Assistant entities.""" -from __future__ import annotations - from collections.abc import Generator from dataclasses import dataclass from typing import cast @@ -576,7 +574,8 @@ DISCOVERY_SCHEMAS = [ command_class=CommandClass.SENSOR_MULTILEVEL, endpoint=3, ), - # External sensor (connected to device) with limit by floor sensor (2x sensors) + # External sensor (connected to device) with + # limit by floor sensor (2x sensors) "External with floor limit": ZwaveValueID( property_=THERMOSTAT_CURRENT_TEMP_PROPERTY, command_class=CommandClass.SENSOR_MULTILEVEL, @@ -1053,13 +1052,15 @@ DISCOVERY_SCHEMAS = [ device_class_generic={"Thermostat"}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), - # Handle the different combinations of Binary Switch, Multilevel Switch and Color Switch + # Handle the different combinations of Binary Switch, + # Multilevel Switch and Color Switch # to create switches and/or (colored) lights. The goal is to: # - couple Color Switch CC with Multilevel Switch CC if possible # - couple Color Switch CC with Binary Switch CC as the first fallback # - use Color Switch CC standalone as the last fallback # - # Multilevel Switch CC (+ Color Switch CC) -> Dimmable light with or without color support. + # Multilevel Switch CC (+ Color Switch CC) -> Dimmable + # light with or without color support. ZWaveDiscoverySchema( platform=Platform.LIGHT, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, @@ -1284,19 +1285,19 @@ def async_discover_single_value( continue # check firmware_version_range - if schema.firmware_version_range is not None and ( - ( + if schema.firmware_version_range is not None: + # skip schema if device firmware version is unknown + if value.node.firmware_version is None: + continue + node_firmware = AwesomeVersion(value.node.firmware_version) + if ( schema.firmware_version_range.min is not None - and schema.firmware_version_range.min_ver - > AwesomeVersion(value.node.firmware_version) - ) - or ( + and schema.firmware_version_range.min_ver > node_firmware + ) or ( schema.firmware_version_range.max is not None - and schema.firmware_version_range.max_ver - < AwesomeVersion(value.node.firmware_version) - ) - ): - continue + and schema.firmware_version_range.max_ver < node_firmware + ): + continue # check device_class_generic # If the value has an endpoint but it is missing on the node diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 9087ea8ba68..62372ba1a9f 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -1,7 +1,5 @@ """Data template classes for discovery used to generate additional data for setup.""" -from __future__ import annotations - from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from enum import Enum @@ -493,7 +491,7 @@ class FanValueMappingDataTemplate: @dataclass class ConfigurableFanValueMappingValueMix: - """Mixin data class for defining fan properties that change based on a device configuration option.""" + """Mixin for fan properties that change based on device config.""" configuration_option: ZwaveValueID configuration_value_to_fan_value_mapping: dict[int, FanValueMapping] diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index cb4db816c50..b86cb84e81b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,13 +1,12 @@ """Generic Z-Wave Entity Class.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from typing import Any from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver +from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( SetValueResult, Value as ZwaveValue, @@ -30,7 +29,12 @@ from .const import ( LOGGER, ) from .discovery_data_template import BaseDiscoverySchemaDataTemplate -from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id +from .helpers import ( + get_device_id, + get_device_info, + get_unique_id, + get_valueless_base_unique_id, +) from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo, ZwaveJSConfigEntry @@ -428,3 +432,65 @@ class ZWaveBaseEntity(Entity): raise HomeAssistantError( f"Unable to set value {value.value_id}: {err}" ) from err + + +class ZWaveNodeBaseEntity(Entity): + """Base entity class for Z-Wave node-level (non-value) entities. + + Used for entities that exist for the whole node rather than a specific + Z-Wave Value (e.g. firmware update, ping button, node status sensor). + """ + + _attr_has_entity_name = True + _attr_should_poll = False + + # Subclasses can opt in to also being removed when a node starts a + # reinterview. Useful for entities whose existence depends on CCs that + # may disappear during reinterview. + _remove_on_reinterview = False + + def __init__(self, driver: Driver, node: ZwaveNode) -> None: + """Initialize a Z-Wave node-level entity.""" + self.driver = driver + self.node = node + + self._base_unique_id = get_valueless_base_unique_id(driver, node) + # device may not be precreated in main handler yet + self._attr_device_info = get_device_info(driver, node) + + async def async_poll_value(self, _: bool) -> None: + """Poll a value (no-op for entities not backed by a Z-Wave Value).""" + # We log an error instead of raising an exception because this service + # call occurs in a separate task since it is called via the dispatcher + # and we don't want to raise the exception in that separate task because + # it is confusing to the user. + LOGGER.error( + "There is no value to refresh for %s so the zwave_js.refresh_value" + " service won't work for it", + self.entity_id, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + if self._remove_on_reinterview: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", + self.async_remove, + ) + ) diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 4919d5bb036..127c89e3ed3 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -1,7 +1,5 @@ """Support for Z-Wave controls using the event platform.""" -from __future__ import annotations - from dataclasses import dataclass from zwave_js_server.model.driver import Driver diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 710c0523271..ac320ee2e1e 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -1,7 +1,5 @@ """Support for Z-Wave fans.""" -from __future__ import annotations - import math from typing import Any, cast @@ -270,7 +268,9 @@ class ValueMappingZwaveFan(ZwaveFan): speed_level = math.ceil( percentage_to_ranged_value((1, self.speed_count), percentage) ) - return self.fan_value_mapping.speeds[speed_level - 1][1] + min_speed, max_speed = self.fan_value_mapping.speeds[speed_level - 1] + # Use the midpoint rather than the range maximum for robustness. + return (min_speed + max_speed) // 2 def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: """Convert a Zwave speed to a percentage. diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6ca88e48ac7..da38d7a4681 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Z-Wave JS integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine from dataclasses import astuple, dataclass @@ -307,7 +305,7 @@ def async_get_node_from_device_id( raise ValueError( f"Device {device_id} is not from an existing zwave_js config entry" ) - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") client = entry.runtime_data.client @@ -355,7 +353,7 @@ async def async_get_provisioning_entry_from_device_id( raise ValueError( f"Device {device_id} is not from an existing zwave_js config entry" ) - if entry.state != ConfigEntryState.LOADED: + if entry.state is not ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") client = entry.runtime_data.client @@ -572,12 +570,12 @@ def get_value_state_schema( return vol.Coerce(bool) if value.configuration_value_type == ConfigurationValueType.ENUMERATED: - return vol.In({int(k): v for k, v in value.metadata.states.items()}) + return vol.In({str(int(k)): v for k, v in value.metadata.states.items()}) return None if value.metadata.states: - return vol.In({int(k): v for k, v in value.metadata.states.items()}) + return vol.In({str(int(k)): v for k, v in value.metadata.states.items()}) return vol.All( vol.Coerce(int), diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 83f5e507c01..efc9dfd901a 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -1,7 +1,5 @@ """Representation of Z-Wave humidifiers.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json index 207df728fcc..0a4b941aa25 100644 --- a/homeassistant/components/zwave_js/icons.json +++ b/homeassistant/components/zwave_js/icons.json @@ -63,9 +63,27 @@ "clear_lock_usercode": { "service": "mdi:eraser" }, + "delete_all_credentials": { + "service": "mdi:eraser-variant" + }, + "delete_all_users": { + "service": "mdi:account-remove" + }, + "delete_credential": { + "service": "mdi:eraser" + }, + "delete_user": { + "service": "mdi:account-remove" + }, + "get_credential_capabilities": { + "service": "mdi:information" + }, "get_lock_usercode": { "service": "mdi:lock-smart" }, + "get_users": { + "service": "mdi:account-group" + }, "invoke_cc_api": { "service": "mdi:api" }, @@ -87,12 +105,18 @@ "set_config_parameter": { "service": "mdi:cog" }, + "set_credential": { + "service": "mdi:lock-smart" + }, "set_lock_configuration": { "service": "mdi:shield-lock" }, "set_lock_usercode": { "service": "mdi:lock-smart" }, + "set_user": { + "service": "mdi:account-plus" + }, "set_value": { "service": "mdi:form-textbox" } diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a2e59e4e6b2..cc2ca618949 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -1,7 +1,5 @@ """Support for Z-Wave lights.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any, cast from zwave_js_server.const import ( @@ -512,7 +510,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): class ZwaveColorOnOffLight(ZwaveLight): - """Representation of a colored Z-Wave light with an optional binary switch to turn on/off. + """Colored Z-Wave light with optional binary switch. Dimming for RGB lights is realized by scaling the color channels. """ @@ -582,7 +580,8 @@ class ZwaveColorOnOffLight(ZwaveLight): ColorComponent.BLUE: 255, } elif brightness is not None: - # If brightness gets set, preserve the color and mix it with the new brightness + # If brightness gets set, preserve the color + # and mix it with the new brightness if self.color_mode == ColorMode.HS: scale = brightness / 255 if self._last_on_color is not None: diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 1bdb70bcaa3..85a098e48f0 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -1,7 +1,5 @@ """Representation of Z-Wave locks.""" -from __future__ import annotations - from typing import Any from zwave_js_server.const import CommandClass @@ -27,9 +25,19 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import const, lock_helpers from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .lock_helpers import ( + CREDENTIAL_RULE_REVERSE_MAP, + CREDENTIAL_TYPE_REVERSE_MAP, + USER_TYPE_REVERSE_MAP, + CredentialCapabilitiesResult, + SetCredentialReturn, + SetUserReturn, + UsersResult, +) from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -46,6 +54,17 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { } +def _credential_service_error( + translation_key: str, err: Exception, **extra: str +) -> HomeAssistantError: + """Wrap a zwave-js-server error with a credential-service translation.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"error": str(err), **extra}, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -220,3 +239,98 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): if result.remaining_duration is not None: msg += f" and remaining duration is {result.remaining_duration!s}" LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) + + async def async_set_user(self, **kwargs: Any) -> SetUserReturn: + """Create or update an access-control user on the lock.""" + user_type = kwargs.get(const.ATTR_USER_TYPE) + credential_rule = kwargs.get(const.ATTR_CREDENTIAL_RULE) + try: + return await lock_helpers.async_set_user( + self.info.node, + user_id=kwargs.get(const.ATTR_USER_ID), + user_name=kwargs.get(const.ATTR_USER_NAME), + user_type=( + USER_TYPE_REVERSE_MAP[user_type] if user_type is not None else None + ), + credential_rule=( + CREDENTIAL_RULE_REVERSE_MAP[credential_rule] + if credential_rule is not None + else None + ), + active=kwargs.get(const.ATTR_USER_ACTIVE), + ) + except BaseZwaveJSServerError as err: + raise _credential_service_error("set_user_failed", err) from err + + async def async_delete_user(self, **kwargs: Any) -> None: + """Delete a single access-control user.""" + user_id: int = kwargs[const.ATTR_USER_ID] + try: + await lock_helpers.async_delete_user(self.info.node, user_id) + except BaseZwaveJSServerError as err: + raise _credential_service_error( + "delete_user_failed", err, user_id=str(user_id) + ) from err + + async def async_delete_all_users(self) -> None: + """Delete all access-control users.""" + try: + await lock_helpers.async_delete_all_users(self.info.node) + except BaseZwaveJSServerError as err: + raise _credential_service_error("delete_all_users_failed", err) from err + + async def async_get_credential_capabilities( + self, + ) -> CredentialCapabilitiesResult: + """Return credential management capabilities for the lock.""" + try: + return await lock_helpers.async_get_credential_capabilities(self.info.node) + except BaseZwaveJSServerError as err: + raise _credential_service_error( + "get_credential_capabilities_failed", err + ) from err + + async def async_get_users(self) -> UsersResult: + """Return access-control users for the lock.""" + try: + return await lock_helpers.async_get_users(self.info.node) + except BaseZwaveJSServerError as err: + raise _credential_service_error("get_users_failed", err) from err + + async def async_set_credential(self, **kwargs: Any) -> SetCredentialReturn: + """Add or update a credential for an existing user.""" + credential_type = kwargs[const.ATTR_CREDENTIAL_TYPE] + try: + return await lock_helpers.async_set_credential( + self.info.node, + user_id=kwargs[const.ATTR_USER_ID], + credential_type=CREDENTIAL_TYPE_REVERSE_MAP[credential_type], + credential_data=kwargs[const.ATTR_CREDENTIAL_DATA], + credential_slot=kwargs.get(const.ATTR_CREDENTIAL_SLOT), + ) + except BaseZwaveJSServerError as err: + raise _credential_service_error("set_credential_failed", err) from err + + async def async_delete_credential(self, **kwargs: Any) -> None: + """Delete a single credential.""" + try: + await lock_helpers.async_delete_credential( + self.info.node, + user_id=kwargs[const.ATTR_USER_ID], + credential_type=CREDENTIAL_TYPE_REVERSE_MAP[ + kwargs[const.ATTR_CREDENTIAL_TYPE] + ], + credential_slot=kwargs[const.ATTR_CREDENTIAL_SLOT], + ) + except BaseZwaveJSServerError as err: + raise _credential_service_error("delete_credential_failed", err) from err + + async def async_delete_all_credentials(self, **kwargs: Any) -> None: + """Delete all credentials for a user.""" + user_id: int = kwargs[const.ATTR_USER_ID] + try: + await lock_helpers.async_delete_all_credentials(self.info.node, user_id) + except BaseZwaveJSServerError as err: + raise _credential_service_error( + "delete_all_credentials_failed", err, user_id=str(user_id) + ) from err diff --git a/homeassistant/components/zwave_js/lock_helpers.py b/homeassistant/components/zwave_js/lock_helpers.py new file mode 100644 index 00000000000..e723492519e --- /dev/null +++ b/homeassistant/components/zwave_js/lock_helpers.py @@ -0,0 +1,522 @@ +"""Lock helpers for Z-Wave JS credential management. + +Provides business logic for user/credential CRUD, capability queries, +auto-find logic, and validation. +""" + +import asyncio +from collections import defaultdict +import logging +from typing import TypedDict + +from zwave_js_server.const.command_class.access_control import ( + SetCredentialResult, + SetUserResult, + UserCredentialRule, + UserCredentialType, + UserCredentialUserType, +) +from zwave_js_server.model.access_control import SetUserOptions +from zwave_js_server.model.node import Node + +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .const import ( + CREDENTIAL_RULE_DUAL, + CREDENTIAL_RULE_SINGLE, + CREDENTIAL_RULE_TRIPLE, + CREDENTIAL_TYPE_BLE, + CREDENTIAL_TYPE_DESFIRE, + CREDENTIAL_TYPE_EYE_BIOMETRIC, + CREDENTIAL_TYPE_FACE_BIOMETRIC, + CREDENTIAL_TYPE_FINGER_BIOMETRIC, + CREDENTIAL_TYPE_HAND_BIOMETRIC, + CREDENTIAL_TYPE_NFC, + CREDENTIAL_TYPE_PASSWORD, + CREDENTIAL_TYPE_PIN_CODE, + CREDENTIAL_TYPE_RFID_CODE, + CREDENTIAL_TYPE_UNSPECIFIED_BIOMETRIC, + CREDENTIAL_TYPE_UWB, + DOMAIN, + USER_TYPE_DISPOSABLE, + USER_TYPE_DURESS, + USER_TYPE_EXPIRING, + USER_TYPE_GENERAL, + USER_TYPE_NON_ACCESS, + USER_TYPE_PROGRAMMING, + USER_TYPE_REMOTE_ONLY, +) + +_LOGGER = logging.getLogger(__name__) + +# --- Enum <-> string mappings --- + +CREDENTIAL_TYPE_MAP: dict[UserCredentialType, str] = { + UserCredentialType.PIN_CODE: CREDENTIAL_TYPE_PIN_CODE, + UserCredentialType.PASSWORD: CREDENTIAL_TYPE_PASSWORD, + UserCredentialType.RFID_CODE: CREDENTIAL_TYPE_RFID_CODE, + UserCredentialType.BLE: CREDENTIAL_TYPE_BLE, + UserCredentialType.NFC: CREDENTIAL_TYPE_NFC, + UserCredentialType.UWB: CREDENTIAL_TYPE_UWB, + UserCredentialType.EYE_BIOMETRIC: CREDENTIAL_TYPE_EYE_BIOMETRIC, + UserCredentialType.FACE_BIOMETRIC: CREDENTIAL_TYPE_FACE_BIOMETRIC, + UserCredentialType.FINGER_BIOMETRIC: CREDENTIAL_TYPE_FINGER_BIOMETRIC, + UserCredentialType.HAND_BIOMETRIC: CREDENTIAL_TYPE_HAND_BIOMETRIC, + UserCredentialType.UNSPECIFIED_BIOMETRIC: CREDENTIAL_TYPE_UNSPECIFIED_BIOMETRIC, + UserCredentialType.DESFIRE: CREDENTIAL_TYPE_DESFIRE, +} +CREDENTIAL_TYPE_REVERSE_MAP: dict[str, UserCredentialType] = { + v: k for k, v in CREDENTIAL_TYPE_MAP.items() +} + +USER_TYPE_MAP: dict[UserCredentialUserType, str] = { + UserCredentialUserType.GENERAL: USER_TYPE_GENERAL, + UserCredentialUserType.PROGRAMMING: USER_TYPE_PROGRAMMING, + UserCredentialUserType.NON_ACCESS: USER_TYPE_NON_ACCESS, + UserCredentialUserType.DURESS: USER_TYPE_DURESS, + UserCredentialUserType.DISPOSABLE: USER_TYPE_DISPOSABLE, + UserCredentialUserType.EXPIRING: USER_TYPE_EXPIRING, + UserCredentialUserType.REMOTE_ONLY: USER_TYPE_REMOTE_ONLY, +} +USER_TYPE_REVERSE_MAP: dict[str, UserCredentialUserType] = { + v: k for k, v in USER_TYPE_MAP.items() +} + +CREDENTIAL_RULE_MAP: dict[UserCredentialRule, str] = { + UserCredentialRule.SINGLE: CREDENTIAL_RULE_SINGLE, + UserCredentialRule.DUAL: CREDENTIAL_RULE_DUAL, + UserCredentialRule.TRIPLE: CREDENTIAL_RULE_TRIPLE, +} +CREDENTIAL_RULE_REVERSE_MAP: dict[str, UserCredentialRule] = { + v: k for k, v in CREDENTIAL_RULE_MAP.items() +} + + +_SET_USER_RESULT_KEYS: dict[SetUserResult, str] = { + SetUserResult.ERROR_ADD_REJECTED_LOCATION_OCCUPIED: "user_rejected_add_occupied", + SetUserResult.ERROR_MODIFY_REJECTED_LOCATION_EMPTY: "user_rejected_modify_empty", + SetUserResult.ERROR_UNKNOWN: "user_rejected_unknown", +} + +_SET_CREDENTIAL_RESULT_KEYS: dict[SetCredentialResult, str] = { + SetCredentialResult.ERROR_ADD_REJECTED_LOCATION_OCCUPIED: ( + "credential_rejected_add_occupied" + ), + SetCredentialResult.ERROR_MODIFY_REJECTED_LOCATION_EMPTY: ( + "credential_rejected_modify_empty" + ), + SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL: "credential_rejected_duplicate", + SetCredentialResult.ERROR_MANUFACTURER_SECURITY_RULES: ( + "credential_rejected_manufacturer_rules" + ), + SetCredentialResult.ERROR_DUPLICATE_ADMIN_PIN_CODE: "credential_rejected_duplicate", + SetCredentialResult.ERROR_WRONG_USER_UNIQUE_IDENTIFIER: ( + "credential_rejected_wrong_uuid" + ), + SetCredentialResult.ERROR_UNKNOWN: "credential_rejected_unknown", +} + + +def _raise_on_set_user_error(status: SetUserResult) -> None: + """Raise HomeAssistantError when a user-mutation command is rejected.""" + if status is SetUserResult.OK: + return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=_SET_USER_RESULT_KEYS.get(status, "user_rejected_unknown"), + ) + + +def _raise_on_set_credential_error(status: SetCredentialResult) -> None: + """Raise HomeAssistantError when a credential-mutation command is rejected.""" + if status is SetCredentialResult.OK: + return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=_SET_CREDENTIAL_RESULT_KEYS.get( + status, "credential_rejected_unknown" + ), + ) + + +# --- TypedDicts for structured return values --- + + +class CredentialTypeCapability(TypedDict): + """Capability info for a single credential type.""" + + num_slots: int + min_length: int + max_length: int + supports_learn: bool + + +class CredentialCapabilitiesResult(TypedDict): + """Return type for get_credential_capabilities.""" + + supports_user_management: bool + max_users: int + supported_user_types: list[str] + max_user_name_length: int + supported_credential_rules: list[str] + supported_credential_types: dict[str, CredentialTypeCapability] + + +class Credential(TypedDict): + """A credential reference within a user entry.""" + + type: str + slot: int + + +class UserEntry(TypedDict): + """A single user entry in the users list.""" + + user_id: int + user_name: str | None + active: bool + user_type: str + credential_rule: str | None + credentials: list[Credential] + + +class UsersResult(TypedDict): + """Return type for get_users.""" + + max_users: int + users: list[UserEntry] + + +class SetUserReturn(TypedDict): + """Return type for set_user.""" + + user_id: int + + +class SetCredentialReturn(TypedDict): + """Return type for set_credential.""" + + credential_slot: int + user_id: int + + +# --- Business logic functions --- + + +async def async_get_credential_capabilities( + node: Node, +) -> CredentialCapabilitiesResult: + """Query access-control capabilities for the node.""" + supported = await node.access_control.is_supported() + if not supported: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + user_caps = await node.access_control.get_user_capabilities_cached() + cred_caps = await node.access_control.get_credential_capabilities_cached() + + supported_credential_types: dict[str, CredentialTypeCapability] = {} + for cred_type, capability in cred_caps.supported_credential_types.items(): + type_str = CREDENTIAL_TYPE_MAP.get(cred_type) + if type_str is None: + continue + supported_credential_types[type_str] = CredentialTypeCapability( + num_slots=capability.number_of_credential_slots, + min_length=capability.min_credential_length, + max_length=capability.max_credential_length, + supports_learn=capability.supports_credential_learn, + ) + + return CredentialCapabilitiesResult( + supports_user_management=True, + max_users=user_caps.max_users, + supported_user_types=[ + USER_TYPE_MAP[ut] + for ut in user_caps.supported_user_types + if ut in USER_TYPE_MAP + ], + max_user_name_length=user_caps.max_user_name_length or 0, + supported_credential_rules=[ + CREDENTIAL_RULE_MAP[cr] + for cr in user_caps.supported_credential_rules + if cr in CREDENTIAL_RULE_MAP + ], + supported_credential_types=supported_credential_types, + ) + + +async def async_get_users(node: Node) -> UsersResult: + """List all users with their credential references.""" + supported = await node.access_control.is_supported() + if not supported: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + user_caps = await node.access_control.get_user_capabilities_cached() + users = await node.access_control.get_users_cached() + all_credentials = await node.access_control.get_all_credentials_cached() + + credentials_by_user: defaultdict[int, list[Credential]] = defaultdict(list) + for cred in all_credentials: + credentials_by_user[cred.user_id].append( + Credential( + type=CREDENTIAL_TYPE_MAP.get(cred.type, str(cred.type)), + slot=cred.slot, + ) + ) + + user_list: list[UserEntry] = [ + UserEntry( + user_id=user.user_id, + user_name=user.user_name, + active=user.active, + user_type=USER_TYPE_MAP.get(user.user_type, str(user.user_type)), + credential_rule=( + CREDENTIAL_RULE_MAP.get(user.credential_rule) + if user.credential_rule is not None + else None + ), + credentials=credentials_by_user.get(user.user_id, []), + ) + for user in users + ] + + return UsersResult( + max_users=user_caps.max_users, + users=user_list, + ) + + +async def async_set_user( + node: Node, + user_id: int | None = None, + user_name: str | None = None, + user_type: UserCredentialUserType | None = None, + credential_rule: UserCredentialRule | None = None, + active: bool | None = None, +) -> SetUserReturn: + """Create or update an access-control user. Returns the allocated user_id.""" + supported = await node.access_control.is_supported() + if not supported: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + # Auto-find first available user slot + if user_id is None: + user_caps = await node.access_control.get_user_capabilities_cached() + users = await node.access_control.get_users_cached() + used_ids = {u.user_id for u in users} + user_id = next( + (i for i in range(1, user_caps.max_users + 1) if i not in used_ids), + None, + ) + if user_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_user_slots", + ) + + options = SetUserOptions( + active=active, + user_type=user_type, + user_name=user_name, + credential_rule=credential_rule, + ) + + status = await node.access_control.set_user(user_id, options) + _raise_on_set_user_error(status) + return SetUserReturn(user_id=user_id) + + +async def async_delete_user(node: Node, user_id: int) -> None: + """Delete a single access-control user.""" + if not await node.access_control.is_supported(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + status = await node.access_control.delete_user(user_id) + _raise_on_set_user_error(status) + + +async def async_delete_all_users(node: Node) -> None: + """Delete all access-control users.""" + if not await node.access_control.is_supported(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + status = await node.access_control.delete_all_users() + _raise_on_set_user_error(status) + + +async def async_set_credential( + node: Node, + user_id: int, + credential_type: UserCredentialType, + credential_data: str, + credential_slot: int | None = None, +) -> SetCredentialReturn: + """Add or update a credential (PIN/password only). + + user_id must refer to an existing user. To create a new user, call + async_set_user first, then pass the returned user_id here. This service + does not create or modify users. + """ + supported = await node.access_control.is_supported() + if not supported: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type)) + cred_caps = await node.access_control.get_credential_capabilities_cached() + type_cap = cred_caps.supported_credential_types.get(credential_type) + if type_cap is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_type_not_supported", + translation_placeholders={"credential_type": cred_type_str}, + ) + + # Validate credential_data length and format against device capabilities + if not ( + type_cap.min_credential_length + <= len(credential_data) + <= type_cap.max_credential_length + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_invalid_length", + translation_placeholders={ + "credential_type": cred_type_str, + "min_length": str(type_cap.min_credential_length), + "max_length": str(type_cap.max_credential_length), + }, + ) + if credential_type is UserCredentialType.PIN_CODE and not ( + credential_data.isascii() and credential_data.isdigit() + ): + # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic), + # which the lock firmware cannot store. Restrict to ASCII 0-9. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_pin_not_digits", + ) + + if credential_slot is None: + existing = await node.access_control.get_credentials_by_type_cached( + credential_type + ) + used_slots = {c.slot for c in existing} + credential_slot = next( + ( + s + for s in range(1, type_cap.number_of_credential_slots + 1) + if s not in used_slots + ), + None, + ) + if credential_slot is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_credential_slots", + translation_placeholders={"credential_type": cred_type_str}, + ) + elif not 1 <= credential_slot <= type_cap.number_of_credential_slots: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_slot_out_of_range", + translation_placeholders={ + "credential_type": cred_type_str, + "max_slot": str(type_cap.number_of_credential_slots), + }, + ) + + status = await node.access_control.set_credential( + user_id, credential_type, credential_slot, credential_data + ) + _raise_on_set_credential_error(status) + + return SetCredentialReturn( + credential_slot=credential_slot, + user_id=user_id, + ) + + +async def async_delete_credential( + node: Node, + user_id: int, + credential_type: UserCredentialType, + credential_slot: int, +) -> None: + """Delete a single credential.""" + if not await node.access_control.is_supported(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + status = await node.access_control.delete_credential( + user_id, credential_type, credential_slot + ) + _raise_on_set_credential_error(status) + + +async def async_delete_all_credentials(node: Node, user_id: int) -> None: + """Delete all credentials for a user.""" + if not await node.access_control.is_supported(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="access_control_not_supported", + ) + + credentials = await node.access_control.get_credentials_cached(user_id) + # Until Z-Wave JS exposes a bulk-delete API, we have to delete credentials one at a time. + # Use return_exceptions=True so a single failure does not cancel the remaining deletions + # and leave the user with a partially-deleted credential set. + results = await asyncio.gather( + *( + node.access_control.delete_credential(user_id, cred.type, cred.slot) + for cred in credentials + ), + return_exceptions=True, + ) + failures: list[tuple[int, BaseException]] = [] + for cred, result in zip(credentials, results, strict=True): + if isinstance(result, BaseException): + failures.append((cred.slot, result)) + continue + try: + _raise_on_set_credential_error(result) + except HomeAssistantError as err: + failures.append((cred.slot, err)) + + if not failures: + return + for slot, failure in failures: + _LOGGER.warning( + "Failed to delete credential at slot %s for user %s: %s", + slot, + user_id, + failure, + ) + if len(failures) == 1: + raise failures[0][1] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="delete_all_credentials_partial_failure", + translation_placeholders={ + "user_id": str(user_id), + "failed_count": str(len(failures)), + }, + ) diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 120084788e1..2db0600fd9b 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -1,7 +1,5 @@ """Describe Z-Wave JS logbook events.""" -from __future__ import annotations - from collections.abc import Callable from zwave_js_server.const import CommandClass diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index cdef87d987a..035d9a6f4a5 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.68.0"], + "requirements": ["zwave-js-server-python==0.71.0"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index e4cd414a2bb..cc7a71f8cb7 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -1,7 +1,5 @@ """Functions used to migrate unique IDs for Z-Wave JS entities.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/zwave_js/models.py b/homeassistant/components/zwave_js/models.py index f1cca8f11a3..7413e5322d0 100644 --- a/homeassistant/components/zwave_js/models.py +++ b/homeassistant/components/zwave_js/models.py @@ -1,7 +1,5 @@ """Provide models for the Z-Wave integration.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 982966ce3a9..9e2547c2430 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -1,7 +1,5 @@ """Support for Z-Wave controls using the number platform.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 114c8fc88e5..da8dd0e22ef 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -1,9 +1,10 @@ """Repairs for Z-Wave JS.""" -from __future__ import annotations - -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -23,7 +24,7 @@ class DeviceConfigFileChangedFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return self.async_show_menu( menu_options=["confirm", "ignore"], @@ -32,7 +33,7 @@ class DeviceConfigFileChangedFlow(RepairsFlow): async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" try: node = async_get_node_from_device_id(self.hass, self.device_id) @@ -46,7 +47,7 @@ class DeviceConfigFileChangedFlow(RepairsFlow): async def async_step_ignore( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the ignore step of a fix flow.""" ir.async_get(self.hass).async_ignore( DOMAIN, f"device_config_file_changed.{self.device_id}", True @@ -85,13 +86,13 @@ class MigrateUniqueIDFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the first step of a fix flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: config_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py index 6b73d1362f9..5ca3dcb992d 100644 --- a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -1,7 +1,5 @@ """Script to convert a device diagnostics file to a fixture.""" -from __future__ import annotations - import argparse import json from pathlib import Path @@ -35,7 +33,9 @@ def get_fixtures_dir_path(data: dict) -> Path: f"{device_config['manufacturer']}-{device_config['label']}_state" ) path = Path(__file__).parents[1] - index = path.parts.index("homeassistant") + # Use the rightmost "homeassistant" component to handle repo paths + # that themselves contain a "homeassistant" segment. + index = len(path.parts) - 1 - path.parts[::-1].index("homeassistant") return Path( *path.parts[:index], "tests", diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index b8c84d02c95..4bddb93af49 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -1,7 +1,5 @@ """Support for Z-Wave controls using the select platform.""" -from __future__ import annotations - from typing import cast from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 10c6553d97a..cb199b8001e 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -1,7 +1,5 @@ """Representation of Z-Wave sensors.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import IntEnum @@ -118,8 +116,7 @@ from .discovery_data_template import ( NumericSensorDataTemplate, NumericSensorDataTemplateData, ) -from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity, ZWaveNodeBaseEntity from .migrate import async_migrate_statistics_sensors from .models import ( NewZWaveDiscoverySchema, @@ -561,7 +558,6 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=False, ), ] @@ -633,7 +629,8 @@ async def async_setup_entry( ) ) elif info.platform_hint == "notification": - # prevent duplicate entities for values that are already represented as binary sensors + # prevent duplicate entities for values that are + # already represented as binary sensors if is_valid_notification_binary_sensor(info): return entities.append( @@ -1035,36 +1032,19 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): return {ATTR_VALUE: value} -class ZWaveNodeStatusSensor(SensorEntity): +class ZWaveNodeStatusSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a node status sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True _attr_translation_key = "node_status" def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, node: ZwaveNode ) -> None: """Initialize a generic Z-Wave device entity.""" + super().__init__(driver, node) self.config_entry = config_entry - self.node = node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.node_status" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _status_changed(self, _: dict) -> None: @@ -1074,60 +1054,27 @@ class ZWaveNodeStatusSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when entity is added.""" - # Add value_changed callbacks. + await super().async_added_to_hass() for evt in ("wake up", "sleep", "dead", "alive"): self.async_on_remove(self.node.on(evt, self._status_changed)) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - # we don't listen for `remove_entity_on_ready_node` signal because this entity - # is created when the node is added which occurs before ready. It only needs to - # be removed if the node is removed from the network. - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) self._attr_native_value: str = self.node.status.name.lower() self.async_write_ha_state() -class ZWaveControllerStatusSensor(SensorEntity): +class ZWaveControllerStatusSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a controller status sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True _attr_translation_key = "controller_status" def __init__(self, config_entry: ZwaveJSConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" - self.config_entry = config_entry self.controller = driver.controller node = self.controller.own_node assert node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) + self.config_entry = config_entry self._attr_unique_id = f"{self._base_unique_id}.controller_status" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _status_changed(self, _: dict) -> None: @@ -1137,34 +1084,16 @@ class ZWaveControllerStatusSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when entity is added.""" - # Add value_changed callbacks. + await super().async_added_to_hass() self.async_on_remove(self.controller.on("status changed", self._status_changed)) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - # we don't listen for `remove_entity_on_ready_node` signal because this is not - # a regular node - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) self._attr_native_value: str = self.controller.status.name.lower() -class ZWaveStatisticsSensor(SensorEntity): +class ZWaveStatisticsSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a node/controller statistics sensor.""" entity_description: ZWaveJSStatisticsSensorEntityDescription - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True def __init__( self, @@ -1174,8 +1103,6 @@ class ZWaveStatisticsSensor(SensorEntity): description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" - self.entity_description = description - self.config_entry = config_entry self.statistics_src = statistics_src node = ( statistics_src.own_node @@ -1183,22 +1110,10 @@ class ZWaveStatisticsSensor(SensorEntity): else statistics_src ) assert node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) + self.entity_description = description + self.config_entry = config_entry self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _statistics_updated(self, event_data: dict) -> None: @@ -1228,20 +1143,7 @@ class ZWaveStatisticsSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) + await super().async_added_to_hass() self.async_on_remove( self.statistics_src.on("statistics updated", self._statistics_updated) ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 524a5b6c548..92f45f90ab7 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -1,7 +1,5 @@ """Methods and classes related to executing Z-Wave commands.""" -from __future__ import annotations - import asyncio from collections.abc import Collection, Generator, Sequence import logging @@ -53,6 +51,7 @@ from .helpers import ( async_get_nodes_from_targets, get_value_id_from_unique_id, ) +from .lock_helpers import CREDENTIAL_RULE_REVERSE_MAP, USER_TYPE_REVERSE_MAP _LOGGER = logging.getLogger(__name__) @@ -70,10 +69,114 @@ TARGET_VALIDATORS = { @callback def async_setup_services(hass: HomeAssistant) -> None: """Register integration services.""" + _async_register_credential_services(hass) services = ZWaveServices(hass, er.async_get(hass), dr.async_get(hass)) services.async_register() +@callback +def _async_register_credential_services(hass: HomeAssistant) -> None: + """Register lock-entity credential platform services.""" + uint16_id = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "set_user", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Optional(const.ATTR_USER_ID): uint16_id, + vol.Optional(const.ATTR_USER_NAME): cv.string, + vol.Optional(const.ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()), + vol.Optional(const.ATTR_CREDENTIAL_RULE): vol.In( + CREDENTIAL_RULE_REVERSE_MAP.keys() + ), + vol.Optional(const.ATTR_USER_ACTIVE): cv.boolean, + }, + func="async_set_user", + supports_response=SupportsResponse.ONLY, + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "delete_user", + entity_domain=LOCK_DOMAIN, + schema={vol.Required(const.ATTR_USER_ID): uint16_id}, + func="async_delete_user", + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "delete_all_users", + entity_domain=LOCK_DOMAIN, + schema={}, + func="async_delete_all_users", + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "get_credential_capabilities", + entity_domain=LOCK_DOMAIN, + schema={}, + func="async_get_credential_capabilities", + supports_response=SupportsResponse.ONLY, + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "get_users", + entity_domain=LOCK_DOMAIN, + schema={}, + func="async_get_users", + supports_response=SupportsResponse.ONLY, + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "set_credential", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(const.ATTR_USER_ID): uint16_id, + vol.Required(const.ATTR_CREDENTIAL_TYPE): vol.In( + const.WRITABLE_CREDENTIAL_TYPES + ), + vol.Required(const.ATTR_CREDENTIAL_DATA): cv.string, + vol.Optional(const.ATTR_CREDENTIAL_SLOT): uint16_id, + }, + func="async_set_credential", + supports_response=SupportsResponse.ONLY, + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "delete_credential", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(const.ATTR_USER_ID): uint16_id, + vol.Required(const.ATTR_CREDENTIAL_TYPE): vol.In( + const.WRITABLE_CREDENTIAL_TYPES + ), + vol.Required(const.ATTR_CREDENTIAL_SLOT): uint16_id, + }, + func="async_delete_credential", + ) + + async_register_platform_entity_service( + hass, + const.DOMAIN, + "delete_all_credentials", + entity_domain=LOCK_DOMAIN, + schema={vol.Required(const.ATTR_USER_ID): uint16_id}, + func="async_delete_all_credentials", + ) + + def parameter_name_does_not_need_bitmask( val: dict[str, int | str | list[str]], ) -> dict[str, int | str | list[str]]: @@ -605,11 +708,13 @@ class ZWaveServices: nodes_or_endpoints_list, _results ): if value_size is None: - # async_set_config_parameter still returns (Value, SetConfigParameterResult) + # async_set_config_parameter still returns + # (Value, SetConfigParameterResult) zwave_value = result[0] cmd_status = result[1] else: - # async_set_raw_config_parameter_value now returns just SetConfigParameterResult + # async_set_raw_config_parameter_value now + # returns just SetConfigParameterResult cmd_status = result zwave_value = f"parameter {property_or_property_name}" @@ -617,7 +722,8 @@ class ZWaveServices: msg = "Set configuration parameter %s on Node %s with value %s" else: msg = ( - "Added command to queue to set configuration parameter %s on %s " + "Added command to queue to set" + " configuration parameter %s on %s " "with value %s. Parameter will be set when the device wakes up" ) _LOGGER.info(msg, zwave_value, node_or_endpoint, new_value) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 9895a6a574c..dc1bbbdc1ae 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -90,6 +90,157 @@ set_lock_configuration: selector: boolean: +# --- Credential management services (lock entity-scoped) --- + +set_user: + target: + entity: + domain: lock + integration: zwave_js + fields: + user_id: + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + user_name: + selector: + text: + user_type: + selector: + select: + options: + - general + - programming + - non_access + - duress + - disposable + - expiring + - remote_only + credential_rule: + selector: + select: + options: + - single + - dual + - triple + active: + selector: + boolean: + +delete_user: + target: + entity: + domain: lock + integration: zwave_js + fields: + user_id: + required: true + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + +delete_all_users: + target: + entity: + domain: lock + integration: zwave_js + +get_credential_capabilities: + target: + entity: + domain: lock + integration: zwave_js + +get_users: + target: + entity: + domain: lock + integration: zwave_js + +set_credential: + target: + entity: + domain: lock + integration: zwave_js + fields: + user_id: + required: true + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + credential_type: + required: true + selector: + select: + options: + - pin_code + - password + credential_data: + required: true + selector: + text: + credential_slot: + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + +delete_credential: + target: + entity: + domain: lock + integration: zwave_js + fields: + user_id: + required: true + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + credential_type: + required: true + selector: + select: + options: + - pin_code + - password + credential_slot: + required: true + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + +delete_all_credentials: + target: + entity: + domain: lock + integration: zwave_js + fields: + user_id: + required: true + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + set_config_parameter: fields: area_id: @@ -125,7 +276,6 @@ set_config_parameter: selector: text: bitmask: - advanced: true selector: text: value: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index f63a3bb9144..7990ec46390 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -1,7 +1,5 @@ """Support for Z-Wave controls using the siren platform.""" -from __future__ import annotations - from typing import Any from zwave_js_server.const.command_class.sound_switch import ToneID diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index cc933386d13..26808011b27 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -292,14 +292,89 @@ } }, "exceptions": { + "access_control_not_supported": { + "message": "Access control is not supported on this device" + }, "clear_lock_usercode_failed": { "message": "Unable to clear lock usercode on lock {entity_id} code_slot {code_slot}: {error}" }, + "credential_data_invalid_length": { + "message": "Credential of type {credential_type} must be between {min_length} and {max_length} characters long." + }, + "credential_data_pin_not_digits": { + "message": "PIN code must contain only digits." + }, + "credential_rejected_add_occupied": { + "message": "Cannot add credential: the target slot is already in use." + }, + "credential_rejected_duplicate": { + "message": "This credential is already registered on the device." + }, + "credential_rejected_manufacturer_rules": { + "message": "The credential violates the device's manufacturer security rules (for example, it is too short, too long, or uses a disallowed pattern)." + }, + "credential_rejected_modify_empty": { + "message": "Cannot modify credential: the slot is empty." + }, + "credential_rejected_unknown": { + "message": "The device rejected the credential without giving a reason." + }, + "credential_rejected_wrong_uuid": { + "message": "The device rejected the credential because the user unique identifier does not match." + }, + "credential_slot_out_of_range": { + "message": "Credential slot for {credential_type} must be between 1 and {max_slot}." + }, + "credential_type_not_supported": { + "message": "Credential type {credential_type} is not supported on this device" + }, + "delete_all_credentials_failed": { + "message": "Failed to delete all credentials for user {user_id}: {error}" + }, + "delete_all_credentials_partial_failure": { + "message": "Failed to delete {failed_count} credentials for user {user_id}. See logs for details." + }, + "delete_all_users_failed": { + "message": "Failed to delete all users: {error}" + }, + "delete_credential_failed": { + "message": "Failed to delete credential: {error}" + }, + "delete_user_failed": { + "message": "Failed to delete user {user_id}: {error}" + }, + "get_credential_capabilities_failed": { + "message": "Failed to get credential capabilities: {error}" + }, "get_lock_usercode_not_found": { "message": "Code slot {code_slot} not found on lock {entity_id}" }, + "get_users_failed": { + "message": "Failed to get users: {error}" + }, + "no_available_credential_slots": { + "message": "No available {credential_type} credential slots on this device" + }, + "no_available_user_slots": { + "message": "No available user slots on this device" + }, + "set_credential_failed": { + "message": "Failed to set credential: {error}" + }, "set_lock_usercode_failed": { "message": "Unable to set lock usercode on lock {entity_id} code_slot {code_slot}: {error}" + }, + "set_user_failed": { + "message": "Failed to set user: {error}" + }, + "user_rejected_add_occupied": { + "message": "Cannot add user: the target slot is already in use." + }, + "user_rejected_modify_empty": { + "message": "Cannot modify user: the slot is empty." + }, + "user_rejected_unknown": { + "message": "The device rejected the user update without giving a reason." } }, "issues": { @@ -395,6 +470,52 @@ }, "name": "Clear lock user code" }, + "delete_all_credentials": { + "description": "Deletes all credentials for a user on a lock.", + "fields": { + "user_id": { + "description": "User slot index to delete all credentials for.", + "name": "User index" + } + }, + "name": "Delete all credentials" + }, + "delete_all_users": { + "description": "Deletes all access-control users from a lock.", + "name": "Delete all users" + }, + "delete_credential": { + "description": "Deletes a single credential from a lock.", + "fields": { + "credential_slot": { + "description": "Credential slot index to delete.", + "name": "Credential slot" + }, + "credential_type": { + "description": "The type of credential to delete (pin_code or password).", + "name": "Credential type" + }, + "user_id": { + "description": "User slot index that owns the credential.", + "name": "User index" + } + }, + "name": "Delete credential" + }, + "delete_user": { + "description": "Deletes a single access-control user from a lock.", + "fields": { + "user_id": { + "description": "User slot index to delete.", + "name": "User index" + } + }, + "name": "Delete user" + }, + "get_credential_capabilities": { + "description": "Queries the credential management capabilities of a lock.", + "name": "Get credential capabilities" + }, "get_lock_usercode": { "description": "Gets user codes from a lock.", "fields": { @@ -405,6 +526,10 @@ }, "name": "Get lock user code" }, + "get_users": { + "description": "Lists all access-control users and their credential references on a lock.", + "name": "Get users" + }, "invoke_cc_api": { "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` action and require direct calls to the Command Class API.", "fields": { @@ -611,6 +736,28 @@ }, "name": "Set device configuration parameter" }, + "set_credential": { + "description": "Adds or updates a credential (PIN code or password) for an existing user on a lock entity. Call `set_user` first to create a user if needed.", + "fields": { + "credential_data": { + "description": "The credential data (e.g. PIN digits or password string).", + "name": "Credential data" + }, + "credential_slot": { + "description": "Credential slot index. If not specified, the first available slot is used.", + "name": "Credential slot" + }, + "credential_type": { + "description": "The type of credential (pin_code or password).", + "name": "Credential type" + }, + "user_id": { + "description": "User slot index to assign the credential to. Must refer to an existing user.", + "name": "User index" + } + }, + "name": "Set credential" + }, "set_lock_configuration": { "description": "Sets the configuration for a lock.", "fields": { @@ -655,6 +802,32 @@ }, "name": "Set lock user code" }, + "set_user": { + "description": "Creates or updates an access-control user on a lock entity. Returns the allocated user_id for each targeted lock entity.", + "fields": { + "active": { + "description": "Whether the user is active.", + "name": "Active" + }, + "credential_rule": { + "description": "Credential rule for the user (single, dual, or triple).", + "name": "Credential rule" + }, + "user_id": { + "description": "User slot index. If not specified, the first available slot is used.", + "name": "User index" + }, + "user_name": { + "description": "Display name for the user.", + "name": "User name" + }, + "user_type": { + "description": "The type of user (e.g. general, programming, remote_only).", + "name": "User type" + } + }, + "name": "Set user" + }, "set_value": { "description": "Changes any value that Z-Wave recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", "fields": { diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 75e6b31bc50..7c93a3800ff 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -1,7 +1,5 @@ """Representation of Z-Wave switches.""" -from __future__ import annotations - from typing import Any from zwave_js_server.const import TARGET_VALUE_PROPERTY diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index d25737ffd59..0d47ca9b636 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -1,7 +1,5 @@ """Z-Wave JS trigger dispatcher.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index fb5259f7582..8af2b60ca5c 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -1,7 +1,5 @@ """Offer Z-Wave JS event listening automation trigger.""" -from __future__ import annotations - from collections.abc import Callable import functools from typing import Any diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 03792771bd3..791b262dc7f 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -21,7 +21,7 @@ def async_bypass_dynamic_config_validation( trigger_devices = config.get(ATTR_DEVICE_ID, []) trigger_entities = config.get(ATTR_ENTITY_ID, []) for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state != ConfigEntryState.LOADED and ( + if entry.state is not ConfigEntryState.LOADED and ( entry.entry_id == config.get(ATTR_CONFIG_ENTRY_ID) or any( device.id in trigger_devices diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 22f8ab78dc7..02690861da4 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -1,7 +1,5 @@ """Offer Z-Wave JS value updated listening automation trigger.""" -from __future__ import annotations - from collections.abc import Callable import functools from typing import Any @@ -51,8 +49,8 @@ ATTR_TO = "to" _OPTIONS_SCHEMA_DICT = { vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_COMMAND_CLASS): vol.In( - {cc.value: cc.name for cc in CommandClass} + vol.Required(ATTR_COMMAND_CLASS): vol.All( + vol.Coerce(int), vol.In({cc.value: cc.name for cc in CommandClass}) ), vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 4e7194a498e..417d235e192 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -1,7 +1,5 @@ """Representation of Z-Wave updates.""" -from __future__ import annotations - import asyncio from collections import Counter from collections.abc import Awaitable, Callable @@ -36,7 +34,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import ZWaveNodeBaseEntity from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 1 @@ -164,12 +162,10 @@ async def async_setup_entry( ) -class ZWaveFirmwareUpdateEntity(UpdateEntity): +class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity): """Representation of a firmware update entity.""" - driver: Driver entity_description: ZWaveUpdateEntityDescription - node: ZwaveNode _attr_entity_category = EntityCategory.CONFIG _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_supported_features = ( @@ -177,8 +173,7 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.PROGRESS ) - _attr_has_entity_name = True - _attr_should_poll = False + _remove_on_reinterview = True def __init__( self, @@ -188,9 +183,8 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): entity_description: ZWaveUpdateEntityDescription, ) -> None: """Initialize a Z-Wave device firmware update entity.""" - self.driver = driver + super().__init__(driver, node) self.entity_description = entity_description - self.node = node self._latest_version_firmware: FirmwareUpdateInfo | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None @@ -201,11 +195,8 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): # Entity class attributes self._attr_name = "Firmware" - self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.firmware_update" self._attr_installed_version = node.firmware_version - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) @property def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData: @@ -266,12 +257,13 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): try: # Retrieve all firmware updates including non-stable ones but filter # non-stable channels out - available_firmware_updates = [ - update - for update in await self.driver.controller.async_get_available_firmware_updates( + all_updates = ( + await self.driver.controller.async_get_available_firmware_updates( self.node, API_KEY_FIRMWARE_UPDATE_SERVICE, True ) - if update.channel == "stable" + ) + available_firmware_updates = [ + update for update in all_updates if update.channel == "stable" ] except FailedZWaveCommand as err: LOGGER.debug( @@ -347,41 +339,9 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): self._latest_version_firmware = None self._unsub_firmware_events_and_reset_progress() - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) - async def async_added_to_hass(self) -> None: """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", - self.async_remove, - ) - ) + await super().async_added_to_hass() # Make sure these variables are set for the elif evaluation state = None diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 36ee62eec53..ae496b7d051 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -1,103 +1,37 @@ """The Z-Wave-Me WS integration.""" -from zwave_me_ws import ZWaveMe, ZWaveMeData - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import dispatcher_send -from .const import DOMAIN, PLATFORMS, ZWaveMePlatform - -ZWAVE_ME_PLATFORMS = [platform.value for platform in ZWaveMePlatform] +from .const import PLATFORMS +from .controller import ZWaveMeConfigEntry, ZWaveMeController -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ZWaveMeConfigEntry) -> bool: """Set up Z-Wave-Me from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - controller = hass.data[DOMAIN][entry.entry_id] = ZWaveMeController(hass, entry) - if await controller.async_establish_connection(): - await async_setup_platforms(hass, entry, controller) - registry = dr.async_get(hass) - controller.remove_stale_devices(registry) - return True - raise ConfigEntryNotReady + controller = ZWaveMeController(hass, entry) + + if not await controller.async_establish_connection(): + raise ConfigEntryNotReady + + entry.runtime_data = controller + await async_setup_platforms(hass, entry, controller) + registry = dr.async_get(hass) + controller.remove_stale_devices(registry) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ZWaveMeConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - controller = hass.data[DOMAIN].pop(entry.entry_id) - await controller.zwave_api.close_ws() + await entry.runtime_data.zwave_api.close_ws() return unload_ok -class ZWaveMeController: - """Main ZWave-Me API class.""" - - def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: - """Create the API instance.""" - self.device_ids: set = set() - self._hass = hass - self.config = config - self.zwave_api = ZWaveMe( - on_device_create=self.on_device_create, - on_device_update=self.on_device_update, - on_device_remove=self.on_device_unavailable, - on_device_destroy=self.on_device_destroy, - on_new_device=self.add_device, - token=self.config.data[CONF_TOKEN], - url=self.config.data[CONF_URL], - platforms=ZWAVE_ME_PLATFORMS, - ) - self.platforms_inited = False - - async def async_establish_connection(self): - """Get connection status.""" - return await self.zwave_api.get_connection() - - def add_device(self, device: ZWaveMeData) -> None: - """Send signal to create device.""" - if device.id in self.device_ids: - dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{device.id}", device) - else: - dispatcher_send( - self._hass, f"ZWAVE_ME_NEW_{device.deviceType.upper()}", device - ) - self.device_ids.add(device.id) - - def on_device_create(self, devices: list[ZWaveMeData]) -> None: - """Create multiple devices.""" - for device in devices: - if device.deviceType in ZWAVE_ME_PLATFORMS and self.platforms_inited: - self.add_device(device) - - def on_device_update(self, new_info: ZWaveMeData) -> None: - """Send signal to update device.""" - dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) - - def on_device_unavailable(self, device_id: str) -> None: - """Send signal to set device unavailable.""" - dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") - - def on_device_destroy(self, device_id: str) -> None: - """Send signal to destroy device.""" - dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") - - def remove_stale_devices(self, registry: dr.DeviceRegistry): - """Remove old-format devices in the registry.""" - for device_id in self.device_ids: - device = registry.async_get_device( - identifiers={(DOMAIN, f"{self.config.unique_id}-{device_id}")} - ) - if device is not None: - registry.async_remove_device(device.id) - - async def async_setup_platforms( hass: HomeAssistant, entry: ConfigEntry, controller: ZWaveMeController ) -> None: diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py index 8563ef76ce1..80cac43b5c3 100644 --- a/homeassistant/components/zwave_me/binary_sensor.py +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -1,7 +1,5 @@ """Representation of a sensorBinary.""" -from __future__ import annotations - from zwave_me_ws import ZWaveMeData from homeassistant.components.binary_sensor import ( @@ -9,13 +7,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ZWaveMeController -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry, ZWaveMeController from .entity import ZWaveMeEntity BINARY_SENSORS_MAP: dict[str, BinarySensorEntityDescription] = { @@ -32,22 +29,22 @@ DEVICE_NAME = ZWaveMePlatform.BINARY_SENSOR async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" @callback def add_new_device(new_device: ZWaveMeData) -> None: - controller: ZWaveMeController = hass.data[DOMAIN][config_entry.entry_id] - description = BINARY_SENSORS_MAP.get( - new_device.probeType, BINARY_SENSORS_MAP["generic"] - ) - sensor = ZWaveMeBinarySensor(controller, new_device, description) - async_add_entities( [ - sensor, + ZWaveMeBinarySensor( + config_entry.runtime_data, + new_device, + BINARY_SENSORS_MAP.get( + new_device.probeType, BINARY_SENSORS_MAP["generic"] + ), + ) ] ) diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index 27d95a14199..20584998942 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -1,12 +1,12 @@ """Representation of a toggleButton.""" from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.BUTTON @@ -14,21 +14,14 @@ DEVICE_NAME = ZWaveMePlatform.BUTTON async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - button = ZWaveMeButton(controller, new_device) - - async_add_entities( - [ - button, - ] - ) + async_add_entities([ZWaveMeButton(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index d54cc6a9310..6b17608d40e 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -1,7 +1,5 @@ """Representation of a thermostat.""" -from __future__ import annotations - from typing import Any from zwave_me_ws import ZWaveMeData @@ -11,13 +9,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity TEMPERATURE_DEFAULT_STEP = 0.5 @@ -27,7 +25,7 @@ DEVICE_NAME = ZWaveMePlatform.CLIMATE async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform.""" @@ -35,14 +33,7 @@ async def async_setup_entry( @callback def add_new_device(new_device: ZWaveMeData) -> None: """Add a new device.""" - controller = hass.data[DOMAIN][config_entry.entry_id] - climate = ZWaveMeClimate(controller, new_device) - - async_add_entities( - [ - climate, - ] - ) + async_add_entities([ZWaveMeClimate(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index d37d76a093b..7488856617c 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure ZWaveMe integration.""" -from __future__ import annotations - import logging from url_normalize import url_normalize diff --git a/homeassistant/components/zwave_me/controller.py b/homeassistant/components/zwave_me/controller.py new file mode 100644 index 00000000000..9e68b168837 --- /dev/null +++ b/homeassistant/components/zwave_me/controller.py @@ -0,0 +1,77 @@ +"""The Z-Wave-Me WS controller.""" + +from zwave_me_ws import ZWaveMe, ZWaveMeData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import DOMAIN, ZWaveMePlatform + +type ZWaveMeConfigEntry = ConfigEntry[ZWaveMeController] + +ZWAVE_ME_PLATFORMS = [platform.value for platform in ZWaveMePlatform] + + +class ZWaveMeController: + """Main ZWave-Me API class.""" + + def __init__(self, hass: HomeAssistant, config: ZWaveMeConfigEntry) -> None: + """Create the API instance.""" + self.device_ids: set[str] = set() + self._hass = hass + self.config = config + self.zwave_api = ZWaveMe( + on_device_create=self.on_device_create, + on_device_update=self.on_device_update, + on_device_remove=self.on_device_unavailable, + on_device_destroy=self.on_device_destroy, + on_new_device=self.add_device, + token=self.config.data[CONF_TOKEN], + url=self.config.data[CONF_URL], + platforms=ZWAVE_ME_PLATFORMS, + ) + self.platforms_inited = False + + async def async_establish_connection(self) -> bool: + """Get connection status.""" + return await self.zwave_api.get_connection() + + def add_device(self, device: ZWaveMeData) -> None: + """Send signal to create device.""" + if device.id in self.device_ids: + dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{device.id}", device) + else: + dispatcher_send( + self._hass, f"ZWAVE_ME_NEW_{device.deviceType.upper()}", device + ) + self.device_ids.add(device.id) + + def on_device_create(self, devices: list[ZWaveMeData]) -> None: + """Create multiple devices.""" + for device in devices: + if device.deviceType in ZWAVE_ME_PLATFORMS and self.platforms_inited: + self.add_device(device) + + def on_device_update(self, new_info: ZWaveMeData) -> None: + """Send signal to update device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + + def on_device_unavailable(self, device_id: str) -> None: + """Send signal to set device unavailable.""" + dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") + + def on_device_destroy(self, device_id: str) -> None: + """Send signal to destroy device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") + + def remove_stale_devices(self, registry: dr.DeviceRegistry): + """Remove old-format devices in the registry.""" + for device_id in self.device_ids: + device = registry.async_get_device( + identifiers={(DOMAIN, f"{self.config.unique_id}-{device_id}")} + ) + if device is not None: + registry.async_remove_device(device.id) diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index 3ae8ec894e1..e3d5bee0525 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -1,7 +1,5 @@ """Representation of a cover.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( @@ -9,12 +7,12 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.COVER @@ -22,21 +20,14 @@ DEVICE_NAME = ZWaveMePlatform.COVER async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - cover = ZWaveMeCover(controller, new_device) - - async_add_entities( - [ - cover, - ] - ) + async_add_entities([ZWaveMeCover(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/entity.py b/homeassistant/components/zwave_me/entity.py index a02c893d54a..87587f96cbe 100644 --- a/homeassistant/components/zwave_me/entity.py +++ b/homeassistant/components/zwave_me/entity.py @@ -8,12 +8,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN +from .controller import ZWaveMeController class ZWaveMeEntity(Entity): """Representation of a ZWaveMe device.""" - def __init__(self, controller, device): + def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: """Initialize the device.""" self.controller = controller self.device = device @@ -71,3 +72,7 @@ class ZWaveMeEntity(Entity): def delete_entity(self) -> None: """Remove this entity.""" self.hass.async_create_task(self.async_remove(force_remove=True)) + + def update(self) -> None: + """Get data from the device.""" + self.controller.zwave_api.send_command(self.device.id, "update") diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index 6ab1df618cb..bfd442413dd 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -1,16 +1,14 @@ """Representation of a fan.""" -from __future__ import annotations - from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.FAN @@ -18,21 +16,14 @@ DEVICE_NAME = ZWaveMePlatform.FAN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - fan = ZWaveMeFan(controller, new_device) - - async_add_entities( - [ - fan, - ] - ) + async_add_entities([ZWaveMeFan(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/helpers.py b/homeassistant/components/zwave_me/helpers.py index 3b5cb4ad0be..e5665853bcc 100644 --- a/homeassistant/components/zwave_me/helpers.py +++ b/homeassistant/components/zwave_me/helpers.py @@ -1,7 +1,5 @@ """Helpers for zwave_me config flow.""" -from __future__ import annotations - from zwave_me_ws import ZWaveMe diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index f8ed397ea25..fe6f460f076 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -1,7 +1,5 @@ """Representation of an RGB light.""" -from __future__ import annotations - from typing import Any from zwave_me_ws import ZWaveMeData @@ -9,22 +7,23 @@ from zwave_me_ws import ZWaveMeData from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + ATTR_TRANSITION, ColorMode, LightEntity, + LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ZWaveMeController -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry, ZWaveMeController from .entity import ZWaveMeEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the rgb platform.""" @@ -32,14 +31,7 @@ async def async_setup_entry( @callback def add_new_device(new_device: ZWaveMeData) -> None: """Add a new device.""" - controller = hass.data[DOMAIN][config_entry.entry_id] - rgb = ZWaveMeRGB(controller, new_device) - - async_add_entities( - [ - rgb, - ] - ) + async_add_entities([ZWaveMeRGB(config_entry.runtime_data, new_device)]) async_dispatcher_connect( hass, f"ZWAVE_ME_NEW_{ZWaveMePlatform.RGB_LIGHT.upper()}", add_new_device @@ -66,6 +58,7 @@ class ZWaveMeRGB(ZWaveMeEntity, LightEntity): self._attr_color_mode = ColorMode.RGB else: self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_features = LightEntityFeature.TRANSITION self._attr_supported_color_modes: set[ColorMode] = {self._attr_color_mode} def turn_off(self, **kwargs: Any) -> None: @@ -74,19 +67,41 @@ class ZWaveMeRGB(ZWaveMeEntity, LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - color = kwargs.get(ATTR_RGB_COLOR) + color: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) + brightness = kwargs.get(ATTR_BRIGHTNESS) + transition: float | None = kwargs.get(ATTR_TRANSITION) - if color is None: - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness is None: - self.controller.zwave_api.send_command(self.device.id, "on") + command_id = "exact" + command_args: dict[str, str] = {} + + # set color levels + if color is not None: + if not any(color): + color = (255, 255, 255) + command_args.update( + {"red": str(color[0]), "green": str(color[1]), "blue": str(color[2])} + ) + elif brightness is not None: + command_args["level"] = str(round(brightness / 2.55)) + elif transition is not None: + command_args["level"] = "100" + else: + command_id = "on" + + if transition is not None: + command_id = "exactSmooth" + if transition < 127: + duration = round(transition) else: - self.controller.zwave_api.send_command( - self.device.id, f"exact?level={round(brightness / 2.55)}" - ) - return - red, green, blue = color if any(color) else (255, 255, 255) - cmd = f"exact?red={red}&green={green}&blue={blue}" + duration = min(127, round((transition) / 60)) + 127 + command_args["duration"] = str(duration) + + cmd = command_id + if command_args: + args = "&".join( + f"{argId}={argVal}" for argId, argVal in command_args.items() + ) + cmd = f"{command_id}?{args}" self.controller.zwave_api.send_command(self.device.id, cmd) @property diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py index cdc8b6471c1..4d5bb1a5a65 100644 --- a/homeassistant/components/zwave_me/lock.py +++ b/homeassistant/components/zwave_me/lock.py @@ -1,18 +1,16 @@ """Representation of a doorlock.""" -from __future__ import annotations - from typing import Any from zwave_me_ws import ZWaveMeData from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.LOCK @@ -20,7 +18,7 @@ DEVICE_NAME = ZWaveMePlatform.LOCK async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the lock platform.""" @@ -28,14 +26,7 @@ async def async_setup_entry( @callback def add_new_device(new_device: ZWaveMeData) -> None: """Add a new device.""" - controller = hass.data[DOMAIN][config_entry.entry_id] - lock = ZWaveMeLock(controller, new_device) - - async_add_entities( - [ - lock, - ] - ) + async_add_entities([ZWaveMeLock(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 0f12a537b42..2b9d4bd34c6 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/zwave_me", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.1"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==3.0.0"], "zeroconf": [ { "name": "*z.wave-me*", diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index 2d6b88840f4..435984bdcd1 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -1,12 +1,12 @@ """Representation of a switchMultilevel.""" from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.NUMBER @@ -14,21 +14,14 @@ DEVICE_NAME = ZWaveMePlatform.NUMBER async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - switch = ZWaveMeNumber(controller, new_device) - - async_add_entities( - [ - switch, - ] - ) + async_add_entities([ZWaveMeNumber(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index fa9ccdfee99..2a4d193c0ca 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -1,7 +1,5 @@ """Representation of a sensorMultilevel.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -28,8 +25,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ZWaveMeController -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry, ZWaveMeController from .entity import ZWaveMeEntity @@ -117,20 +114,20 @@ DEVICE_NAME = ZWaveMePlatform.SENSOR async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" @callback def add_new_device(new_device: ZWaveMeData) -> None: - controller: ZWaveMeController = hass.data[DOMAIN][config_entry.entry_id] - description = SENSORS_MAP.get(new_device.probeType, SENSORS_MAP["generic"]) - sensor = ZWaveMeSensor(controller, new_device, description) - async_add_entities( [ - sensor, + ZWaveMeSensor( + config_entry.runtime_data, + new_device, + SENSORS_MAP.get(new_device.probeType, SENSORS_MAP["generic"]), + ) ] ) diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py index 7bfbf2b2cd4..8eb771aa7b1 100644 --- a/homeassistant/components/zwave_me/siren.py +++ b/homeassistant/components/zwave_me/siren.py @@ -2,13 +2,15 @@ from typing import Any +from zwave_me_ws import ZWaveMeData + from homeassistant.components.siren import SirenEntity, SirenEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry, ZWaveMeController from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.SIREN @@ -16,21 +18,14 @@ DEVICE_NAME = ZWaveMePlatform.SIREN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the siren platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - siren = ZWaveMeSiren(controller, new_device) - - async_add_entities( - [ - siren, - ] - ) + async_add_entities([ZWaveMeSiren(config_entry.runtime_data, new_device)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -42,7 +37,7 @@ async def async_setup_entry( class ZWaveMeSiren(ZWaveMeEntity, SirenEntity): """Representation of a ZWaveMe siren.""" - def __init__(self, controller, device): + def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: """Initialize the device.""" super().__init__(controller, device) self._attr_supported_features = ( diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py index 26d832ca022..9b49ec1a1d5 100644 --- a/homeassistant/components/zwave_me/switch.py +++ b/homeassistant/components/zwave_me/switch.py @@ -3,17 +3,19 @@ import logging from typing import Any +from zwave_me_ws import ZWaveMeData + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ZWaveMePlatform +from .const import ZWaveMePlatform +from .controller import ZWaveMeConfigEntry, ZWaveMeController from .entity import ZWaveMeEntity _LOGGER = logging.getLogger(__name__) @@ -29,19 +31,18 @@ SWITCH_MAP: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZWaveMeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" @callback def add_new_device(new_device): - controller = hass.data[DOMAIN][config_entry.entry_id] - switch = ZWaveMeSwitch(controller, new_device, SWITCH_MAP["generic"]) - async_add_entities( [ - switch, + ZWaveMeSwitch( + config_entry.runtime_data, new_device, SWITCH_MAP["generic"] + ) ] ) @@ -55,7 +56,12 @@ async def async_setup_entry( class ZWaveMeSwitch(ZWaveMeEntity, SwitchEntity): """Representation of a ZWaveMe binary switch.""" - def __init__(self, controller, device, description): + def __init__( + self, + controller: ZWaveMeController, + device: ZWaveMeData, + description: SwitchEntityDescription, + ) -> None: """Initialize the device.""" super().__init__(controller, device) self.entity_description = description diff --git a/homeassistant/config.py b/homeassistant/config.py index 7bd8c4e3c8a..2c938079fba 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,7 +1,5 @@ """Module to help with parsing and generating configuration files.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from collections.abc import Callable, Hashable, Iterable, Sequence @@ -1041,7 +1039,7 @@ def extract_platform_integrations( platform = item.get(CONF_PLATFORM) except AttributeError: continue - if platform and isinstance(platform, Hashable): + if platform and isinstance(platform, str): platform_integrations.setdefault(domain, set()).add(platform) return platform_integrations diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 85e1d1d3ffe..3310effb26d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1,7 +1,5 @@ """Manage config entries in Home Assistant.""" -from __future__ import annotations - import asyncio from collections import UserDict, defaultdict from collections.abc import ( @@ -140,6 +138,8 @@ SAVE_DELAY = 1 DISCOVERY_COOLDOWN = 1 +SETUP_RETRY_MAX_WAIT = 600 # 10 minutes + ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 @@ -316,11 +316,11 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): class FlowType(StrEnum): - """Flow type.""" + """Flow type supported in `next_flow` of ConfigFlowResult.""" CONFIG_FLOW = "config_flow" - # Add other flow types here as needed in the future, - # if we want to support them in the `next_flow` parameter. + OPTIONS_FLOW = "options_flow" + CONFIG_SUBENTRIES_FLOW = "config_subentries_flow" def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None: @@ -562,8 +562,12 @@ class ConfigEntry[_DataT = Any]: def __repr__(self) -> str: """Representation of ConfigEntry.""" return ( - f"" + f"" ) def __setattr__(self, key: str, value: Any) -> None: @@ -579,6 +583,13 @@ class ConfigEntry[_DataT = Any]: self.clear_state_cache() self.clear_storage_cache() + @property + def logger(self) -> logging.Logger: + """Return logger for this config entry.""" + if self._integration_for_domain: + return self._integration_for_domain.logger + return _LOGGER + @property def supports_options(self) -> bool: """Return if entry supports config options.""" @@ -620,7 +631,9 @@ class ConfigEntry[_DataT = Any]: subentry_flow_handler, "async_step_reconfigure" ) } - for subentry_flow_type, subentry_flow_handler in supported_flows.items() + for subentry_flow_type, subentry_flow_handler in ( + supported_flows.items() + ) }, ) return self._supported_subentry_types or {} @@ -658,7 +671,9 @@ class ConfigEntry[_DataT = Any]: "disabled_by": self.disabled_by, "reason": self.reason, "error_reason_translation_key": self.error_reason_translation_key, - "error_reason_translation_placeholders": self.error_reason_translation_placeholders, + "error_reason_translation_placeholders": ( + self.error_reason_translation_placeholders + ), "num_subentries": len(self.subentries), } return json_fragment(json_bytes(json_repr)) @@ -698,6 +713,10 @@ class ConfigEntry[_DataT = Any]: integration = await loader.async_get_integration(hass, self.domain) self._integration_for_domain = integration + # Log setup to the integration logger so it's visible + # when debug logs are enabled. + logger = self.logger + # Only store setup result as state if it was not forwarded. if domain_is_integration := self.domain == integration.domain: if self.state in ( @@ -726,7 +745,7 @@ class ConfigEntry[_DataT = Any]: try: component = await integration.async_get_component() except ImportError as err: - _LOGGER.error( + logger.error( "Error importing integration %s to set up %s configuration entry: %s", integration.domain, self.domain, @@ -742,7 +761,7 @@ class ConfigEntry[_DataT = Any]: try: await integration.async_get_platform("config_flow") except ImportError as err: - _LOGGER.error( + logger.error( ( "Error importing platform config_flow from integration %s to" " set up %s configuration entry: %s" @@ -777,7 +796,7 @@ class ConfigEntry[_DataT = Any]: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( # type: ignore[unreachable] + logger.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -785,7 +804,7 @@ class ConfigEntry[_DataT = Any]: error_reason = str(exc) or "Unknown fatal config entry error" error_reason_translation_key = exc.translation_key error_reason_translation_placeholders = exc.translation_placeholders - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s: %s", self.title, self.domain, @@ -800,14 +819,14 @@ class ConfigEntry[_DataT = Any]: auth_message = ( f"{auth_base_message}: {message}" if message else auth_base_message ) - _LOGGER.warning( + logger.warning( "Config entry '%s' for %s integration %s", self.title, self.domain, auth_message, ) - _LOGGER.debug("Full exception", exc_info=True) - self.async_start_reauth(hass) + logger.debug("Full exception", exc_info=True) + self.async_start_reauth_if_available(hass) except ConfigEntryNotReady as exc: message = str(exc) error_reason_translation_key = exc.translation_key @@ -819,19 +838,19 @@ class ConfigEntry[_DataT = Any]: error_reason_translation_key, error_reason_translation_placeholders, ) - wait_time = 2 ** min(self._tries, 4) * 5 + ( + wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" - _LOGGER.info( + logger.info( "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, wait_time, ) - _LOGGER.debug("Full exception", exc_info=True) + logger.debug("Full exception", exc_info=True) if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( @@ -854,7 +873,7 @@ class ConfigEntry[_DataT = Any]: except asyncio.CancelledError: # We want to propagate CancelledError if we are being cancelled. if (task := asyncio.current_task()) and task.cancelling() > 0: - _LOGGER.exception( + logger.exception( "Setup of config entry '%s' for %s integration cancelled", self.title, self.domain, @@ -869,13 +888,13 @@ class ConfigEntry[_DataT = Any]: raise # This was not a "real" cancellation, log it and treat as a normal error. - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s", self.title, integration.domain ) # pylint: disable-next=broad-except except SystemExit, Exception: - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s", self.title, integration.domain ) @@ -884,8 +903,9 @@ class ConfigEntry[_DataT = Any]: await self._async_process_on_unload(hass) # - # After successfully calling async_setup_entry, it is important that this function - # does not yield to the event loop by using `await` or `async with` or + # After successfully calling async_setup_entry, it is important + # that this function does not yield to the event loop by using + # `await` or `async with` or # similar until after the state has been set by calling self._async_set_state. # # Otherwise we risk that any `call_soon`s @@ -967,7 +987,7 @@ class ConfigEntry[_DataT = Any]: self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True - if self.state == ConfigEntryState.NOT_LOADED: + if self.state is ConfigEntryState.NOT_LOADED: return True if not integration and (integration := self._integration_for_domain) is None: @@ -1029,7 +1049,7 @@ class ConfigEntry[_DataT = Any]: ) except Exception as exc: - _LOGGER.exception( + self.logger.exception( "Error unloading entry %s for %s", self.title, integration.domain ) if domain_is_integration: @@ -1072,7 +1092,7 @@ class ConfigEntry[_DataT = Any]: try: await component.async_remove_entry(hass, self) except Exception: - _LOGGER.exception( + self.logger.exception( "Error calling entry remove callback %s for %s", self.title, integration.domain, @@ -1117,7 +1137,7 @@ class ConfigEntry[_DataT = Any]: Returns True if config entry is up-to-date or has been migrated. """ if (handler := HANDLERS.get(self.domain)) is None: - _LOGGER.error( + self.logger.error( "Flow handler not found for entry %s for %s", self.title, self.domain ) return False @@ -1138,7 +1158,7 @@ class ConfigEntry[_DataT = Any]: if not supports_migrate: if same_major_version: return True - _LOGGER.error( + self.logger.error( "Migration handler not found for entry %s for %s", self.title, self.domain, @@ -1148,14 +1168,14 @@ class ConfigEntry[_DataT = Any]: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( # type: ignore[unreachable] + self.logger.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: hass.config_entries._async_schedule_save() # noqa: SLF001 except Exception: - _LOGGER.exception( + self.logger.exception( "Error migrating entry %s for %s", self.title, self.domain ) return False @@ -1218,7 +1238,7 @@ class ConfigEntry[_DataT = Any]: ) for task in pending: - _LOGGER.warning( + self.logger.warning( "Unloading %s (%s) config entry. Task %s did not complete in time", self.title, self.domain, @@ -1247,7 +1267,7 @@ class ConfigEntry[_DataT = Any]: try: func() except Exception: - _LOGGER.exception( + self.logger.exception( "Error calling on_state_change callback for %s (%s)", self.title, self.domain, @@ -1272,6 +1292,19 @@ class ConfigEntry[_DataT = Any]: eager_start=True, ) + @callback + def async_start_reauth_if_available( + self, + hass: HomeAssistant, + context: ConfigFlowContext | None = None, + data: dict[str, Any] | None = None, + ) -> None: + """Start a reauth flow only if the integration implements one.""" + handler = HANDLERS.get(self.domain) + if handler is None or not hasattr(handler, "async_step_reauth"): + return + self.async_start_reauth(hass, context, data) + async def _async_init_reauth( self, hass: HomeAssistant, @@ -1598,6 +1631,26 @@ class ConfigEntriesFlowManager( issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}" ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + def _async_validate_next_flow( + self, + result: ConfigFlowResult, + ) -> None: + """Validate `next_flow` in result if provided.""" + if (next_flow := result.get("next_flow")) is None: + return + flow_type, flow_id = next_flow + if flow_type not in FlowType: + raise HomeAssistantError(f"Invalid flow type: {flow_type}") + if flow_type == FlowType.CONFIG_FLOW: + # Raises UnknownFlow if the flow does not exist. + self.hass.config_entries.flow.async_get(flow_id) + if flow_type == FlowType.OPTIONS_FLOW: + # Raises UnknownFlow if the flow does not exist. + self.hass.config_entries.options.async_get(flow_id) + if flow_type == FlowType.CONFIG_SUBENTRIES_FLOW: + # Raises UnknownFlow if the flow does not exist. + self.hass.config_entries.subentries.async_get(flow_id) + async def async_finish_flow( self, flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], @@ -1610,7 +1663,7 @@ class ConfigEntriesFlowManager( """ flow = cast(ConfigFlow, flow) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY: # If there's a config entry with a matching unique ID, # update the discovery key. if ( @@ -1636,7 +1689,7 @@ class ConfigEntriesFlowManager( ) } ) - _LOGGER.debug( + entry.logger.debug( "Updating discovery keys for %s entry %s %s -> %s", entry.domain, unique_id, @@ -1646,6 +1699,8 @@ class ConfigEntriesFlowManager( self.config_entries.async_update_entry( entry, discovery_keys=new_discovery_keys ) + + self._async_validate_next_flow(result) return result # Mark the step as done. @@ -1760,6 +1815,10 @@ class ConfigEntriesFlowManager( self.config_entries._async_clean_up(existing_entry) # noqa: SLF001 result["result"] = entry + if not existing_entry: + result = await flow.async_on_create_entry(result) + self._async_validate_next_flow(result) + return result async def async_create_flow( @@ -1867,9 +1926,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): data = self.data self.check_unique_id(entry) if entry_id in data: - # This is likely a bug in a test that is adding the same entry twice. - # In the future, once we have fixed the tests, this will raise HomeAssistantError. - _LOGGER.error("An entry with the id %s already exists", entry_id) + # This is likely a bug in a test that is adding the same + # entry twice. In the future, once we have fixed the tests, + # this will raise HomeAssistantError. + entry.logger.error("An entry with the id %s already exists", entry_id) self._unindex_entry(entry_id) data[entry_id] = entry self._index_entry(entry) @@ -1892,7 +1952,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): report_issue = async_suggest_report_issue( self._hass, integration_domain=entry.domain ) - _LOGGER.error( + entry.logger.error( ( "Config entry '%s' from integration %s has an invalid unique_id" " '%s' of type %s when a string is expected, please %s" @@ -2132,7 +2192,7 @@ class ConfigEntries: """ entries = self._entries.get_entries_for_domain(domain) - return [entry for entry in entries if entry.state == ConfigEntryState.LOADED] + return [entry for entry in entries if entry.state is ConfigEntryState.LOADED] @callback def async_entry_for_domain_unique_id( @@ -2282,8 +2342,9 @@ class ConfigEntries: try: await loader.async_get_integration(self.hass, entry.domain) except loader.IntegrationNotFound: - _LOGGER.info( - "Integration for ignored config entry %s not found. Creating repair issue", + entry.logger.info( + "Integration for ignored config entry %s" + " not found. Creating repair issue", entry, ) ir.async_create_issue( @@ -2310,9 +2371,10 @@ class ConfigEntries: if entry.state is not ConfigEntryState.NOT_LOADED: raise OperationNotAllowed( - f"The config entry '{entry.title}' ({entry.domain}) with entry_id" - f" '{entry.entry_id}' cannot be set up because it is in state " - f"{entry.state}, but needs to be in the {ConfigEntryState.NOT_LOADED} state" + f"The config entry '{entry.title}' ({entry.domain})" + f" with entry_id '{entry.entry_id}' cannot be set up" + f" because it is in state {entry.state}, but needs to" + f" be in the {ConfigEntryState.NOT_LOADED} state" ) # Setup Component if not set up yet @@ -2514,7 +2576,7 @@ class ConfigEntries: report_issue = async_suggest_report_issue( self.hass, integration_domain=entry.domain ) - _LOGGER.error( + entry.logger.error( ( "Unique id of config entry '%s' from integration %s changed to" " '%s' which is already in use, please %s" @@ -2569,7 +2631,8 @@ class ConfigEntries: for listener in entry.update_listeners: self.hass.async_create_task( listener(self.hass, entry), - f"config entry update listener {entry.title} {entry.domain} {entry.domain}", + "config entry update listener" + f" {entry.title} {entry.domain} {entry.domain}", ) self._async_schedule_save() @@ -2965,7 +3028,9 @@ class ConfigFlow(ConfigEntryBaseFlow): def async_update_title_placeholders( self, title_placeholders: Mapping[str, str] ) -> None: - """Update title placeholders for the discovery notification and notify listeners. + """Update title placeholders for the discovery notification. + + Notifies listeners. This updates the flow context title_placeholders and notifies listeners (such as the frontend) to reload the flow state, updating the discovery @@ -3059,6 +3124,13 @@ class ConfigFlow(ConfigEntryBaseFlow): # Existing config entry present, and the # entry data just changed should_reload = True + if entry.update_listeners: + report_usage( + "has an update listener and should use it for scheduling a reload", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.12.0", + integration_domain=self.handler, + ) elif ( self.source in DISCOVERY_SOURCES and entry.state is ConfigEntryState.SETUP_RETRY @@ -3166,8 +3238,9 @@ class ConfigFlow(ConfigEntryBaseFlow): is called when the user ignores a discovered device or service, we then store the key for the flow being ignored. - Once the ignore config entry is created, ConfigEntriesFlowManager.async_finish_flow - will make sure the discovery key is kept up to date since it may not be stable + Once the ignore config entry is created, + ConfigEntriesFlowManager.async_finish_flow will make sure the + discovery key is kept up to date since it may not be stable unlike the unique id. """ await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) @@ -3281,7 +3354,10 @@ class ConfigFlow(ConfigEntryBaseFlow): return flow_type, flow_id = next_flow if flow_type != FlowType.CONFIG_FLOW: - raise HomeAssistantError("Invalid next_flow type") + raise HomeAssistantError( + "next_flow only supports FlowType.CONFIG_FLOW; " + "use async_on_create_entry for options or subentry flows" + ) # Raises UnknownFlow if the flow does not exist. self.hass.config_entries.flow.async_get(flow_id) result["next_flow"] = next_flow @@ -3302,6 +3378,15 @@ class ConfigFlow(ConfigEntryBaseFlow): self._async_set_next_flow_if_valid(result, next_flow) return result + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Runs after a config flow has created a config entry. + + Can be overridden by integrations to add additional data to the result. + Example: creating next flow entries to the result which needs a + config entry created before it can start. + """ + return result + @callback def async_create_entry( # type: ignore[override] self, @@ -3349,7 +3434,8 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -> bool: """Update config entry and return result. - Internal to be used by update_and_abort and update_reload_and_abort methods only. + Internal to be used by update_and_abort and + update_reload_and_abort methods only. """ if data_updates is not UNDEFINED: @@ -3446,6 +3532,13 @@ class ConfigFlow(ConfigEntryBaseFlow): options=options, ) if reload_even_if_entry_is_unchanged or result: + if entry.update_listeners: + report_usage( + "has an update listener and should use it for scheduling a reload", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.12.0", + integration_domain=self.handler, + ) self.hass.config_entries.async_schedule_reload(entry.entry_id) if reason is UNDEFINED: reason = "reauth_successful" @@ -3553,7 +3646,8 @@ class ConfigSubentryFlowManager( subentry_types = handler.async_get_supported_subentry_types(entry) if subentry_type not in subentry_types: raise data_entry_flow.UnknownHandler( - f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'" + f"Config entry '{entry.domain}' does not support" + f" subentry '{subentry_type}'" ) subentry_flow = subentry_types[subentry_type]() subentry_flow.init_step = context["source"] @@ -3572,7 +3666,7 @@ class ConfigSubentryFlowManager( """ flow = cast(ConfigSubentryFlow, flow) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY: return result entry_id, subentry_type = flow.handler @@ -3645,7 +3739,8 @@ class ConfigSubentryFlow( ) -> bool: """Update config subentry and return result. - Internal to be used by update_and_abort and update_reload_and_abort methods only. + Internal to be used by update_and_abort and + update_reload_and_abort methods only. """ if data_updates is not UNDEFINED: @@ -3794,7 +3889,7 @@ class OptionsFlowManager( """ flow = cast(OptionsFlow, flow) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY: return result entry = self.hass.config_entries.async_get_known_entry(flow.handler) @@ -3806,7 +3901,8 @@ class OptionsFlowManager( if automatic_reload and entry.update_listeners: raise ValueError( - "Config entry update listeners should not be used with OptionsFlowWithReload" + "Config entry update listeners should not be" + " used with OptionsFlowWithReload" ) if ( @@ -4046,7 +4142,7 @@ async def _load_integration( try: await integration.async_get_platform("config_flow") except ImportError as err: - _LOGGER.error( + integration.logger.error( "Error occurred loading flow for integration %s: %s", domain, err, diff --git a/homeassistant/const.py b/homeassistant/const.py index 7ba7fcb9f22..b32c7f65d1e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,11 +1,16 @@ """Constants used by Home Assistant components.""" -from __future__ import annotations - from enum import StrEnum +from functools import partial from typing import TYPE_CHECKING, Final from .generated.entity_platforms import EntityPlatforms +from .helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .util.event_type import EventType from .util.hass_dict import HassKey from .util.signal_type import SignalType @@ -16,7 +21,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 5 +MINOR_VERSION: Final = 7 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -172,6 +177,7 @@ CONF_MODEL_ID: Final = "model_id" CONF_MONITORED_CONDITIONS: Final = "monitored_conditions" CONF_MONITORED_VARIABLES: Final = "monitored_variables" CONF_NAME: Final = "name" +CONF_NOTE: Final = "note" CONF_OFFSET: Final = "offset" CONF_OPTIMISTIC: Final = "optimistic" CONF_OPTIONS: Final = "options" @@ -523,6 +529,7 @@ class UnitOfEnergyDistance(StrEnum): class UnitOfElectricCurrent(StrEnum): """Electric current units.""" + MICROAMPERE = "μA" MILLIAMPERE = "mA" AMPERE = "A" @@ -758,7 +765,9 @@ CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" -CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³" +_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant( + "p/m³", "p/m³", "2027.7" +) CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" @@ -946,10 +955,6 @@ PRECISION_WHOLE: Final = 1 PRECISION_HALVES: Final = 0.5 PRECISION_TENTHS: Final = 0.1 -# Static list of entities that will never be exposed to -# cloud, alexa, or google_home components -CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"] - class EntityCategory(StrEnum): """Category of an entity. @@ -996,3 +1001,10 @@ FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" # This is not a hard limit, but caches and other # data structures will be pre-allocated to this size MAX_EXPECTED_ENTITY_IDS: Final = 16384 + +# These can be removed if no deprecated constants are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/core.py b/homeassistant/core.py index 1c51a564129..e4cc5a91385 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -4,8 +4,6 @@ Home Assistant is a Home Automation framework for observing the state of entities and react to changes. """ -from __future__ import annotations - import asyncio from collections import UserDict, defaultdict from collections.abc import ( @@ -209,7 +207,7 @@ def validate_state(state: str) -> str: def callback[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" - setattr(func, "_hass_callback", True) + setattr(func, "_hass_callback", True) # noqa: B010 return func @@ -544,8 +542,9 @@ class HomeAssistant: ) -> None: """Add a job to be executed by the event loop or by an executor. - If the job is either a coroutine or decorated with @callback, it will be - run by the event loop, if not it will be run by an executor. + If the job is a coroutine, coroutine function, or decorated with + @callback, it will be run by the event loop, if not it will be run + by an executor. target: target to call. args: parameters for method to call. @@ -557,6 +556,15 @@ class HomeAssistant: functools.partial(self.async_create_task, target, eager_start=True) ) return + # For @callback targets, schedule directly via call_soon_threadsafe + # to avoid the extra deferral through _async_add_hass_job + call_soon. + # Check iscoroutinefunction to gracefully handle + # incorrectly labeled @callback functions. + if is_callback_check_partial(target) and not inspect.iscoroutinefunction( + target + ): + self.loop.call_soon_threadsafe(target, *args) + return self.loop.call_soon_threadsafe( functools.partial(self._async_add_hass_job, HassJob(target), *args) ) @@ -598,8 +606,9 @@ class HomeAssistant: ) -> asyncio.Future[_R] | None: """Add a job to be executed by the event loop or by an executor. - If the job is either a coroutine or decorated with @callback, it will be - run by the event loop, if not it will be run by an executor. + If the job is a coroutine, coroutine function, or decorated with + @callback, it will be run by the event loop, if not it will be run + by an executor. This method must be run in the event loop. @@ -2725,7 +2734,8 @@ class ServiceRegistry: If return_response=True, indicates that the caller can consume return values from the service, if any. Return values are a dict that can be returned by the - standard JSON serialization process. Return values can only be used with blocking=True. + standard JSON serialization process. Return values can only + be used with blocking=True. This method will fire an event to indicate the service has been called. diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 678094a3a1d..3bfd631e36a 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -1,7 +1,5 @@ """Module to help with parsing and generating configuration files.""" -from __future__ import annotations - from collections import OrderedDict from collections.abc import Sequence from contextlib import suppress @@ -369,9 +367,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non [{"type": "totp", "id": "totp", "name": "Authenticator app"}], ) - setattr( - hass, "auth", await auth.auth_manager_from_config(hass, auth_conf, mfa_conf) - ) + hass.auth = await auth.auth_manager_from_config(hass, auth_conf, mfa_conf) await hass.config.async_load() @@ -452,7 +448,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non set(config[LEGACY_CONF_WHITELIST_EXTERNAL_DIRS]) ) - # Init whitelist external URL list – make sure to add / to every URL that doesn't + # Init whitelist external URL list - make sure to add / to every URL that doesn't # already have it so that we can properly test "path ownership" if CONF_ALLOWLIST_EXTERNAL_URLS in config: hac.allowlist_external_urls.update( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 5df715b03ca..d17a02222d2 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -1,7 +1,5 @@ """Classes to help gather user submissions.""" -from __future__ import annotations - import abc import asyncio from collections import defaultdict @@ -18,6 +16,7 @@ import voluptuous as vol from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.deprecation import deprecated_function from .helpers.frame import ReportBehavior, report_usage from .loader import async_suggest_report_issue from .util import uuid as uuid_util @@ -119,7 +118,6 @@ class AbortFlow(FlowError): class FlowContext(TypedDict, total=False): """Typed context dict.""" - show_advanced_options: bool source: str @@ -265,7 +263,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): matcher: Callable[[Any], bool], include_uninitialized: bool = False, ) -> list[_FlowResultT]: - """Return flows in progress init matching by data type as a partial FlowResult.""" + """Return flows in progress matching by data type.""" return self._async_flow_handler_to_flow_result( [ progress @@ -328,11 +326,11 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): if flow and flow.deprecated_show_progress: if (cur_step := flow.cur_step) and cur_step[ "type" - ] == FlowResultType.SHOW_PROGRESS: + ] is FlowResultType.SHOW_PROGRESS: # Allow the progress task to finish before we call the flow handler await asyncio.sleep(0) - while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE: + while not result or result["type"] is FlowResultType.SHOW_PROGRESS_DONE: result = await self._async_configure(flow_id, user_input) flow = self._progress.get(flow_id) if flow and flow.deprecated_show_progress: @@ -365,7 +363,8 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): try: _map_error_to_schema_errors(schema_errors, error, data_schema) except ValueError: - # If we get here, the path in the exception does not exist in the schema. + # If we get here, the path in the exception + # does not exist in the schema. schema_errors.setdefault("base", []).append(str(error)) raise InvalidData( "Schema validation failed", @@ -375,7 +374,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): ) from ex # Handle a menu navigation choice - if cur_step["type"] == FlowResultType.MENU and user_input: + if cur_step["type"] is FlowResultType.MENU and user_input: result = await self._async_handle_step( flow, user_input["next_step_id"], None ) @@ -388,7 +387,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.EXTERNAL_STEP, FlowResultType.SHOW_PROGRESS, ): - if cur_step["type"] == FlowResultType.EXTERNAL_STEP and result[ + if cur_step["type"] is FlowResultType.EXTERNAL_STEP and result[ "type" ] not in ( FlowResultType.EXTERNAL_STEP, @@ -398,7 +397,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): "External step can only transition to " "external step or external step done." ) - if cur_step["type"] == FlowResultType.SHOW_PROGRESS and result[ + if cur_step["type"] is FlowResultType.SHOW_PROGRESS and result[ "type" ] not in ( FlowResultType.SHOW_PROGRESS, @@ -415,7 +414,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): # - The step is same but result type is SHOW_PROGRESS and progress_action # or description_placeholders has changed if cur_step["step_id"] != result.get("step_id") or ( - result["type"] == FlowResultType.SHOW_PROGRESS + result["type"] is FlowResultType.SHOW_PROGRESS and ( cur_step["progress_action"] != result.get("progress_action") or cur_step["description_placeholders"] @@ -492,8 +491,10 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): if flow.flow_id not in self._progress: # The flow was removed during the step, raise UnknownFlow - # unless the result is an abort - if result["type"] != FlowResultType.ABORT: + # unless the result is an abort. Uses `!=` (not `is not`) because + # this runs before the legacy-string normalization below, and + # out-of-tree flow handlers may still return raw "abort". + if result["type"] != FlowResultType.ABORT: # type: ignore[ha-enum-identity-compare,unused-ignore] raise UnknownFlow return result @@ -510,7 +511,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): ) if ( - result["type"] == FlowResultType.SHOW_PROGRESS + result["type"] is FlowResultType.SHOW_PROGRESS # Mypy does not agree with using pop on _FlowResultT and (progress_task := result.pop("progress_task", None)) # type: ignore[arg-type] and progress_task != flow.async_get_progress_task() @@ -527,7 +528,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): progress_task.add_done_callback(schedule_configure) # type: ignore[attr-defined] flow.async_set_progress_task(progress_task) # type: ignore[arg-type] - elif result["type"] != FlowResultType.SHOW_PROGRESS: + elif result["type"] is not FlowResultType.SHOW_PROGRESS: flow.async_cancel_progress_task() if result["type"] in STEP_ID_OPTIONAL_STEPS: @@ -552,7 +553,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): ) # _async_finish_flow may change result type, check it again - if result["type"] == FlowResultType.FORM: + if result["type"] is FlowResultType.FORM: flow.cur_step = result return result @@ -587,7 +588,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flows: Iterable[FlowHandler[_FlowContextT, _FlowResultT, _HandlerT]], include_uninitialized: bool, ) -> list[_FlowResultT]: - """Convert a list of FlowHandler to a partial FlowResult that can be serialized.""" + """Convert a list of FlowHandler to a partial FlowResult.""" return [ self._flow_result( flow_id=flow.flow_id, @@ -642,9 +643,17 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): return self.context.get("source", None) # type: ignore[return-value] @property + @deprecated_function( + "a user friendly way to present additional options in the UI, for example a section", + breaks_in_ha_version="2027.6", + ) def show_advanced_options(self) -> bool: - """If we should show advanced options.""" - return self.context.get("show_advanced_options", False) # type: ignore[return-value] + """If we should show advanced options. + + During the deprecation period return True to not break existing flows that use + this property to determine whether to show additional options. + """ + return True def add_suggested_values_to_schema( self, data_schema: vol.Schema, suggested_values: Mapping[str, Any] | None @@ -657,15 +666,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): """ schema = {} for key, val in data_schema.schema.items(): - if isinstance(key, vol.Marker): - # Exclude advanced field - if ( - key.description - and key.description.get("advanced") - and not self.show_advanced_options - ): - continue - # Process the section schema options if ( suggested_values is not None diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 8b1c9c49afe..62f8d25b393 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,9 +1,6 @@ """The exceptions used by Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable, Generator, Sequence -from dataclasses import dataclass from typing import TYPE_CHECKING, Any from aiohttp import ClientResponse, ClientResponseError, RequestInfo @@ -38,6 +35,9 @@ class HomeAssistantError(Exception): _message: str | None = None generate_message: bool = False + translation_domain: str | None = None + translation_key: str | None = None + translation_placeholders: dict[str, str] | None = None def __init__( self, @@ -59,9 +59,9 @@ class HomeAssistantError(Exception): def __str__(self) -> str: """Return exception message. - If no message was passed to `__init__`, the exception message is generated from - the translation_key. The message will be in English, regardless of the configured - language. + If no message was passed to `__init__`, the exception message + is generated from the translation_key. The message will be in + English, regardless of the configured language. """ if self._message: @@ -129,11 +129,13 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") -@dataclass(slots=True) class ConditionError(HomeAssistantError): """Error during condition evaluation.""" - type: str + def __init__(self, type: str) -> None: + """Initialize condition error.""" + super().__init__() + self.type = type @staticmethod def _indent(indent: int, message: str) -> str: @@ -149,28 +151,47 @@ class ConditionError(HomeAssistantError): return "\n".join(list(self.output(indent=0))) -@dataclass(slots=True) class ConditionErrorMessage(ConditionError): """Condition error message.""" - # A message describing this error - message: str + def __init__(self, type: str, message: str) -> None: + """Initialize condition error with a message. + + Args: + message: A message describing the error. + """ + super().__init__(type) + self.message = message def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" yield self._indent(indent, f"In '{self.type}' condition: {self.message}") -@dataclass(slots=True) class ConditionErrorIndex(ConditionError): """Condition error with index.""" - # The zero-based index of the failed condition, for conditions with multiple parts - index: int - # The total number of parts in this condition, including non-failed parts - total: int - # The error that this error wraps - error: ConditionError + def __init__( + self, + type: str, + *, + index: int, + total: int, + error: ConditionError, + ) -> None: + """Initialize condition error with index. + + Args: + index: The zero-based index of the failed condition, + for conditions with multiple parts. + total: The total number of parts in this condition, + including non-failed parts. + error: The error that this error wraps. + """ + super().__init__(type) + self.index = index + self.total = total + self.error = error def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" @@ -184,12 +205,17 @@ class ConditionErrorIndex(ConditionError): yield from self.error.output(indent + 1) -@dataclass(slots=True) class ConditionErrorContainer(ConditionError): """Condition error with subconditions.""" - # List of ConditionErrors that this error wraps - errors: Sequence[ConditionError] + def __init__(self, type: str, *, errors: Sequence[ConditionError]) -> None: + """Initialize condition error container. + + Args: + errors: List of ConditionErrors that this error wraps. + """ + super().__init__(type) + self.errors = errors def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" diff --git a/homeassistant/generated/amazon_polly.py b/homeassistant/generated/amazon_polly.py index 8fcfabd4edf..c6112f470f0 100644 --- a/homeassistant/generated/amazon_polly.py +++ b/homeassistant/generated/amazon_polly.py @@ -3,8 +3,6 @@ To update, run python3 -m script.amazon_polly """ -from __future__ import annotations - from typing import Final SUPPORTED_ENGINES: Final[set[str]] = { diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e916..9385d7a3d65 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -43,6 +43,7 @@ APPLICATION_CREDENTIALS = [ "teslemetry", "tibber", "twitch", + "vicare", "volvo", "watts", "weheat", @@ -50,5 +51,6 @@ APPLICATION_CREDENTIALS = [ "xbox", "yale", "yolink", + "yoto", "youtube", ] diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8abd999eedf..86f6eb6e53c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -3,8 +3,6 @@ To update, run python3 -m script.hassfest """ -from __future__ import annotations - from typing import Final BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ @@ -65,6 +63,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 1794, "service_uuid": "0000fce0-0000-1000-8000-00805f9b34fb", }, + { + "domain": "avea", + "local_name": "Avea*", + "service_uuid": "f815e810-456c-6761-746f-4d756e696368", + }, { "connectable": False, "domain": "bluemaestro", @@ -133,18 +136,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "eufylife_ble", "local_name": "eufy T9149", }, + { + "connectable": True, + "domain": "eurotronic_cometblue", + "service_uuid": "47e9ee00-47e9-11e4-8939-164230d1df67", + }, { "connectable": False, "domain": "fjaraskupan", - "manufacturer_data_start": [ - 79, - 68, - 70, - 74, - 65, - 82, - ], - "manufacturer_id": 20296, + "service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b", }, { "connectable": True, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eb103b00ced..2495b990e34 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ FLOWS = { "integration", "min_max", "mold_indicator", + "otp", "random", "statistics", "switch_as_x", @@ -35,6 +36,7 @@ FLOWS = { "aemet", "aftership", "agent_dvr", + "aidot", "airgradient", "airly", "airnow", @@ -57,6 +59,7 @@ FLOWS = { "amberelectric", "ambient_network", "ambient_station", + "analytics", "analytics_insights", "android_ip_webcam", "androidtv", @@ -83,6 +86,7 @@ FLOWS = { "aussie_broadband", "autarco", "autoskope", + "avea", "awair", "aws_s3", "axis", @@ -120,9 +124,11 @@ FLOWS = { "casper_glow", "cast", "ccm15", + "centriconnect", "cert_expiry", "chacon_dio", "chess_com", + "cielo_home", "cloudflare", "cloudflare_r2", "co2signal", @@ -137,11 +143,13 @@ FLOWS = { "crownstone", "cync", "daikin", + "data_grand_lyon", "datadog", "deako", "deconz", "decora_wifi", "deluge", + "denon_rs232", "denonavr", "devialet", "devolo_home_control", @@ -165,17 +173,20 @@ FLOWS = { "dsmr", "dsmr_reader", "duckdns", + "duco", "dunehd", "duotecno", "dwd_weather_warnings", "dynalite", "eafm", + "earn_e_p1", "easyenergy", "ecobee", "ecoforest", "econet", "ecovacs", "ecowitt", + "edifier_infrared", "edl21", "efergy", "egauge", @@ -197,6 +208,7 @@ FLOWS = { "enigma2", "enocean", "enphase_envoy", + "envertech_evt800", "environment_canada", "epic_games_store", "epion", @@ -206,6 +218,7 @@ FLOWS = { "esphome", "essent", "eufylife_ble", + "eurotronic_cometblue", "evil_genius_labs", "ezviz", "faa_delays", @@ -242,6 +255,7 @@ FLOWS = { "frontier_silicon", "fujitsu_fglair", "fully_kiosk", + "fumis", "fyta", "garages_amsterdam", "gardena_bluetooth", @@ -282,11 +296,13 @@ FLOWS = { "green_planet_energy", "growatt_server", "guardian", + "guntamatic", "habitica", "hanna", "harmony", "hdfury", "hegel", + "helty", "heos", "here_travel_time", "hikvision", @@ -306,6 +322,7 @@ FLOWS = { "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", @@ -332,6 +349,7 @@ FLOWS = { "imeon_inverter", "imgw_pib", "immich", + "imou", "improv_ble", "incomfort", "indevolt", @@ -366,11 +384,11 @@ FLOWS = { "keenetic_ndms2", "kegtron", "keymitt_ble", + "kiosker", "kmtronic", "knocki", "knx", "kodi", - "konnected", "kostal_plenticore", "kraken", "kulersky", @@ -391,6 +409,7 @@ FLOWS = { "lg_netcast", "lg_soundbar", "lg_thinq", + "lg_tv_rs232", "libre_hardware_monitor", "lichess", "lidarr", @@ -417,6 +436,7 @@ FLOWS = { "lyric", "madvr", "mailgun", + "marantz_infrared", "mastodon", "matter", "mcp", @@ -438,6 +458,7 @@ FLOWS = { "mikrotik", "mill", "minecraft_server", + "mitsubishi_comfort", "mjpeg", "moat", "mobile_app", @@ -490,6 +511,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "novy_cooker_hood", "nrgkick", "ntfy", "nuheat", @@ -502,6 +524,7 @@ FLOWS = { "octoprint", "ohme", "ollama", + "omie", "omnilogic", "ondilo_ico", "onedrive", @@ -518,22 +541,26 @@ FLOWS = { "opengarage", "openhome", "openrgb", + "opensensemap", "opensky", "opentherm_gw", "openuv", "openweathermap", + "opnsense", "opower", "oralb", "orvibo", "osoenergy", "otbr", - "otp", + "ouman_eh_800", "ourgroceries", "overkiz", "overseerr", + "ovhcloud_ai_endpoints", "ovo_energy", "owntracks", "p1_monitor", + "paj_gps", "palazzetti", "panasonic_viera", "paperless_ngx", @@ -570,6 +597,7 @@ FLOWS = { "proxmoxve", "prusalink", "ps4", + "ptdevices", "pterodactyl", "pure_energie", "purpleair", @@ -622,6 +650,7 @@ FLOWS = { "ruuvitag_ble", "rympro", "sabnzbd", + "samsung_infrared", "samsungtv", "sanix", "satel_integra", @@ -698,6 +727,7 @@ FLOWS = { "sunweg", "surepetcare", "swiss_public_transport", + "swisscom", "switchbee", "switchbot", "switchbot_cloud", @@ -718,6 +748,7 @@ FLOWS = { "technove", "tedee", "telegram_bot", + "teleinfo", "tellduslive", "teltonika", "tesla_fleet", @@ -764,6 +795,7 @@ FLOWS = { "ukraine_alarm", "unifi", "unifi_access", + "unifi_discovery", "unifiprotect", "upb", "upcloud", @@ -786,6 +818,7 @@ FLOWS = { "victron_gx", "victron_remote_monitoring", "vilfo", + "vistapool", "vivotek", "vizio", "vlc_telnet", @@ -829,6 +862,7 @@ FLOWS = { "xiaomi_aqara", "xiaomi_ble", "xiaomi_miio", + "xthings_cloud", "yale", "yale_smart_alarm", "yalexs_ble", @@ -836,6 +870,7 @@ FLOWS = { "yardian", "yeelight", "yolink", + "yoto", "youless", "youtube", "zamg", diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index c3c912c4882..3644f69ab9a 100644 --- a/homeassistant/generated/countries.py +++ b/homeassistant/generated/countries.py @@ -7,8 +7,6 @@ to the political situation in the world, please contact the ISO 3166 working gro """ -from __future__ import annotations - from typing import Final COUNTRIES: Final[set[str]] = { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1d2e1847c84..3e89b646ca2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -3,8 +3,6 @@ To update, run python3 -m script.hassfest """ -from __future__ import annotations - from typing import Final DHCP: Final[list[dict[str, str | bool]]] = [ @@ -173,6 +171,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "dlink", "hostname": "dsp-w215", }, + { + "domain": "duco", + "hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]", + }, + { + "domain": "elgato", + "registered_devices": True, + }, { "domain": "elkm1", "registered_devices": True, @@ -262,6 +268,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "fully_kiosk", "registered_devices": True, }, + { + "domain": "fumis", + "macaddress": "0016D0*", + }, { "domain": "fyta", "hostname": "fyta*", @@ -293,6 +303,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "guntamatic", + "hostname": "kessel*", + "macaddress": "0024BD*", + }, { "domain": "home_connect", "hostname": "balay-*", @@ -321,6 +336,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "iaqualink", + "hostname": "iaqualink-*", + }, { "domain": "incomfort", "hostname": "rfgateway", @@ -330,6 +349,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "incomfort", "registered_devices": True, }, + { + "domain": "indevolt", + "registered_devices": True, + }, { "domain": "insteon", "macaddress": "000EF3*", @@ -420,6 +443,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "lyric-*", "macaddress": "00D02D*", }, + { + "domain": "mitsubishi_comfort", + "registered_devices": True, + }, { "domain": "motion_blinds", "registered_devices": True, @@ -436,6 +463,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "motion_blinds", "hostname": "connector_*", }, + { + "domain": "mystrom", + "hostname": "mystrom-*", + }, + { + "domain": "mystrom", + "registered_devices": True, + }, { "domain": "nest", "macaddress": "18B430*", @@ -453,6 +488,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "xl857-*", "macaddress": "000231*", }, + { + "domain": "nobo_hub", + "registered_devices": True, + }, + { + "domain": "nobo_hub", + "hostname": "hub*", + "macaddress": "7C8306*", + }, { "domain": "nuheat", "hostname": "nuheat", @@ -1319,43 +1363,43 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "twinkly-*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "B4FBE4*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "802AA8*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "F09FC2*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "68D79A*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "18E829*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "245A4C*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "784558*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "E063DA*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "265A4C*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "74ACB9*", }, { @@ -1375,6 +1419,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "vicare", "macaddress": "B87424*", }, + { + "domain": "vistapool", + "hostname": "sugarwifi", + }, { "domain": "withings", "macaddress": "0024E4*", @@ -1424,4 +1472,8 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "yeelight", "hostname": "yeelink-*", }, + { + "domain": "yoto", + "hostname": "yoto-*", + }, ] diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py index 718c3745be8..ac97ac50c71 100644 --- a/homeassistant/generated/entity_platforms.py +++ b/homeassistant/generated/entity_platforms.py @@ -36,6 +36,7 @@ class EntityPlatforms(StrEnum): MEDIA_PLAYER = "media_player" NOTIFY = "notify" NUMBER = "number" + RADIO_FREQUENCY = "radio_frequency" REMOTE = "remote" SCENE = "scene" SELECT = "select" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1f9c0286ca3..d9c8e68df6b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -105,6 +105,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "aidot": { + "name": "AiDot", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "airgradient": { "name": "AirGradient", "integration_type": "device", @@ -570,7 +576,7 @@ "name": "Ambient Radio Weather Network", "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling" + "iot_class": "local_push" }, "aseko_pool_live": { "name": "Aseko Pool Live", @@ -659,6 +665,11 @@ "config_flow": false, "iot_class": "assumed_state" }, + "avosdim": { + "name": "Avosdim", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "awair": { "name": "Awair", "integration_type": "hub", @@ -991,6 +1002,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "centriconnect": { + "name": "CentriConnect/MyPropane", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "cert_expiry": { "integration_type": "service", "config_flow": true, @@ -1014,6 +1031,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "cielo_home": { + "name": "Cielo Home", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "cisco": { "name": "Cisco", "integrations": { @@ -1249,6 +1272,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "data_grand_lyon": { + "name": "Data Grand Lyon", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "datadog": { "name": "Datadog", "integration_type": "service", @@ -1323,6 +1352,12 @@ "iot_class": "local_push", "name": "Denon AVR Network Receivers" }, + "denon_rs232": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Denon RS-232" + }, "heos": { "integration_type": "hub", "config_flow": true, @@ -1521,6 +1556,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "duco": { + "name": "Duco", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "dunehd": { "name": "Dune HD", "integration_type": "device", @@ -1551,6 +1592,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "earn_e_p1": { + "name": "EARN-E P1 Meter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "eastron": { "name": "Eastron", "integration_type": "virtual", @@ -1612,6 +1659,12 @@ "config_flow": true, "iot_class": "local_push" }, + "edifier_infrared": { + "name": "Edifier Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "edimax": { "name": "Edimax", "integration_type": "hub", @@ -1620,7 +1673,7 @@ }, "edl21": { "name": "EDL21", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -1676,8 +1729,8 @@ "name": "Elgato", "integrations": { "avea": { - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling", "name": "Elgato Avea" }, @@ -1819,6 +1872,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "envertech_evt800": { + "name": "ENVERTECH EVT800", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "environment_canada": { "name": "Environment Canada", "integration_type": "service", @@ -1919,6 +1978,12 @@ } } }, + "eurotronic_cometblue": { + "name": "Eurotronic Comet Blue", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "eve": { "name": "Eve", "iot_standards": [ @@ -2308,6 +2373,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "fumis": { + "name": "Fumis", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "futurenow": { "name": "P5 FutureNow", "integration_type": "hub", @@ -2683,6 +2754,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "guntamatic": { + "name": "Guntamatic", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "habitica": { "name": "Habitica", "integration_type": "service", @@ -2789,6 +2866,12 @@ "zwave" ] }, + "helty": { + "name": "Helty Flow", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "here_travel_time": { "name": "HERE Travel Time", "integration_type": "service", @@ -2945,6 +3028,12 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (US)" + }, + "honeywell_string_lights": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Honeywell String Lights" } } }, @@ -2968,7 +3057,7 @@ }, "html5": { "name": "HTML5 Push Notifications", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "single_config_entry": true @@ -3056,7 +3145,7 @@ "iot_class": "local_polling" }, "iaqualink": { - "name": "Jandy iAqualink", + "name": "Jandy iAquaLink", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", @@ -3151,6 +3240,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "imou": { + "name": "Imou", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", @@ -3455,6 +3550,12 @@ "config_flow": true, "iot_class": "assumed_state" }, + "kiosker": { + "name": "Kiosker", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kira": { "name": "Kira", "integration_type": "hub", @@ -3502,12 +3603,6 @@ "konnected": { "name": "Konnected", "integrations": { - "konnected": { - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push", - "name": "Konnected.io (Legacy)" - }, "konnected_esphome": { "integration_type": "virtual", "config_flow": false, @@ -3693,6 +3788,12 @@ "iot_class": "cloud_push", "name": "LG ThinQ" }, + "lg_tv_rs232": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "LG TV via Serial" + }, "webostv": { "integration_type": "device", "config_flow": true, @@ -3966,8 +4067,20 @@ }, "marantz": { "name": "Marantz", - "integration_type": "virtual", - "supported_by": "denonavr" + "integrations": { + "marantz": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "denonavr", + "name": "Marantz" + }, + "marantz_infrared": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Marantz Infrared" + } + } }, "martec": { "name": "Martec", @@ -4047,12 +4160,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "melcloud": { - "name": "MELCloud", - "integration_type": "device", - "config_flow": true, - "iot_class": "cloud_polling" - }, "melissa": { "name": "Melissa", "integration_type": "hub", @@ -4261,6 +4368,23 @@ "config_flow": false, "iot_class": "cloud_push" }, + "mitsubishi": { + "name": "Mitsubishi", + "integrations": { + "melcloud": { + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "MELCloud" + }, + "mitsubishi_comfort": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Mitsubishi Comfort" + } + } + }, "mjpeg": { "name": "MJPEG IP Camera", "integration_type": "hub", @@ -4518,11 +4642,6 @@ "config_flow": true, "iot_class": "local_push" }, - "national_grid_us": { - "name": "National Grid US", - "integration_type": "virtual", - "supported_by": "opower" - }, "neato": { "name": "Neato Botvac", "integration_type": "hub", @@ -4723,6 +4842,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "novy_cooker_hood": { + "name": "Novy Cooker Hood", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "nrgkick": { "name": "NRGkick", "integration_type": "device", @@ -4862,6 +4987,13 @@ "config_flow": false, "iot_class": "local_polling" }, + "omie": { + "name": "OMIE - Spain and Portugal electricity prices", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true + }, "omnilogic": { "name": "Hayward Omnilogic", "integration_type": "hub", @@ -4968,8 +5100,8 @@ }, "opensensemap": { "name": "openSenseMap", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_polling" }, "opensky": { @@ -5016,7 +5148,7 @@ "opnsense": { "name": "OPNsense", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "opower": { @@ -5072,9 +5204,9 @@ "config_flow": true, "iot_class": "local_polling" }, - "otp": { - "name": "One-Time Password (OTP)", - "integration_type": "hub", + "ouman_eh_800": { + "name": "Ouman EH-800", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -5096,6 +5228,12 @@ "config_flow": true, "iot_class": "local_push" }, + "ovhcloud_ai_endpoints": { + "name": "OVHcloud AI Endpoints", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ovo_energy": { "name": "OVO Energy", "integration_type": "service", @@ -5115,6 +5253,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "paj_gps": { + "name": "PAJ GPS", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "palazzetti": { "name": "Palazzetti", "integration_type": "device", @@ -5452,6 +5596,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "ptdevices": { + "name": "PTDevices", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pterodactyl": { "name": "Pterodactyl", "integration_type": "service", @@ -5996,6 +6146,12 @@ "iot_class": "local_polling", "name": "Samsung Family Hub" }, + "samsung_infrared": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Samsung Infrared" + }, "samsungtv": { "integration_type": "device", "config_flow": true, @@ -6085,6 +6241,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "sensereo": { + "name": "Sensereo", + "iot_standards": [ + "matter" + ] + }, "sensibo": { "name": "Sensibo", "integration_type": "hub", @@ -6746,7 +6908,7 @@ "swisscom": { "name": "Swisscom Internet-Box", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "switchbee": { @@ -6937,6 +7099,12 @@ } } }, + "teleinfo": { + "name": "Teleinfo", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "telldus": { "name": "Telldus", "integrations": { @@ -7625,6 +7793,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "vistapool": { + "name": "Vistapool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "vivotek": { "name": "VIVOTEK", "integration_type": "device", @@ -7967,6 +8141,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "xthings_cloud": { + "name": "Xthings Cloud", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "yale": { "name": "Yale (non-US/Canada)", "integrations": { @@ -8076,6 +8256,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "yoto": { + "name": "Yoto", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "youless": { "name": "YouLess", "integration_type": "device", @@ -8175,6 +8361,12 @@ "zwave" ] }, + "zunzunbee": { + "name": "Zunzunbee", + "iot_standards": [ + "zigbee" + ] + }, "zwave_js": { "name": "Z-Wave", "integration_type": "hub", @@ -8268,6 +8460,12 @@ "config_flow": true, "iot_class": "calculated" }, + "otp": { + "name": "One-Time Password (OTP)", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "random": { "integration_type": "helper", "config_flow": true, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index a1ab04fdb3a..ecb94f5d1e1 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -201,11 +201,6 @@ SSDP = { "manufacturer": "ZyXEL Communications Corp.", }, ], - "konnected": [ - { - "manufacturer": "konnected.io", - }, - ], "lametric": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1", @@ -341,25 +336,7 @@ SSDP = { "manufacturer": "Synology", }, ], - "unifi": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE", - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max", - }, - ], - "unifiprotect": [ + "unifi_discovery": [ { "manufacturer": "Ubiquiti Networks", "modelDescription": "UniFi Dream Machine", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414..48c26466a80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -583,6 +583,10 @@ ZEROCONF = { "domain": "bsblan", "name": "bsb-lan*", }, + { + "domain": "duco", + "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", + }, { "domain": "eheimdigital", "name": "eheimdigital._http._tcp.local.", @@ -603,6 +607,10 @@ ZEROCONF = { "domain": "homevolt", "name": "homevolt*", }, + { + "domain": "indevolt", + "name": "igen_fw*", + }, { "domain": "lektrico", "name": "lektrico*", @@ -615,6 +623,14 @@ ZEROCONF = { "domain": "loqed", "name": "loqed*", }, + { + "domain": "lunatone", + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*", + }, + }, { "domain": "nam", "name": "nam-*", @@ -695,6 +711,11 @@ ZEROCONF = { "domain": "ipp", }, ], + "_kiosker._tcp.local.": [ + { + "domain": "kiosker", + }, + ], "_kizbox._tcp.local.": [ { "domain": "overkiz", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 0939c31eadc..99aac6ef88f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -1,7 +1,5 @@ """Helper for aiohttp webclient stuff.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress @@ -24,7 +22,6 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util import ssl as ssl_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads @@ -214,7 +211,6 @@ class ChunkAsyncStreamIterator: @callback -@bind_hass def async_get_clientsession( hass: HomeAssistant, verify_ssl: bool = True, @@ -244,7 +240,6 @@ def async_get_clientsession( @callback -@bind_hass def async_create_clientsession( hass: HomeAssistant, verify_ssl: bool = True, @@ -318,7 +313,6 @@ def _async_create_clientsession( return clientsession -@bind_hass async def async_aiohttp_proxy_web( hass: HomeAssistant, request: web.BaseRequest, @@ -351,7 +345,6 @@ async def async_aiohttp_proxy_web( req.close() -@bind_hass async def async_aiohttp_proxy_stream( hass: HomeAssistant, request: web.BaseRequest, diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 7732b2001ed..ee920302ef6 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -1,7 +1,5 @@ """Provide a way to connect devices to one physical location.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Iterable import dataclasses @@ -15,7 +13,7 @@ from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey -from . import device_registry as dr, entity_registry as er +from . import device_registry as dr from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, @@ -325,6 +323,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def async_delete(self, area_id: str) -> None: """Delete area.""" + from . import entity_registry as er # noqa: PLC0415 # Circular dependency + self.hass.verify_event_loop_thread("area_registry.async_delete") device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index 44481b0f030..190d77c1a53 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -1,7 +1,5 @@ """Provide a way to categorize things within a defined scope.""" -from __future__ import annotations - from collections.abc import Iterable import dataclasses from dataclasses import dataclass, field diff --git a/homeassistant/helpers/chat_session.py b/homeassistant/helpers/chat_session.py index e7a4ecd2ca9..987bba12c49 100644 --- a/homeassistant/helpers/chat_session.py +++ b/homeassistant/helpers/chat_session.py @@ -1,7 +1,5 @@ """Helper to organize chat sessions between integrations.""" -from __future__ import annotations - from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 836536da9ee..c7f302a41f6 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -1,7 +1,5 @@ """Helper to check the configuration file.""" -from __future__ import annotations - from collections import OrderedDict import logging import os @@ -31,7 +29,7 @@ from homeassistant.requirements import ( async_get_integration_with_requirements, ) -from . import config_validation as cv +from . import condition, config_validation as cv, trigger from .typing import ConfigType @@ -93,6 +91,12 @@ async def async_check_ha_config_file( # noqa: C901 result = HomeAssistantConfig() async_clear_install_history(hass) + # Set up condition and trigger helpers needed for config validation. + if condition.CONDITIONS not in hass.data: + await condition.async_setup(hass) + if trigger.TRIGGERS not in hass.data: + await trigger.async_setup(hass) + def _pack_error( hass: HomeAssistant, package: str, @@ -102,7 +106,10 @@ async def async_check_ha_config_file( # noqa: C901 ) -> None: """Handle errors from packages.""" message = f"Setup of package '{package}' failed: {message}" - domain = f"homeassistant.packages.{package}{'.' + component if component is not None else ''}" + domain = ( + f"homeassistant.packages.{package}" + f"{'.' + component if component is not None else ''}" + ) pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index aef673cb500..98bf59a107b 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -1,7 +1,5 @@ """Helper to deal with YAML + storage.""" -from __future__ import annotations - from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable @@ -545,13 +543,21 @@ class StorageCollectionWebsocket[_StorageCollectionT: StorageCollection]: model_name: str, create_schema: VolDictType, update_schema: VolDictType, + *, + admin_only: bool = False, ) -> None: - """Initialize a websocket CRUD.""" + """Initialize a websocket CRUD. + + When ``admin_only`` is set, the ``/list`` and ``/subscribe`` commands + are also restricted to admin users (the mutating commands are always + admin-only). Use this for collections whose items contain secrets. + """ self.storage_collection = storage_collection self.api_prefix = api_prefix self.model_name = model_name self.create_schema = create_schema self.update_schema = update_schema + self.admin_only = admin_only self._remove_subscription: CALLBACK_TYPE | None = None self._subscribers: set[tuple[websocket_api.ActiveConnection, int]] = set() @@ -566,10 +572,18 @@ class StorageCollectionWebsocket[_StorageCollectionT: StorageCollection]: @callback def async_setup(self, hass: HomeAssistant) -> None: """Set up the websocket commands.""" + list_handler: websocket_api.const.WebSocketCommandHandler = self.ws_list_item + subscribe_handler: websocket_api.const.WebSocketCommandHandler = ( + self._ws_subscribe + ) + if self.admin_only: + list_handler = websocket_api.require_admin(list_handler) + subscribe_handler = websocket_api.require_admin(subscribe_handler) + websocket_api.async_register_command( hass, f"{self.api_prefix}/list", - self.ws_list_item, + list_handler, websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): f"{self.api_prefix}/list"} ), @@ -592,7 +606,7 @@ class StorageCollectionWebsocket[_StorageCollectionT: StorageCollection]: websocket_api.async_register_command( hass, f"{self.api_prefix}/subscribe", - self._ws_subscribe, + subscribe_handler, websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): f"{self.api_prefix}/subscribe"} ), diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 810b8f40b73..ccde1d00a72 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1,7 +1,5 @@ """Offer reusable conditions.""" -from __future__ import annotations - import abc from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping @@ -16,12 +14,15 @@ import sys from typing import ( TYPE_CHECKING, Any, + ClassVar, Final, Literal, + Never, Protocol, TypedDict, Unpack, cast, + final, overload, override, ) @@ -93,7 +94,12 @@ from .selector import ( NumericThresholdType, TargetSelector, ) -from .target import TargetSelection, async_extract_referenced_entity_ids +from .target import ( + TargetSelection, + TargetStateChangedData, + async_extract_referenced_entity_ids, + async_track_target_selector_state_change_event, +) from .template import Template, render_complex from .trace import ( TraceElement, @@ -284,10 +290,118 @@ _CONDITION_SCHEMA = _CONDITION_BASE_SCHEMA.extend( ) -class Condition(abc.ABC): - """Condition class.""" +class ConditionChecker(abc.ABC): + """Base class for condition checkers.""" - _hass: HomeAssistant + _set_up = False + _unloaded = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize condition checker.""" + self._hass = hass + + def __call__( + self, hass: HomeAssistant, variables: TemplateVarsType = None + ) -> bool | None: + """Check the condition. + + `hass` parameter is for backwards compatibility only and is always ignored. + """ + return self.async_check(variables=variables) + + def __del__(self) -> None: + """Clean up when the checker is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading condition checker") + + @final + async def async_setup(self) -> None: + """Set up the condition checker. + + Users of conditions do not need to call this method directly. It is called + automatically by async_from_config and async_conditions_from_config. + """ + await self._async_setup() + self._set_up = True + + async def _async_setup(self) -> None: # noqa: B027 + """Set up the condition checker. + + Intended to be overridden in derived classes that need to do setup. + """ + + @final + def async_unload(self) -> None: + """Clean up any resources held by the checker. + + Users of conditions must call this method when they are done with the + checker to ensure resources are released. + """ + self._async_unload() + self._unloaded = True + + def _async_unload(self) -> None: # noqa: B027 + """Clean up any resources held by the checker. + + Intended to be overridden in derived classes that need to do unloading. + """ + + @final + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool | None: + """Check the condition.""" + if not self._set_up: + raise HomeAssistantError("Condition checker is not set up") + with trace_condition(variables): + result = self._async_check(variables=variables) + condition_trace_update_result(result=result) + return result + + @abc.abstractmethod + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool | None: + """Check the condition.""" + + +class LegacyConditionChecker(ConditionChecker): + """Condition checker wrapping a legacy condition factory function.""" + + def __init__(self, hass: HomeAssistant, checker: ConditionCheckerType) -> None: + """Initialize condition checker.""" + super().__init__(hass) + self._checker = checker + + def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool: + return self._checker(self._hass, variables) + + +class DisabledConditionChecker(ConditionChecker): + """Condition checker for disabled conditions.""" + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> None: + return None + + +class CompoundConditionChecker(ConditionChecker): + """Base class for compound condition checkers (and/or/not).""" + + def __init__(self, hass: HomeAssistant, conditions: list[ConditionChecker]) -> None: + """Initialize condition checker.""" + super().__init__(hass) + self._conditions = conditions + + def _async_unload(self) -> None: + """Clean up child conditions.""" + for condition in self._conditions: + condition.async_unload() + + +class Condition(ConditionChecker): + """Condition class.""" @classmethod async def async_validate_complete_config( @@ -323,11 +437,7 @@ class Condition(abc.ABC): def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: """Initialize condition.""" - self._hass = hass - - @abc.abstractmethod - async def async_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" + super().__init__(hass) ATTR_BEHAVIOR: Final = "behavior" @@ -337,10 +447,11 @@ BEHAVIOR_ALL: Final = "all" ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPTIONS, default={}): { vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), + vol.Optional(CONF_FOR): cv.positive_time_period, }, } ) @@ -350,7 +461,13 @@ class EntityConditionBase(Condition): """Base class for entity conditions.""" _domain_specs: Mapping[str, DomainSpec] + _excluded_states: Final[frozenset[str]] = frozenset( + {STATE_UNAVAILABLE, STATE_UNKNOWN} + ) _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL + # When True, indirect target expansion (via device/area/floor) skips + # entities with an entity_category. + _primary_entities_only: ClassVar[bool] = True @override @classmethod @@ -366,13 +483,190 @@ class EntityConditionBase(Condition): if TYPE_CHECKING: assert config.target assert config.options + self._target = config.target self._target_selection = TargetSelection(config.target) self._behavior = config.options[ATTR_BEHAVIOR] + self._duration: timedelta | None = config.options.get(CONF_FOR) + if self._behavior == BEHAVIOR_ANY: + self._matcher = self._check_any_match_state + elif self._behavior == BEHAVIOR_ALL: + self._matcher = self._check_all_match_state + self._on_unload: list[Callable[[], None]] = [] + self._valid_since: dict[str, datetime] = {} def entity_filter(self, entities: set[str]) -> set[str]: """Filter entities matching any of the domain specs.""" return filter_by_domain_specs(self._hass, self._domain_specs, entities) + @property + def _needs_duration_tracking(self) -> bool: + """Whether this condition needs active state change tracking for duration. + + The base implementation intentionally defaults to always tracking + duration and should be overridden by subclasses that can safely use + state.last_changed directly. For example, conditions that are true + for a single main state value may not need active tracking, while + conditions that track attributes or match multiple states do because + last_changed does not capture those transitions. + """ + return True + + def _state_valid_since(self, _state: State) -> datetime: + """Return the datetime that anchors `for:` durations for `state`. + + Override in subclasses whose `is_valid_state` reads + attributes directly without going through `value_source`. + """ + if self._domain_specs[_state.domain].value_source is None: + return _state.last_changed + return _state.last_updated + + def _update_valid_since(self, entity_id: str, _state: State | None) -> None: + """Update _valid_since tracking for an entity based on its current state. + + If the entity is in a valid state and not already tracked, records + when the condition became true (via `_state_valid_since`). If the + entity is not in a valid state, removes it from tracking. + """ + if ( + _state is not None + and self._should_include(_state) + and self.is_valid_state(_state) + ): + # Only record the time if not already tracked, to avoid + # resetting the duration on unrelated state/attribute updates. + if entity_id not in self._valid_since: + self._valid_since[entity_id] = self._state_valid_since(_state) + else: + self._valid_since.pop(entity_id, None) + + @override + async def _async_setup(self) -> None: + """Set up state tracking for duration-based conditions.""" + if not self._duration or not self._needs_duration_tracking: + return + + @callback + def _state_change_listener( + data: TargetStateChangedData, + ) -> None: + """Track when entities enter or leave a valid state.""" + event = data.state_change_event + entity_id = event.data["entity_id"] + to_state = event.data["new_state"] + + self._update_valid_since(entity_id, to_state) + + @callback + def _on_entities_update(added: set[str], removed: set[str]) -> None: + """Handle changes to the tracked entity set.""" + for entity_id in added: + self._update_valid_since(entity_id, self._hass.states.get(entity_id)) + for entity_id in removed: + self._valid_since.pop(entity_id, None) + + unsub = async_track_target_selector_state_change_event( + self._hass, + self._target, + _state_change_listener, + self.entity_filter, + _on_entities_update, + primary_entities_only=self._primary_entities_only, + ) + self._on_unload.append(unsub) + + @override + def _async_unload(self) -> None: + """Unsubscribe from listeners.""" + for cb in self._on_unload: + cb() + self._on_unload.clear() + + def _should_include(self, _state: State) -> bool: + """Check if an entity should participate in any/all checks. + + The default implementation excludes only entities whose state.state + is in `_excluded_states` (unavailable / unknown). Subclasses can + override to also exclude entities that lack the optional capability + the condition relies on. + """ + return _state.state not in self._excluded_states + + @abc.abstractmethod + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected state(s).""" + + def _check_any_match_state(self, states: list[State]) -> bool: + """Test if any entity matches the state.""" + if not self._duration: + # Skip duration check if duration is not specified or 0 + return any(self.is_valid_state(state) for state in states) + cutoff = dt_util.utcnow() - self._duration + if not self._needs_duration_tracking: + return any( + self.is_valid_state(state) and state.last_changed <= cutoff + for state in states + ) + return any( + self.is_valid_state(state) + and (valid_since := self._valid_since.get(state.entity_id)) is not None + and valid_since <= cutoff + for state in states + ) + + def _check_all_match_state(self, states: list[State]) -> bool: + """Test if all entities match the state.""" + if not self._duration: + # Skip duration check if duration is not specified or 0 + return all(self.is_valid_state(state) for state in states) + cutoff = dt_util.utcnow() - self._duration + if not self._needs_duration_tracking: + return all( + self.is_valid_state(state) and state.last_changed <= cutoff + for state in states + ) + return all( + self.is_valid_state(state) + and (valid_since := self._valid_since.get(state.entity_id)) is not None + and valid_since <= cutoff + for state in states + ) + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test state condition.""" + targeted_entities = async_extract_referenced_entity_ids( + self._hass, + self._target_selection, + expand_group=False, + primary_entities_only=self._primary_entities_only, + ) + referenced_entity_ids = targeted_entities.referenced.union( + targeted_entities.indirectly_referenced + ) + filtered_entity_ids = self.entity_filter(referenced_entity_ids) + entity_states = [ + _state + for entity_id in filtered_entity_ids + if (_state := self._hass.states.get(entity_id)) + and self._should_include(_state) + ] + return self._matcher(entity_states) + + +class EntityStateConditionBase(EntityConditionBase): + """State condition.""" + + _states: set[str | bool] + + @property + def _needs_duration_tracking(self) -> bool: + """Single-state conditions with no attribute tracking can use last_changed.""" + if len(self._states) != 1: + return True + return any( + spec.value_source is not None for spec in self._domain_specs.values() + ) + def _get_tracked_value(self, entity_state: State) -> Any: """Get the tracked value from a state based on the DomainSpec.""" domain_spec = self._domain_specs[entity_state.domain] @@ -380,53 +674,6 @@ class EntityConditionBase(Condition): return entity_state.state return entity_state.attributes.get(domain_spec.value_source) - @abc.abstractmethod - def is_valid_state(self, entity_state: State) -> bool: - """Check if the state matches the expected state(s).""" - - @override - async def async_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" - - def check_any_match_state(states: list[State]) -> bool: - """Test if any entity matches the state.""" - return any(self.is_valid_state(state) for state in states) - - def check_all_match_state(states: list[State]) -> bool: - """Test if all entities match the state.""" - return all(self.is_valid_state(state) for state in states) - - matcher: Callable[[list[State]], bool] - if self._behavior == BEHAVIOR_ANY: - matcher = check_any_match_state - elif self._behavior == BEHAVIOR_ALL: - matcher = check_all_match_state - - def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool: - """Test state condition.""" - targeted_entities = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False - ) - referenced_entity_ids = targeted_entities.referenced.union( - targeted_entities.indirectly_referenced - ) - filtered_entity_ids = self.entity_filter(referenced_entity_ids) - entity_states = [ - _state - for entity_id in filtered_entity_ids - if (_state := self._hass.states.get(entity_id)) - and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ] - return matcher(entity_states) - - return test_state - - -class EntityStateConditionBase(EntityConditionBase): - """State condition.""" - - _states: set[str | bool] - def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" return self._get_tracked_value(entity_state) in self._states @@ -444,6 +691,8 @@ def _normalize_domain_specs( def make_entity_state_condition( domain_specs: Mapping[str, DomainSpec] | str, states: str | bool | set[str | bool], + *, + primary_entities_only: bool = True, ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -462,6 +711,7 @@ def make_entity_state_condition( _domain_specs = specs _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -526,7 +776,10 @@ class EntityNumericalConditionBase(EntityConditionBase): return None def _get_tracked_value(self, entity_state: State) -> Any: - """Get the tracked value from a state, with unit validation for state-based values.""" + """Get the tracked value from a state. + + Includes unit validation for state-based values. + """ domain_spec = self._domain_specs[entity_state.domain] if domain_spec.value_source is None: if not self._is_valid_unit( @@ -560,7 +813,7 @@ class EntityNumericalConditionBase(EntityConditionBase): if lower_limit is None or upper_limit is None: # Entity not found or invalid number, don't trigger return False - between = lower_limit < value < upper_limit + between = lower_limit <= value <= upper_limit if self._threshold_type == NumericThresholdType.BETWEEN: return between return not between @@ -569,6 +822,8 @@ class EntityNumericalConditionBase(EntityConditionBase): def make_entity_numerical_condition( domain_specs: Mapping[str, DomainSpec] | str, valid_unit: str | None | UndefinedType = UNDEFINED, + *, + primary_entities_only: bool = True, ) -> type[EntityNumericalConditionBase]: """Create a condition for numerical state comparisons.""" specs = _normalize_domain_specs(domain_specs) @@ -578,6 +833,7 @@ def make_entity_numerical_condition( _domain_specs = specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomCondition @@ -707,13 +963,6 @@ class ConditionCheckParams(TypedDict, total=False): variables: TemplateVarsType -class ConditionChecker(Protocol): - """Protocol for condition checker callable with typed kwargs.""" - - def __call__(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: - """Check the condition.""" - - type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool] type ConditionCheckerTypeOptional = Callable[ [HomeAssistant, TemplateVarsType], bool | None @@ -837,20 +1086,10 @@ async def _async_get_condition_platform( return platform, platform_module -async def _async_get_checker(condition: Condition) -> ConditionCheckerType: - new_checker = await condition.async_get_checker() - - @trace_condition_function - def checker(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - return new_checker(variables=variables) - - return checker - - async def async_from_config( hass: HomeAssistant, config: ConfigType, -) -> ConditionCheckerTypeOptional: +) -> ConditionChecker: """Turn a condition configuration into a method. Should be run on the event loop. @@ -866,15 +1105,9 @@ async def async_from_config( f"Error rendering condition enabled template: {err}" ) from err if not enabled: - - @trace_condition_function - def disabled_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool | None: - """Condition not enabled, will act as if it didn't exist.""" - return None - - return disabled_condition + disabled_checker = DisabledConditionChecker(hass) + await disabled_checker.async_setup() + return disabled_checker condition_key: str = config[CONF_CONDITION] factory: Any = None @@ -893,7 +1126,8 @@ async def async_from_config( target=config.get(CONF_TARGET), ), ) - return await _async_get_checker(condition) + await condition.async_setup() + return condition for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) @@ -906,31 +1140,42 @@ async def async_from_config( while isinstance(check_factory, ft.partial): check_factory = check_factory.func + checker: ConditionChecker | ConditionCheckerType if inspect.iscoroutinefunction(check_factory): - return cast(ConditionCheckerType, await factory(hass, config)) - return cast(ConditionCheckerType, factory(config)) + checker = await factory(hass, config) + else: + checker = factory(config) + if not isinstance(checker, ConditionChecker): + checker = LegacyConditionChecker(hass, checker) + await checker.async_setup() + return checker async def async_and_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'AND'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return AndConditionChecker(hass, checks) - @trace_condition_function - def if_and_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class AndConditionChecker(CompoundConditionChecker): + """Condition checker for 'and' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test and condition.""" errors = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is False: + if condition.async_check(**kwargs) is False: return False except ConditionError as ex: errors.append( - ConditionErrorIndex("and", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "and", index=index, total=len(self._conditions), error=ex + ) ) # Raise the errors if no check was false @@ -939,29 +1184,32 @@ async def async_and_from_config( return True - return if_and_condition - async def async_or_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'OR'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return OrConditionChecker(hass, checks) - @trace_condition_function - def if_or_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class OrConditionChecker(CompoundConditionChecker): + """Condition checker for 'or' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test or condition.""" errors = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is True: + if condition.async_check(**kwargs) is True: return True except ConditionError as ex: errors.append( - ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "or", index=index, total=len(self._conditions), error=ex + ) ) # Raise the errors if no check was true @@ -970,29 +1218,32 @@ async def async_or_from_config( return False - return if_or_condition - async def async_not_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'NOT'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return NotConditionChecker(hass, checks) - @trace_condition_function - def if_not_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class NotConditionChecker(CompoundConditionChecker): + """Condition checker for 'not' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test not condition.""" errors = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(hass, variables): + if condition.async_check(**kwargs): return False except ConditionError as ex: errors.append( - ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "not", index=index, total=len(self._conditions), error=ex + ) ) # Raise the errors if no check was true @@ -1001,8 +1252,6 @@ async def async_not_from_config( return True - return if_not_condition - def numeric_state( hass: HomeAssistant, @@ -1159,7 +1408,6 @@ def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType: above = config.get(CONF_ABOVE) value_template = config.get(CONF_VALUE_TEMPLATE) - @trace_condition_function def if_numeric_state( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -1278,7 +1526,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: if not isinstance(req_states, list): req_states = [req_states] - @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" errors = [] @@ -1340,7 +1587,6 @@ def async_template_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE)) - @trace_condition_function def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate template based if-condition.""" return async_template(hass, value_template, variables) @@ -1384,7 +1630,7 @@ def time( after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( after_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1414,7 +1660,7 @@ def time( return False elif ( before_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1453,7 +1699,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: after = config.get(CONF_AFTER) weekday = config.get(CONF_WEEKDAY) - @trace_condition_function def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate time based if-condition.""" return time(hass, before, after, weekday) @@ -1467,7 +1712,6 @@ async def async_trigger_from_config( """Test a trigger condition.""" trigger_id = config[CONF_ID] - @trace_condition_function def trigger_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate trigger based if-condition.""" return ( @@ -1504,9 +1748,14 @@ def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType + hass: HomeAssistant, config: ConfigType | str ) -> ConfigType: """Validate config.""" + if isinstance(config, str): + config = { + CONF_CONDITION: "template", + CONF_VALUE_TEMPLATE: cv.dynamic_template(config), + } condition_key: str = config[CONF_CONDITION] if condition_key in ("and", "not", "or"): @@ -1556,40 +1805,81 @@ async def async_conditions_from_config( condition_configs: list[ConfigType], logger: logging.Logger, name: str, -) -> Callable[[TemplateVarsType], bool]: +) -> ConditionsChecker: """AND all conditions.""" checks = [ await async_from_config(hass, condition_config) for condition_config in condition_configs ] + return ConditionsChecker(checks, logger, name) - def check_conditions(variables: TemplateVarsType = None) -> bool: + +class ConditionsChecker: + """Condition checker that ANDs multiple conditions. + + Used by automations and template entities. Unlike AndConditionChecker, + this logs warnings on errors instead of raising, and uses "condition" + as the trace path prefix. + """ + + def __init__( + self, + conditions: list[ConditionChecker], + logger: logging.Logger, + name: str, + ) -> None: + """Initialize condition checker.""" + self._conditions = conditions + self._logger = logger + self._name = name + self._unloaded = False + + def __call__(self, variables: TemplateVarsType = None) -> bool: + """Check all conditions.""" + return self.async_check(variables=variables) + + def __del__(self) -> None: + """Clean up when the checker is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading condition checker") + + def async_unload(self) -> None: + """Clean up child conditions.""" + self._unloaded = True + for condition in self._conditions: + condition.async_unload() + + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool: """AND all conditions.""" errors: list[ConditionErrorIndex] = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["condition", str(index)]): - if check(hass, variables) is False: + if condition.async_check(variables=variables, **kwargs) is False: return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "condition", index=index, total=len(checks), error=ex + "condition", index=index, total=len(self._conditions), error=ex ) ) if errors: - logger.warning( + self._logger.warning( "Error evaluating condition in '%s':\n%s", - name, + self._name, ConditionErrorContainer("condition", errors=errors), ) return False return True - return check_conditions - @callback def async_extract_entities(config: ConfigType | Template) -> set[str]: diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 7e38dff3a31..acc5a8fcb68 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,7 +1,5 @@ """Helpers for data entry flows for config entries.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable import logging from typing import TYPE_CHECKING, Any, cast diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c5bce5779c5..5c2cd348da0 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -6,8 +6,6 @@ This module exists of the following parts: """ -from __future__ import annotations - from abc import ABC, ABCMeta, abstractmethod import asyncio from asyncio import Lock @@ -804,6 +802,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None: return None try: - return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return] + return jwt.decode(encoded, secret, algorithms=["HS256"]) except jwt.InvalidTokenError: return None diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f9b536a9141..6034ede4449 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,7 +1,5 @@ """Helpers for config validation using voluptuous.""" -from __future__ import annotations - from collections.abc import Callable, Hashable, Mapping import contextlib from contextvars import ContextVar @@ -62,6 +60,7 @@ from homeassistant.const import ( CONF_ID, CONF_IF, CONF_MATCH, + CONF_NOTE, CONF_PARALLEL, CONF_PLATFORM, CONF_REPEAT, @@ -696,7 +695,7 @@ def slugify(value: Any) -> str: def string(value: Any) -> str: - """Coerce value to string, except for None.""" + """Coerce value to string, except for None, list or dict.""" if value is None: raise vol.Invalid("string value is None") @@ -759,15 +758,7 @@ def dynamic_template(value: Any) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): - from .frame import ReportBehavior, report_usage # noqa: PLC0415 - - report_usage( - ( - "validates schema outside the event loop, " - "which will stop working in HA Core 2025.10" - ), - core_behavior=ReportBehavior.LOG, - ) + raise vol.Invalid("Validates schema outside the event loop") template_value = template_helper.Template(str(value), hass) @@ -1088,7 +1079,8 @@ def renamed( if old_key in value: if new_key in value: raise vol.Invalid( - f"Cannot specify both '{old_key}' and '{new_key}'. Please use '{new_key}' only." + f"Cannot specify both '{old_key}' and" + f" '{new_key}'. Please use '{new_key}' only." ) value[new_key] = value.pop(old_key) @@ -1421,7 +1413,7 @@ def _make_entity_service_schema(schema: dict, extra: int) -> VolSchemaType: ), _HAS_ENTITY_SERVICE_FIELD, ) - setattr(validator, "_entity_service_schema", True) + setattr(validator, "_entity_service_schema", True) # noqa: B010 return validator @@ -1467,6 +1459,7 @@ SCRIPT_SCHEMA = vol.All(ensure_list, [script_action]) SCRIPT_ACTION_BASE_SCHEMA: VolDictType = { vol.Optional(CONF_ALIAS): string, + vol.Remove(CONF_NOTE): str, # Is only used in frontend vol.Optional(CONF_CONTINUE_ON_ERROR): boolean, vol.Optional(CONF_ENABLED): vol.Any(boolean, template), } @@ -1534,6 +1527,7 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any( CONDITION_BASE_SCHEMA: VolDictType = { vol.Optional(CONF_ALIAS): string, + vol.Remove(CONF_NOTE): str, # Is only used in frontend vol.Optional(CONF_ENABLED): vol.Any(boolean, template), } @@ -1780,7 +1774,7 @@ def _base_condition_validator(value: Any) -> Any: vol.Schema( { **CONDITION_BASE_SCHEMA, - CONF_CONDITION: vol.All(str, vol.NotIn(BUILT_IN_CONDITIONS)), + vol.Required(CONF_CONDITION): vol.All(str, vol.NotIn(BUILT_IN_CONDITIONS)), }, extra=vol.ALLOW_EXTRA, )(value) @@ -1850,7 +1844,8 @@ def _trigger_pre_validator(value: Any | None) -> Any: if CONF_TRIGGER in value: if CONF_PLATFORM in value: raise vol.Invalid( - "Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only." + "Cannot specify both 'platform' and 'trigger'." + " Please use 'trigger' only." ) value = dict(value) value[CONF_PLATFORM] = value.pop(CONF_TRIGGER) @@ -1867,6 +1862,7 @@ TRIGGER_BASE_SCHEMA = vol.Schema( vol.Optional(CONF_ID): str, vol.Optional(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA, vol.Optional(CONF_ENABLED): vol.Any(boolean, template), + vol.Remove(CONF_NOTE): str, # Is only used in frontend } ) @@ -1875,7 +1871,7 @@ _base_trigger_validator_schema = TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_ def _base_trigger_list_flatten(triggers: list[Any]) -> list[Any]: - """Flatten trigger arrays containing 'triggers:' sublists into a single list of triggers.""" + """Flatten trigger arrays with 'triggers:' sublists into one list.""" flatlist = [] for t in triggers: if CONF_TRIGGERS in t and len(t) == 1: @@ -1957,6 +1953,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( [ { vol.Optional(CONF_ALIAS): string, + vol.Remove(CONF_NOTE): str, # Is only used in frontend vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } @@ -2000,17 +1997,24 @@ _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA = vol.Schema( } ) -_SCRIPT_STOP_SCHEMA = vol.Schema( - { - **SCRIPT_ACTION_BASE_SCHEMA, - vol.Required(CONF_STOP): vol.Any(None, string), - vol.Exclusive(CONF_ERROR, "error_or_response"): boolean, - vol.Exclusive( - CONF_RESPONSE_VARIABLE, - "error_or_response", - msg="not allowed to add a response to an error stop action", - ): str, - } + +def _stop_action_check_error_response(config: dict) -> dict: + """Validate that error stop actions don't have a response variable.""" + if config.get(CONF_ERROR) and CONF_RESPONSE_VARIABLE in config: + raise vol.Invalid("not allowed to add a response to an error stop action") + return config + + +_SCRIPT_STOP_SCHEMA = vol.All( + vol.Schema( + { + **SCRIPT_ACTION_BASE_SCHEMA, + vol.Required(CONF_STOP): vol.Any(None, string), + vol.Optional(CONF_ERROR): boolean, + vol.Optional(CONF_RESPONSE_VARIABLE): str, + } + ), + _stop_action_check_error_response, ) _SCRIPT_SEQUENCE_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 9ace020f342..719ed2d2f90 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -1,7 +1,5 @@ """Helpers for the data entry flow.""" -from __future__ import annotations - from http import HTTPStatus from typing import Any, Generic, TypeVar @@ -33,7 +31,7 @@ class _BaseFlowManagerView(HomeAssistantView, Generic[_FlowManagerT]): self, result: data_entry_flow.FlowResult ) -> dict[str, Any]: """Convert result to JSON serializable dict.""" - if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: + if result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY: assert "result" not in result return { key: val @@ -62,7 +60,6 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]): vol.Schema( { vol.Required("handler"): str, - vol.Optional("show_advanced_options", default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA, ) @@ -95,7 +92,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]): def get_context(self, data: dict[str, Any]) -> dict[str, Any]: """Return context.""" - return {"show_advanced_options": data["show_advanced_options"]} + return {} class FlowManagerResourceView(_BaseFlowManagerView[_FlowManagerT]): diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 67d6ad55a3a..9b16a1db247 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -1,7 +1,5 @@ """Debounce helper.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager @@ -183,7 +181,10 @@ class Debouncer[_R_co]: if not self._execute_at_end_of_timer: return self._execute_at_end_of_timer = False - name = f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}" + name = ( + f"debouncer {self._job} finish" + f" cooldown={self.cooldown}, immediate={self.immediate}" + ) if not self._background: self.hass.async_create_task( self._handle_timer_finish(), name, eager_start=True @@ -195,8 +196,9 @@ class Debouncer[_R_co]: @callback def _schedule_timer(self) -> None: - """Schedule a timer.""" - if not self._shutdown_requested: - self._timer_task = self.hass.loop.call_later( - self.cooldown, self._on_debounce - ) + """Schedule a timer, cancelling any previously-scheduled handle.""" + if self._shutdown_requested: + return + if self._timer_task is not None: + self._timer_task.cancel() + self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 6dfb002305a..a0287aa8af1 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,14 +1,12 @@ """Deprecation helpers for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict import functools import inspect import logging -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast def deprecated_substitute[_ObjectT: object]( @@ -45,7 +43,7 @@ def deprecated_substitute[_ObjectT: object]( inspect.getfile(self.__class__), ) warnings[module_name] = True - setattr(func, "_deprecated_substitute_warnings", warnings) + setattr(func, "_deprecated_substitute_warnings", warnings) # noqa: B010 # Return the old property return getattr(self, substitute_name) @@ -88,27 +86,44 @@ def get_deprecated( return config.get(new_name, default) -def deprecated_class[**_P, _R]( +def deprecated_class[_T]( replacement: str, *, breaks_in_ha_version: str | None = None -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: +) -> Callable[[type[_T]], type[_T]]: """Mark class as deprecated and provide a replacement class to be used instead. If the deprecated function was called from a custom integration, ask the user to report an issue. """ - def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: + def deprecated_decorator(cls: type[_T]) -> type[_T]: """Decorate class as deprecated.""" + base_meta = type(cls) - @functools.wraps(cls) - def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: - """Wrap for the original class.""" + def __call__(self: type[Any], *args: Any, **kwargs: Any) -> Any: _print_deprecation_warning( cls, replacement, "class", "instantiated", breaks_in_ha_version ) - return cls(*args, **kwargs) + return base_meta.__call__(self, *args, **kwargs) - return deprecated_cls + deprecated_meta = type( + f"Deprecated{base_meta.__name__}", + (base_meta,), + {"__call__": __call__}, + ) + + deprecated_cls = deprecated_meta( + cls.__name__, + (cls,), + { + "__module__": cls.__module__, + "__qualname__": cls.__qualname__, + "__doc__": cls.__doc__, + "__slots__": (), + "__wrapped__": cls, + }, + ) + + return cast(type[_T], deprecated_cls) return deprecated_decorator diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index bf0e2ab31be..2d90a9c7914 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -109,7 +109,8 @@ def async_remove_stale_devices_links_keep_current_device( continue ent_reg.async_update_entity(entity.entity_id, device_id=current_device_id) - # Removes all devices from the config entry that are not the same as the current device + # Removes all devices from the config entry that are not the same + # as the current device for device in dev_reg.devices.get_devices_for_config_entry_id(entry_id): if device.id == current_device_id: continue diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index dc2f083c90e..af5cf83b8d4 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,7 +1,5 @@ """Provide a way to connect entities belonging to one device.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Iterable, Mapping @@ -10,7 +8,7 @@ from enum import StrEnum from functools import lru_cache import logging import time -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack import attr from yarl import URL @@ -37,7 +35,6 @@ from .deprecation import deprecated_function from .frame import ReportBehavior, report_usage from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType -from .singleton import singleton from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -224,11 +221,11 @@ class DeviceConnectionCollisionError(DeviceCollisionError): ) -def _validate_device_info( +def _determine_device_info_type( config_entry: ConfigEntry, device_info: DeviceInfo, ) -> str: - """Process a device info.""" + """Determine the type of a device info.""" keys = set(device_info) # If no keys or not enough info to match up, abort @@ -260,22 +257,66 @@ def _validate_device_info( return device_info_type +class _ValidatedDeviceInfoFields(TypedDict): + """Device info fields validated on create and update.""" + + configuration_url: str | URL | None | UndefinedType + hw_version: str | None | UndefinedType + manufacturer: str | None | UndefinedType + model: str | None | UndefinedType + model_id: str | None | UndefinedType + serial_number: str | None | UndefinedType + sw_version: str | None | UndefinedType + + _cached_parse_url = lru_cache(maxsize=512)(URL) """Parse a URL and cache the result.""" -def _validate_configuration_url(value: Any) -> str | None: - """Validate and convert configuration_url.""" - if value is None: - return None +def _validate_str(name: str, value: Any) -> str | None | UndefinedType: + """Validate that a device registry string field has correct type.""" + if ( + value is UNDEFINED + or value is None + or type(value) is str # fast path for exact str + or isinstance(value, str) + ): + return value + report_usage( + f"passes a non-string value of type {type(value).__name__} " + f"as {name} to the device registry", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.12.0", + ) + return str(value) - url_as_str = str(value) - url = value if type(value) is URL else _cached_parse_url(url_as_str) - if url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host: - raise ValueError(f"invalid configuration_url '{value}'") - - return url_as_str +def _validate_device_info_fields( + **fields: Unpack[_ValidatedDeviceInfoFields], +) -> _ValidatedDeviceInfoFields: + """Validate device-info field values.""" + configuration_url = fields["configuration_url"] + url: URL | None = None + if type(configuration_url) is URL: + url = configuration_url + configuration_url = str(configuration_url) + else: + configuration_url = _validate_str("configuration_url", configuration_url) + if isinstance(configuration_url, str): + url = _cached_parse_url(configuration_url) + if url is not None and ( + url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host + ): + raise ValueError(f"invalid configuration_url '{configuration_url}'") + return { + "configuration_url": configuration_url, + "hw_version": _validate_str("hw_version", fields["hw_version"]), + "manufacturer": _validate_str("manufacturer", fields["manufacturer"]), + "model": _validate_str("model", fields["model"]), + "model_id": _validate_str("model_id", fields["model_id"]), + "serial_number": _validate_str("serial_number", fields["serial_number"]), + "sw_version": _validate_str("sw_version", fields["sw_version"]), + } @lru_cache(maxsize=512) @@ -368,8 +409,8 @@ class DeviceEntry: "configuration_url": self.configuration_url, "config_entries": list(self.config_entries), "config_entries_subentries": { - config_entry_id: list(subentries) - for config_entry_id, subentries in self.config_entries_subentries.items() + entry_id: list(subentries) + for entry_id, subentries in self.config_entries_subentries.items() }, "connections": list(self.connections), "created_at": self.created_at.timestamp(), @@ -418,8 +459,10 @@ class DeviceEntry: # representation in HA Core 2026.2 "config_entries": list(self.config_entries), "config_entries_subentries": { - config_entry_id: list(subentries) - for config_entry_id, subentries in self.config_entries_subentries.items() + entry_id: list(subentries) + for entry_id, subentries in ( + self.config_entries_subentries.items() + ) }, "configuration_url": self.configuration_url, "connections": list(self.connections), @@ -517,8 +560,10 @@ class DeletedDeviceEntry: # representation in HA Core 2026.2 "config_entries": list(self.config_entries), "config_entries_subentries": { - config_entry_id: list(subentries) - for config_entry_id, subentries in self.config_entries_subentries.items() + entry_id: list(subentries) + for entry_id, subentries in ( + self.config_entries_subentries.items() + ) }, "connections": list(self.connections), "created_at": self.created_at, @@ -772,11 +817,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): devices: ActiveDeviceRegistryItems deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] _device_data: dict[str, DeviceEntry] - _loaded_event: asyncio.Event | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" self.hass = hass + self._loaded_event = asyncio.Event() self._store = DeviceRegistryStore( hass, STORAGE_VERSION_MAJOR, @@ -786,11 +831,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): serialize_in_event_loop=False, ) - @callback - def async_setup(self) -> None: - """Set up the registry.""" - self._loaded_event = asyncio.Event() - @callback def async_get(self, device_id: str) -> DeviceEntry | None: """Get device. @@ -866,8 +906,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" - if configuration_url is not UNDEFINED: - configuration_url = _validate_configuration_url(configuration_url) + default_manufacturer = _validate_str( + "default_manufacturer", default_manufacturer + ) + default_model = _validate_str("default_model", default_model) + validated_fields = _validate_device_info_fields( + configuration_url=configuration_url, + hw_version=hw_version, + manufacturer=manufacturer, + model=model, + model_id=model_id, + serial_number=serial_number, + sw_version=sw_version, + ) config_entry = self.hass.config_entries.async_get_entry(config_entry_id) if config_entry is None: @@ -893,27 +944,21 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device_info: DeviceInfo = { # type: ignore[assignment] key: val for key, val in ( - ("configuration_url", configuration_url), ("connections", connections), ("default_manufacturer", default_manufacturer), ("default_model", default_model), ("default_name", default_name), ("entry_type", entry_type), - ("hw_version", hw_version), ("identifiers", identifiers), - ("manufacturer", manufacturer), - ("model", model), - ("model_id", model_id), ("name", name), - ("serial_number", serial_number), ("suggested_area", suggested_area), - ("sw_version", sw_version), ("via_device", via_device), + *validated_fields.items(), ) if val is not UNDEFINED } - device_info_type = _validate_device_info(config_entry, device_info) + device_info_type = _determine_device_info_type(config_entry, device_info) if identifiers is None or identifiers is UNDEFINED: identifiers = set() @@ -965,10 +1010,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = config_entry.title if default_manufacturer is not UNDEFINED and device.manufacturer is None: - manufacturer = default_manufacturer + validated_fields["manufacturer"] = default_manufacturer if default_model is not UNDEFINED and device.model is None: - model = default_model + validated_fields["model"] = default_model if default_name is not UNDEFINED and device.name is None: name = default_name @@ -992,22 +1037,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): allow_collisions=True, add_config_entry_id=config_entry_id, add_config_subentry_id=config_subentry_id, - configuration_url=configuration_url, device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, - hw_version=hw_version, is_new=is_new, - manufacturer=manufacturer, merge_connections=connections or UNDEFINED, merge_identifiers=identifiers or UNDEFINED, - model=model, - model_id=model_id, name=name, - serial_number=serial_number, suggested_area=suggested_area, - sw_version=sw_version, via_device_id=via_device_id, + **validated_fields, ) # This is safe because _async_update_device will always return a device @@ -1052,8 +1091,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) -> DeviceEntry | None: """Private update device attributes. - :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id + :param add_config_subentry_id: Add the device to a specific + subentry of add_config_entry_id + :param remove_config_subentry_id: Remove the device from a + specific subentry of remove_config_entry_id """ old = self.devices[device_id] @@ -1085,7 +1126,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr] ): raise HomeAssistantError( - f"Config entry {add_config_entry_id} has no subentry {add_config_subentry_id}" + f"Config entry {add_config_entry_id} has no" + f" subentry {add_config_subentry_id}" ) if ( @@ -1141,8 +1183,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # Enable the device if it was disabled by config entry and we're adding # a non disabled config entry if ( - # mypy says add_config_entry can be None. That's impossible, because we - # raise above if that happens + # mypy says add_config_entry can be None. + # That's impossible, because we raise above if + # that happens not add_config_entry.disabled_by # type: ignore[union-attr] and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY ): @@ -1251,9 +1294,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) old_values["identifiers"] = old.identifiers - if configuration_url is not UNDEFINED: - configuration_url = _validate_configuration_url(configuration_url) - for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), @@ -1353,8 +1393,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) -> DeviceEntry | None: """Update device attributes. - :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id + :param add_config_subentry_id: Add the device to a specific + subentry of add_config_entry_id + :param remove_config_subentry_id: Remove the device from a + specific subentry of remove_config_entry_id """ if suggested_area is not UNDEFINED: report_usage( @@ -1363,32 +1405,36 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): breaks_in_ha_version="2026.9.0", ) + validated_fields = _validate_device_info_fields( + configuration_url=configuration_url, + hw_version=hw_version, + manufacturer=manufacturer, + model=model, + model_id=model_id, + serial_number=serial_number, + sw_version=sw_version, + ) + return self._async_update_device( device_id, add_config_entry_id=add_config_entry_id, add_config_subentry_id=add_config_subentry_id, area_id=area_id, - configuration_url=configuration_url, device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, - hw_version=hw_version, labels=labels, - manufacturer=manufacturer, merge_connections=merge_connections, merge_identifiers=merge_identifiers, - model=model, - model_id=model_id, name_by_user=name_by_user, name=name, new_connections=new_connections, new_identifiers=new_identifiers, remove_config_entry_id=remove_config_entry_id, remove_config_subentry_id=remove_config_subentry_id, - serial_number=serial_number, suggested_area=suggested_area, - sw_version=sw_version, via_device_id=via_device_id, + **validated_fields, ) @callback @@ -1470,8 +1516,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): async def _async_load(self) -> None: """Load the device registry.""" - assert self._loaded_event is not None - assert not self._loaded_event.is_set() + if self._loaded_event.is_set(): + raise RuntimeError("Device registry is already loaded") async_setup_cleanup(self.hass, self) @@ -1573,12 +1619,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self._loaded_event.set() async def async_wait_loaded(self) -> None: - """Wait until the device registry is fully loaded. - - Will only wait if the registry had already been set up. - """ - if self._loaded_event is not None: - await self._loaded_event.wait() + """Wait until the device registry is fully loaded.""" + await self._loaded_event.wait() @callback def _data_to_save(self) -> dict[str, Any]: @@ -1720,16 +1762,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback -@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> DeviceRegistry: """Get device registry.""" - return DeviceRegistry(hass) + try: + return hass.data[DATA_REGISTRY] + except KeyError as ex: + raise RuntimeError("Device registry not set up") from ex def async_setup(hass: HomeAssistant) -> None: """Set up device registry.""" - assert DATA_REGISTRY not in hass.data - async_get(hass).async_setup() + if DATA_REGISTRY in hass.data: + raise RuntimeError("Device registry is already set up") + hass.data[DATA_REGISTRY] = DeviceRegistry(hass) async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 7c1b5ac4a64..df6f8530106 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -6,14 +6,11 @@ There are two different types of discoveries that can be fired/listened for. components to allow discovery of their platforms. """ -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any, TypedDict from homeassistant import core, setup from homeassistant.const import Platform -from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalTypeFormat from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal @@ -36,7 +33,6 @@ class DiscoveryDict(TypedDict): @core.callback -@bind_hass def async_listen( hass: core.HomeAssistant, service: str, @@ -62,7 +58,6 @@ def async_listen( ) -@bind_hass def discover( hass: core.HomeAssistant, service: str, @@ -77,7 +72,6 @@ def discover( ) -@bind_hass async def async_discover( hass: core.HomeAssistant, service: str, @@ -100,7 +94,6 @@ async def async_discover( ) -@bind_hass def async_listen_platform( hass: core.HomeAssistant, component: str, @@ -127,7 +120,6 @@ def async_listen_platform( ) -@bind_hass def load_platform( hass: core.HomeAssistant, component: Platform | str, @@ -142,7 +134,6 @@ def load_platform( ) -@bind_hass async def async_load_platform( hass: core.HomeAssistant, component: Platform | str, diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index fd41c7ffb44..7c7a925327a 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -1,14 +1,11 @@ """The discovery flow helper.""" -from __future__ import annotations - from collections.abc import Coroutine import dataclasses from typing import TYPE_CHECKING, Any, NamedTuple, Self from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.hass_dict import HassKey @@ -37,7 +34,6 @@ class DiscoveryKey: return cls(domain=json_dict["domain"], key=key, version=json_dict["version"]) -@bind_hass @callback def async_create_flow( hass: HomeAssistant, diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 8eda564e7cb..85bc0723f2c 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,7 +1,5 @@ """Helpers for Home Assistant dispatcher & internal component/platform.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Coroutine from functools import partial @@ -15,7 +13,6 @@ from homeassistant.core import ( callback, get_hassjob_callable_job_type, ) -from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception, log_exception @@ -36,21 +33,18 @@ type _DispatcherDataType[*_Ts] = dict[ @overload -@bind_hass def dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None] ) -> Callable[[], None]: ... @overload -@bind_hass def dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., None] ) -> Callable[[], None]: ... -@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def -def dispatcher_connect[*_Ts]( +def dispatcher_connect[*_Ts]( # type: ignore[misc] hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None], @@ -89,7 +83,6 @@ def _async_remove_dispatcher[*_Ts]( @overload @callback -@bind_hass def async_dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], Any] ) -> Callable[[], None]: ... @@ -97,14 +90,12 @@ def async_dispatcher_connect[*_Ts]( @overload @callback -@bind_hass def async_dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., Any] ) -> Callable[[], None]: ... @callback -@bind_hass def async_dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts] | str, @@ -126,19 +117,16 @@ def async_dispatcher_connect[*_Ts]( @overload -@bind_hass def dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: ... @overload -@bind_hass def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: ... -@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def -def dispatcher_send[*_Ts]( +def dispatcher_send[*_Ts]( # type: ignore[misc] hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: """Send signal and data.""" @@ -181,7 +169,6 @@ def _generate_job[*_Ts]( @overload @callback -@bind_hass def async_dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: ... @@ -189,12 +176,10 @@ def async_dispatcher_send[*_Ts]( @overload @callback -@bind_hass def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: ... @callback -@bind_hass def async_dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts ) -> None: @@ -216,7 +201,6 @@ def async_dispatcher_send[*_Ts]( @callback -@bind_hass def async_dispatcher_send_internal[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts ) -> None: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 63e02627f71..d8e21bdd7b3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -50,7 +50,7 @@ from homeassistant.core import ( ) from homeassistant.core_config import DATA_CUSTOMIZE from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError -from homeassistant.loader import async_suggest_report_issue, bind_hass +from homeassistant.loader import async_suggest_report_issue from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed @@ -76,7 +76,7 @@ DATA_ENTITY_SOURCE = "entity_info" # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable -FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 +FLOAT_PRECISION = abs(math.floor(math.log10(abs(sys.float_info.epsilon)))) - 1 # How many times per hour we allow capabilities to be updated before logging a warning CAPABILITIES_UPDATE_LIMIT = 100 @@ -91,7 +91,6 @@ def async_setup(hass: HomeAssistant) -> None: @callback -@bind_hass @singleton(DATA_ENTITY_SOURCE) def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources. @@ -374,7 +373,8 @@ class CachedProperties(type): attr_name = f"_attr_{property_name}" private_attr_name = f"__attr_{property_name}" # Check if an _attr_ class attribute exits and move it to __attr_. We check - # __dict__ here because we don't care about _attr_ class attributes in parents. + # __dict__ here because we don't care about _attr_ class + # attributes in parents. if attr_name in cls.__dict__: attr = getattr(cls, attr_name) if isinstance(attr, (FunctionType, property)): @@ -389,7 +389,8 @@ class CachedProperties(type): else: def wrapped_annotate(format: Format) -> dict[str, Any]: - # Note: to avoid complicating things, we only support FORWARDREF + # Note: to avoid complicating things, + # we only support FORWARDREF return annotations cls.__annotate__ = wrapped_annotate @@ -413,8 +414,9 @@ class CachedProperties(type): if property_name in seen_props: continue attr_name = f"_attr_{property_name}" - # Check if an _attr_ class attribute exits. We check __dict__ here because - # we don't care about _attr_ class attributes in parents. + # Check if an _attr_ class attribute exists. + # We check __dict__ here because we don't care + # about _attr_ class attributes in parents. if (attr_name) not in cls.__dict__: continue wrap_attr(cls, property_name) @@ -732,7 +734,8 @@ class Entity( return device_class_name return description_name - # The entity has no name set by _attr_name, translation_key or entity_description + # The entity has no name set by _attr_name, translation_key + # or entity_description # Check if the entity should be named by its device class if self._default_to_device_class_name(): return device_class_name @@ -1185,8 +1188,9 @@ class Entity( self._disabled_reported = True _LOGGER.warning( ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" + "Entity %s is incorrectly being triggered" + " for updates while it is disabled." + " This is a bug in the %s integration" ), self.entity_id, self.platform.platform_name, @@ -1205,8 +1209,9 @@ class Entity( time_now = timer() if entry := self.registry_entry: - # Make sure capabilities and other data in the entity registry are up to date. - # Capabilities include capability attributes, device class and supported features. + # Make sure capabilities and other data in the entity + # registry are up to date. Capabilities include capability + # attributes, device class and supported features. supported_features = supported_features or 0 if ( capabilities != entry.capabilities diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ca46be3d934..412c2b7c074 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -1,9 +1,7 @@ """Helpers for components that manage entities.""" -from __future__ import annotations - import asyncio -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Coroutine, Iterable, Mapping from datetime import timedelta import logging from types import ModuleType @@ -17,6 +15,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( + EntityServiceResponse, Event, HassJobType, HomeAssistant, @@ -29,7 +28,7 @@ from homeassistant.exceptions import ( HomeAssistantError, ServiceValidationError, ) -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.loader import async_get_integration from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.hass_dict import HassKey @@ -41,7 +40,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES: HassKey[dict[str, EntityComponent]] = HassKey("entity_components") -@bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: """Trigger an update for an entity.""" domain = entity_id.partition(".")[0] @@ -69,7 +67,8 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: as 'hue.light'. This class has the following responsibilities: - - Process the configuration and set up a platform based component, for example light. + - Process the configuration and set up a platform based component, + for example light. - Manage the platforms and their entities. - Help extract the entities from a service call. - Listen for discovery events for platforms related to the domain. @@ -96,7 +95,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: ] = {domain: domain_platform} self.async_add_entities = domain_platform.async_add_entities self.add_entities = domain_platform.add_entities - self._entities: dict[str, entity.Entity] = domain_platform.domain_entities + self._entities: dict[str, _EntityT] = domain_platform.domain_entities # type: ignore[assignment] hass.data.setdefault(DATA_INSTANCES, {})[domain] = self # type: ignore[assignment] @property @@ -107,11 +106,11 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: callers that iterate over this asynchronously should make a copy using list() before iterating. """ - return self._entities.values() # type: ignore[return-value] + return self._entities.values() def get_entity(self, entity_id: str) -> _EntityT | None: """Get an entity.""" - return self._entities.get(entity_id) # type: ignore[return-value] + return self._entities.get(entity_id) def register_shutdown(self) -> None: """Register shutdown on Home Assistant STOP event. @@ -242,6 +241,37 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]: description_placeholders=description_placeholders, ) + @callback + def async_register_batched_entity_service( + self, + name: str, + schema: VolDictType | VolSchemaType | None, + func: Callable[ + [list[_EntityT], ServiceCall], + Coroutine[Any, Any, EntityServiceResponse | None], + ], + required_features: Iterable[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, + *, + description_placeholders: Mapping[str, str] | None = None, + ) -> None: + """Register a batched entity service. + + A batched entity service calls the service function once with all + matching entities as a list, instead of once per entity. + """ + service.async_register_batched_entity_service( + self.hass, + self.domain, + name, + entities=self._entities, + func=func, + required_features=required_features, + schema=schema, + supports_response=supports_response, + description_placeholders=description_placeholders, + ) + async def async_setup_platform( self, platform_type: str, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 3ef6dfac39c..5a321e4d51c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,7 +1,5 @@ """Class to manage the entities for a single platform.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from contextvars import ContextVar @@ -68,6 +66,61 @@ PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) +@callback +def async_create_platform_config_not_supported_issue( + hass: HomeAssistant, + integration_domain: str, + platform_domain: str, + *, + yaml_config_under_integration_supported: bool = False, + learn_more_url: str | None = None, + logger: Logger = _LOGGER, +) -> None: + """Create a repair issue for an unsupported YAML platform configuration. + + Raised when an integration is configured via the legacy + : - platform: schema. + Set yaml_config_under_integration_supported=False if the integration does + not support YAML configuration for this platform and the config should be + removed. Set it to True if the integration supports YAML configuration + under its own : key and the config should be moved + there. + """ + if yaml_config_under_integration_supported: + logger.error( + "Configuring the %s integration under the %s platform key is not" + " supported, it must be configured under its own %s key instead", + integration_domain, + platform_domain, + integration_domain, + ) + else: + logger.error( + "The %s platform for the %s integration does not support platform" + " setup, please remove it from your config", + integration_domain, + platform_domain, + ) + platform_key = f"platform: {integration_domain}" + yaml_example = f"```yaml\n{platform_domain}:\n - {platform_key}\n```" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"platform_integration_no_support_{platform_domain}_{integration_domain}", + is_fixable=False, + issue_domain=integration_domain, + learn_more_url=learn_more_url, + severity=IssueSeverity.ERROR, + translation_key=f"platform_{'config' if yaml_config_under_integration_supported else 'setup'}_not_supported", + translation_placeholders={ + "platform_domain": platform_domain, + "integration_domain": integration_domain, + "platform_key": platform_key, + "yaml_example": yaml_example, + }, + ) + + class AddEntitiesCallback(Protocol): """Protocol type for EntityPlatform.add_entities callback.""" @@ -317,20 +370,13 @@ class EntityPlatform: if not hasattr(platform, "async_setup_platform") and not hasattr( platform, "setup_platform" ): - self.logger.error( - ( - "The %s platform for the %s integration does not support platform" - " setup. Please remove it from your config." - ), - self.platform_name, - self.domain, - ) learn_more_url = None if self.platform: if "custom_components" in self.platform.__file__: # type: ignore[attr-defined] self.logger.warning( ( - "The %s platform module for the %s custom integration does not implement" + "The %s platform module for the %s custom" + " integration does not implement" " async_setup_platform or setup_platform." ), self.platform_name, @@ -338,25 +384,14 @@ class EntityPlatform: ) else: learn_more_url = f"https://www.home-assistant.io/integrations/{self.platform_name}/" - platform_key = f"platform: {self.platform_name}" - yaml_example = f"```yaml\n{self.domain}:\n - {platform_key}\n```" - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"platform_integration_no_support_{self.domain}_{self.platform_name}", - is_fixable=False, - issue_domain=self.platform_name, - learn_more_url=learn_more_url, - severity=IssueSeverity.ERROR, - translation_key="no_platform_setup", - translation_placeholders={ - "domain": self.domain, - "platform": self.platform_name, - "platform_key": platform_key, - "yaml_example": yaml_example, - }, - ) + async_create_platform_config_not_supported_issue( + self.hass, + self.platform_name, + self.domain, + learn_more_url=learn_more_url, + logger=self.logger, + ) return @callback @@ -583,7 +618,8 @@ class EntityPlatform: update_before_add=update_before_add, config_subentry_id=config_subentry_id, ), - f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", + "EntityPlatform async_add_entities_for_entry" + f" {self.domain}.{self.platform_name}", eager_start=True, ) @@ -714,8 +750,9 @@ class EntityPlatform: or config_subentry_id not in self.config_entry.subentries ): raise HomeAssistantError( - f"Can't add entities to unknown subentry {config_subentry_id} of config " - f"entry {self.config_entry.entry_id if self.config_entry else None}" + f"Can't add entities to unknown subentry" + f" {config_subentry_id} of config entry" + f" {self.config_entry.entry_id if self.config_entry else None}" ) entities: list[Entity] = ( @@ -845,8 +882,9 @@ class EntityPlatform: ) if domain != self.domain: report_usage( - f"sets an entity ID with wrong domain: '{entity.entity_id}'. " - f"Expected domain is '{self.domain}'", + "sets an entity ID with wrong domain:" + f" '{entity.entity_id}'." + f" Expected domain is '{self.domain}'", integration_domain=self.platform_name, breaks_in_ha_version="2027.5.0", ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 1bcf61a3cf9..c0cc468ee20 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -8,8 +8,6 @@ registered. Registering a new entity while a timer is in progress resets the timer. """ -from __future__ import annotations - from collections import defaultdict from collections.abc import Callable, Hashable, KeysView, Mapping from datetime import datetime, timedelta @@ -33,7 +31,6 @@ from homeassistant.const import ( MAX_LENGTH_STATE_DOMAIN, MAX_LENGTH_STATE_ENTITY_ID, STATE_UNAVAILABLE, - STATE_UNKNOWN, EntityCategory, Platform, ) @@ -53,7 +50,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from homeassistant.util.read_only_dict import ReadOnlyDict -from . import device_registry as dr, storage +from . import area_registry as ar, device_registry as dr, storage from .device_registry import ( EVENT_DEVICE_REGISTRY_UPDATED, EventDeviceRegistryUpdatedData, @@ -483,6 +480,7 @@ def async_get_unprefixed_name(hass: HomeAssistant, entry: RegistryEntry) -> str: def _async_get_full_entity_name( hass: HomeAssistant, *, + area_id: str | None | UndefinedType = UNDEFINED, device_id: str | None, fallback: str, has_entity_name: bool, @@ -495,11 +493,11 @@ def _async_get_full_entity_name( ) -> str: """Get full name for an entity. - This includes the device name if appropriate. + This includes the device and area name if appropriate. Used for both full entity name and entity ID. """ if name is None and overridden_name is not None: - name = overridden_name + full_name = overridden_name elif not use_legacy_naming or name is None: device_name: str | None = None @@ -509,7 +507,19 @@ def _async_get_full_entity_name( ): device_name = device.name_by_user or device.name - if name is None: + if area_id is None: + area_id = device.area_id + + area_name: str | None = None + if ( + area_id is not UNDEFINED + and area_id is not None + and (area := ar.async_get(hass).async_get_area(area_id)) is not None + ): + area_name = area.name + + entity_name = name + if entity_name is None: if original_name_unprefixed is UNDEFINED: original_name_unprefixed = ( _async_strip_prefix_from_entity_name(original_name, device_name) @@ -517,7 +527,7 @@ def _async_get_full_entity_name( else None ) - name = ( + entity_name = ( original_name_unprefixed if original_name_unprefixed is not None else original_name @@ -525,17 +535,19 @@ def _async_get_full_entity_name( elif unprefix_name: unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) if unprefixed_name is not None: - name = unprefixed_name + entity_name = unprefixed_name - if not name: - name = device_name - elif device_name: - name = f"{device_name} {name}" + full_name = " ".join( + part for part in (area_name, device_name, entity_name) if part + ) - if not name: + else: + full_name = name + + if not full_name: return fallback - return name + return full_name @callback @@ -861,8 +873,8 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): entity["name"] = None entity["options"] = {} if old_minor_version < 19: - # Version 1.19 adds undefined flags to deleted entities, this is a bugfix - # of version 1.18 + # Version 1.19 adds undefined flags to deleted + # entities, this is a bugfix of version 1.18 set_to_undefined = old_minor_version < 18 for entity in data["deleted_entities"]: entity["disabled_by_undefined"] = set_to_undefined @@ -897,10 +909,10 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): entity["name"] = name if old_minor_version < 22: - # Version 1.22 adds support for COMPUTED_NAME in aliases and starts preserving - # their order. - # To avoid a major version bump, we keep the old aliases as-is and use aliases_v2 - # field instead. + # Version 1.22 adds support for COMPUTED_NAME in + # aliases and starts preserving their order. + # To avoid a major version bump, we keep the old + # aliases as-is and use aliases_v2 field instead. for entity in data["entities"]: entity["aliases_v2"] = [None, *entity["aliases"]] @@ -1084,7 +1096,8 @@ def _validate_item( and not isinstance(entity_category, EntityCategory) ): raise ValueError( - f"entity_category must be a valid EntityCategory instance, got {entity_category}" + "entity_category must be a valid EntityCategory" + f" instance, got {entity_category}" ) if ( hidden_by @@ -1174,8 +1187,9 @@ class EntityRegistry(BaseRegistry): This function is deprecated. Use `async_get_available_entity_id` instead. - Entity ID conflicts are checked against registered and currently existing entities, - as well as provided `reserved_entity_ids`. + Entity ID conflicts are checked against registered and + currently existing entities, as well as provided + `reserved_entity_ids`. """ report_usage( "calls `entity_registry.async_generate_entity_id`, " @@ -1203,8 +1217,9 @@ class EntityRegistry(BaseRegistry): ) -> str: """Get next available entity ID. - Entity ID conflicts are checked against registered and currently existing entities, - as well as provided `reserved_entity_ids`. + Entity ID conflicts are checked against registered and + currently existing entities, as well as provided + `reserved_entity_ids`. """ preferred_string = f"{domain}.{slugify(suggested_object_id)}" @@ -1229,6 +1244,7 @@ class EntityRegistry(BaseRegistry): def _async_generate_entity_id( self, *, + area_id: str | None = None, current_entity_id: str | None, device_id: str | None, domain: str, @@ -1255,6 +1271,7 @@ class EntityRegistry(BaseRegistry): """ object_id = _async_get_full_entity_name( self.hass, + area_id=area_id, device_id=device_id, fallback=f"{platform}_{unique_id}", has_entity_name=has_entity_name, @@ -1279,10 +1296,12 @@ class EntityRegistry(BaseRegistry): ) -> str: """Regenerate an entity ID for an entry. - Entity ID conflicts are checked against registered and currently existing entities, - as well as provided `reserved_entity_ids`. + Entity ID conflicts are checked against registered and + currently existing entities, as well as provided + `reserved_entity_ids`. """ return self._async_generate_entity_id( + area_id=entry.area_id, current_entity_id=entry.entity_id, device_id=entry.device_id, domain=entry.domain, @@ -1427,6 +1446,7 @@ class EntityRegistry(BaseRegistry): if entity_id is None: entity_id = self._async_generate_entity_id( + area_id=area_id, current_entity_id=None, device_id=device_id, domain=domain, @@ -1604,8 +1624,8 @@ class EntityRegistry(BaseRegistry): ): self.async_remove(entity.entity_id) - # Remove entities which belong to config subentries no longer associated with the - # device + # Remove entities which belong to config subentries no longer + # associated with the device if old_config_entries_subentries := changes.get("config_entries_subentries"): entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True @@ -1930,13 +1950,14 @@ class EntityRegistry(BaseRegistry): This should only be used when an entity needs to be migrated between integrations. """ - if ( - state := self.hass.states.get(entity_id) - ) is not None and state.state != STATE_UNKNOWN: + # import here to avoid circular import + from .entity import entity_sources # noqa: PLC0415 + + if entity_id in entity_sources(self.hass): raise ValueError("Only entities that haven't been loaded can be migrated") old = self.entities[entity_id] - if new_config_entry_id == UNDEFINED and old.config_entry_id is not None: + if new_config_entry_id is UNDEFINED and old.config_entry_id is not None: raise ValueError( f"new_config_entry_id required because {entity_id} is already linked " "to a config entry" diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 7d9e0aa29e1..519802bd69a 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -1,7 +1,5 @@ """A class to hold entity values.""" -from __future__ import annotations - import fnmatch from functools import lru_cache import re diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 1eaa0fb1404..e83a3ca43b5 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,7 +1,5 @@ """Helper class to implement include/exclude of entities and domains.""" -from __future__ import annotations - from collections.abc import Callable import fnmatch from functools import lru_cache, partial diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 03c699168ef..03ce51864d1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,7 +1,5 @@ """Helpers for listening to events.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence @@ -36,8 +34,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.loader import bind_hass +from homeassistant.exceptions import TemplateError from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.event_type import EventType @@ -94,7 +91,7 @@ _TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) @dataclass(slots=True, frozen=True) -class _KeyedEventTracker(Generic[_TypedDictT]): +class _KeyedEventTracker(Generic[_TypedDictT]): # noqa: UP046 """Class to track events by key.""" key: HassKey[_KeyedEventData[_TypedDictT]] @@ -118,7 +115,7 @@ class _KeyedEventTracker(Generic[_TypedDictT]): @dataclass(slots=True, frozen=True) -class _KeyedEventData(Generic[_TypedDictT]): +class _KeyedEventData(Generic[_TypedDictT]): # noqa: UP046 """Class to track data for events by key.""" listener: CALLBACK_TYPE @@ -199,7 +196,6 @@ def threaded_listener_factory[**_P]( @callback -@bind_hass def async_track_state_change( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -305,7 +301,6 @@ def async_track_state_change( track_state_change = threaded_listener_factory(async_track_state_change) -@bind_hass def async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -384,7 +379,6 @@ _KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker( ) -@bind_hass def _async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -537,7 +531,6 @@ _KEYED_TRACK_ENTITY_REGISTRY_UPDATED = _KeyedEventTracker( ) -@bind_hass @callback def async_track_entity_registry_updated_event( hass: HomeAssistant, @@ -649,7 +642,6 @@ def _async_domain_added_filter( ) -@bind_hass def async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -670,7 +662,6 @@ _KEYED_TRACK_STATE_ADDED_DOMAIN = _KeyedEventTracker( ) -@bind_hass def _async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -707,7 +698,6 @@ _KEYED_TRACK_STATE_REMOVED_DOMAIN = _KeyedEventTracker( ) -@bind_hass def async_track_state_removed_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -863,7 +853,6 @@ class _TrackStateChangeFiltered: @callback -@bind_hass def async_track_state_change_filtered( hass: HomeAssistant, track_states: TrackStates, @@ -894,7 +883,6 @@ def async_track_state_change_filtered( @callback -@bind_hass def async_track_template( hass: HomeAssistant, template: Template, @@ -1000,14 +988,6 @@ class TrackTemplateResultInfo: self._last_result: dict[Template, bool | str | TemplateError] = {} - for track_template_ in track_templates: - if track_template_.template.hass: - continue - - raise HomeAssistantError( - "Calls async_track_template_result with template without hass" - ) - self._rate_limit = KeyedRateLimit(hass) self._info: dict[Template, RenderInfo] = {} self._track_state_changes: _TrackStateChangeFiltered | None = None @@ -1339,7 +1319,6 @@ type TrackTemplateResultListener = Callable[ @callback -@bind_hass def async_track_template_result( hass: HomeAssistant, track_templates: Sequence[TrackTemplate], @@ -1392,7 +1371,6 @@ def async_track_template_result( @callback -@bind_hass def async_track_same_state( hass: HomeAssistant, period: timedelta, @@ -1460,7 +1438,6 @@ track_same_state = threaded_listener_factory(async_track_same_state) @callback -@bind_hass def async_track_point_in_time( hass: HomeAssistant, action: HassJob[[datetime], Coroutine[Any, Any, None] | None] @@ -1540,7 +1517,6 @@ class _TrackPointUTCTime: @callback -@bind_hass def async_track_point_in_utc_time( hass: HomeAssistant, action: HassJob[[datetime], Coroutine[Any, Any, None] | None] @@ -1575,7 +1551,6 @@ def _run_async_call_action( @callback -@bind_hass def async_call_at( hass: HomeAssistant, action: HassJob[[datetime], Coroutine[Any, Any, None] | None] @@ -1595,7 +1570,6 @@ def async_call_at( @callback -@bind_hass def async_call_later( hass: HomeAssistant, delay: float | timedelta, @@ -1675,7 +1649,6 @@ class _TrackTimeInterval: @callback -@bind_hass def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], @@ -1761,7 +1734,6 @@ class SunListener: @callback -@bind_hass def async_track_sunrise( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: @@ -1777,7 +1749,6 @@ track_sunrise = threaded_listener_factory(async_track_sunrise) @callback -@bind_hass def async_track_sunset( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: @@ -1853,7 +1824,6 @@ class _TrackUTCTimeChange: @callback -@bind_hass def async_track_utc_time_change( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], @@ -1901,7 +1871,6 @@ track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) @callback -@bind_hass def async_track_time_change( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index aae2a08e81e..385d8cebfda 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -1,7 +1,5 @@ """Provide a way to assign areas to floors in one's home.""" -from __future__ import annotations - from collections import defaultdict from collections.abc import Iterable import dataclasses diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 2d9b368254a..3734871ec81 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -1,7 +1,5 @@ """Provide frame helper for finding the current frame context.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import enum diff --git a/homeassistant/helpers/group.py b/homeassistant/helpers/group.py index 939d1c1cafd..c7e80e41a8e 100644 --- a/homeassistant/helpers/group.py +++ b/homeassistant/helpers/group.py @@ -1,7 +1,5 @@ """Helper for groups.""" -from __future__ import annotations - from collections.abc import Iterable from typing import TYPE_CHECKING, Any diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 04a1d2cca76..c433040a6c5 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -1,7 +1,5 @@ """Helpers for helper integrations.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index f097da77cb8..1d300133e75 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -1,8 +1,6 @@ """Helper to track the current http request.""" -from __future__ import annotations - -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Container, Mapping from contextvars import ContextVar from http import HTTPStatus import inspect @@ -22,7 +20,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, HomeAssistant, is_callback +from homeassistant.core import Context, HomeAssistant, callback, is_callback from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data from .json import find_paths_unserializable_data, json_bytes, json_dumps @@ -57,17 +55,27 @@ def request_handler_factory( authenticated = request.get(KEY_AUTHENTICATED, False) - if view.requires_auth and not authenticated: + if view.use_query_token_for_auth and not authenticated: + token = request.query.get("token") + if token and token in view.get_valid_auth_tokens(request.match_info): + _LOGGER.debug("Authenticated request with query token") + authenticated = True + + if (view.requires_auth or view.use_query_token_for_auth) and not authenticated: # Import here to avoid circular dependency with network.py from .network import NoURLAvailableError, get_url # noqa: PLC0415 + # Get the current request header to include as resource metadata + # endpoint for RFC9728. We currently prefer external since this + # is likely most used by remote OAuth clients try: - url_prefix = get_url(hass, require_current_request=True) + url_prefix = get_url( + hass, require_current_request=True, prefer_external=True + ) except NoURLAvailableError: # Omit header to avoid leaking configured URLs raise HTTPUnauthorized from None raise HTTPUnauthorized( - # Include resource metadata endpoint for RFC9728 headers={ "WWW-Authenticate": ( f'Bearer resource_metadata="{url_prefix}' @@ -127,6 +135,7 @@ class HomeAssistantView: extra_urls: list[str] = [] # Views inheriting from this class can override this requires_auth = True + use_query_token_for_auth = False cors_allowed = False @staticmethod @@ -202,3 +211,8 @@ class HomeAssistantView: if allow_cors: for route in routes: allow_cors(route) + + @callback + def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]: + """Return valid auth tokens, which can be used for query token authentication.""" + return () diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index d253c3377aa..97610ff4a3d 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -1,7 +1,5 @@ """Helper for httpx.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import sys from types import TracebackType @@ -14,7 +12,6 @@ import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from homeassistant.util.ssl import ( SSL_ALPN_HTTP11, @@ -44,7 +41,6 @@ USER_AGENT = "User-Agent" @callback -@bind_hass def get_async_client( hass: HomeAssistant, verify_ssl: bool = True, diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index a8c1b0b2186..b79ad62d596 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,5 @@ """Icon helper methods.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from functools import lru_cache @@ -172,13 +170,13 @@ def icon_for_battery_level( if battery_level is None: return f"{icon}-unknown" if charging and battery_level > 10: - icon += f"-charging-{int(round(battery_level / 20 - 0.01)) * 20}" + icon += f"-charging-{round(battery_level / 20 - 0.01) * 20}" elif charging: icon += "-outline" elif battery_level <= 5: icon += "-alert" elif 5 < battery_level < 95: - icon += f"-{int(round(battery_level / 10 - 0.01)) * 10}" + icon += f"-{round(battery_level / 10 - 0.01) * 10}" return icon diff --git a/homeassistant/helpers/importlib.py b/homeassistant/helpers/importlib.py index 3953881532d..f0d1ac94420 100644 --- a/homeassistant/helpers/importlib.py +++ b/homeassistant/helpers/importlib.py @@ -1,7 +1,5 @@ """Helper to import modules from asyncio.""" -from __future__ import annotations - import asyncio import importlib import logging diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py index 1d62ca633ee..2b0257d0578 100644 --- a/homeassistant/helpers/instance_id.py +++ b/homeassistant/helpers/instance_id.py @@ -1,7 +1,5 @@ """Helper to create a unique instance ID.""" -from __future__ import annotations - import logging import uuid diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 4ded7444989..419741748e9 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -1,7 +1,5 @@ """Helpers to help with integration platforms.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -17,7 +15,6 @@ from homeassistant.loader import ( async_get_integrations, async_get_loaded_integration, async_register_preload_platform, - bind_hass, ) from homeassistant.setup import ATTR_COMPONENT, EventComponentLoaded from homeassistant.util.hass_dict import HassKey @@ -153,7 +150,6 @@ def _format_err(name: str, platform_name: str, *args: Any) -> str: return f"Exception in {name} when processing platform '{platform_name}': {args}" -@bind_hass async def async_process_integration_platforms( hass: HomeAssistant, platform_name: str, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 85f99053557..b11b07d8f4e 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,7 +1,5 @@ """Module to coordinate user intentions.""" -from __future__ import annotations - from abc import abstractmethod import asyncio from collections.abc import Callable, Collection, Coroutine, Iterable @@ -23,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from . import ( @@ -72,7 +69,6 @@ SPEECH_TYPE_SSML = "ssml" @callback -@bind_hass def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: """Register an intent with Home Assistant.""" if (intents := hass.data.get(DATA_KEY)) is None: @@ -90,7 +86,6 @@ def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: @callback -@bind_hass def async_remove(hass: HomeAssistant, intent_type: str) -> None: """Remove an intent from Home Assistant.""" if (intents := hass.data.get(DATA_KEY)) is None: @@ -105,7 +100,6 @@ def async_get(hass: HomeAssistant) -> Iterable[IntentHandler]: return hass.data.get(DATA_KEY, {}).values() -@bind_hass async def async_handle( hass: HomeAssistant, platform: str, @@ -202,7 +196,11 @@ class MatchFailedError(IntentError): def __str__(self) -> str: """Return string representation.""" - return f"" + return ( + f"" + ) class NoStatesMatchedError(MatchFailedError): @@ -264,7 +262,10 @@ class MatchFailedReason(Enum): """Floor name from constraint does not exist.""" DUPLICATE_NAME = auto() - """Two or more entities matched the same name constraint and could not be disambiguated.""" + """Two or more entities matched the same name constraint. + + Could not be disambiguated. + """ MULTIPLE_TARGETS = auto() """Two or more entities matched when a single target is required.""" @@ -292,7 +293,10 @@ class MatchTargetsResult: """List of matched entity states.""" no_match_name: str | None = None - """Name of invalid area/floor or duplicate name when match fails for those reasons.""" + """Name of invalid area/floor or duplicate name. + + Used when match fails for those reasons. + """ areas: list[ar.AreaEntry] = field(default_factory=list) """Areas that were targeted.""" @@ -352,7 +356,7 @@ class MatchTargetsConstraints: @dataclass class MatchTargetsPreferences: - """Preferences used to disambiguate duplicate name matches in async_match_targets.""" + """Preferences to disambiguate duplicate name matches.""" area_id: str | None = None """Id of area to use when deduplicating names.""" @@ -774,7 +778,6 @@ def async_match_targets( # noqa: C901 @callback -@bind_hass def async_match_states( hass: HomeAssistant, name: str | None = None, @@ -785,7 +788,10 @@ def async_match_states( states: list[State] | None = None, assistant: str | None = None, ) -> Iterable[State]: - """Simplified interface to async_match_targets that returns states matching the constraints.""" + """Return states matching the constraints. + + Simplified interface to async_match_targets. + """ result = async_match_targets( hass, constraints=MatchTargetsConstraints( @@ -1181,17 +1187,11 @@ class DynamicServiceIntentHandler(IntentHandler): After the timeout the task will continue to run in the background. """ - try: - await asyncio.wait({task}, timeout=self.service_timeout) - except TimeoutError: - pass - except asyncio.CancelledError: - # Task calling us was cancelled, so cancel service call task, and wait for - # it to be cancelled, within reason, before leaving. - _LOGGER.debug("Service call was cancelled: %s", task.get_name()) - task.cancel() - await asyncio.wait({task}, timeout=5) - raise + done, _ = await asyncio.wait({task}, timeout=self.service_timeout) + if done: + # Task finished within the timeout. Re-raise any exception + # (e.g. validation errors) so the caller can handle it. + task.result() class ServiceIntentHandler(DynamicServiceIntentHandler): @@ -1427,7 +1427,7 @@ class IntentResponse: def async_set_states( self, matched_states: list[State], unmatched_states: list[State] | None = None ) -> None: - """Set entity states that were matched or not matched during intent handling (query).""" + """Set matched/unmatched entity states during intent handling.""" self.matched_states = matched_states self.unmatched_states = unmatched_states or [] @@ -1440,20 +1440,20 @@ class IntentResponse: def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of an intent response.""" response_dict: dict[str, Any] = { - "speech": self.speech, - "card": self.card, + "speech": {k: dict(v) for k, v in self.speech.items()}, + "card": {k: dict(v) for k, v in self.card.items()}, "language": self.language, "response_type": self.response_type.value, } if self.reprompt: - response_dict["reprompt"] = self.reprompt + response_dict["reprompt"] = {k: dict(v) for k, v in self.reprompt.items()} if self.speech_slots: - response_dict["speech_slots"] = self.speech_slots + response_dict["speech_slots"] = self.speech_slots.copy() response_data: dict[str, Any] = {} - if self.response_type == IntentResponseType.ERROR: + if self.response_type is IntentResponseType.ERROR: assert self.error_code is not None, "error code is required" response_data["code"] = self.error_code.value else: diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index ce12d1f19da..8850faa5632 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -1,7 +1,5 @@ """Persistently store issues raised by integrations.""" -from __future__ import annotations - import dataclasses from datetime import datetime from enum import StrEnum @@ -29,6 +27,13 @@ STORAGE_KEY = "repairs.issue_registry" STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 2 +# Issues that are handled entirely by the frontend and don't need +# a description or fix_flow. +FRONTEND_HANDLED_ISSUES: dict[str, set[str]] = { + "sensor": {"mean_type_changed", "state_class_removed", "units_changed"}, + "vacuum": {"segments_changed"}, +} + class EventIssueRegistryUpdatedData(TypedDict): """Event data for when the issue registry is updated.""" diff --git a/homeassistant/helpers/label_registry.py b/homeassistant/helpers/label_registry.py index a010347a7a5..99a9c81f82c 100644 --- a/homeassistant/helpers/label_registry.py +++ b/homeassistant/helpers/label_registry.py @@ -1,7 +1,5 @@ """Provide a way to label and group anything.""" -from __future__ import annotations - from collections.abc import Iterable import dataclasses from dataclasses import dataclass diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index c9ca479df8e..3035b713e02 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -1,7 +1,5 @@ """Module to coordinate llm tools.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field as dc_field @@ -73,18 +71,42 @@ NO_ENTITIES_PROMPT = ( "to their voice assistant in Home Assistant." ) -DYNAMIC_CONTEXT_PROMPT = """You ARE equipped to answer questions about the current state of -the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the -functionality if the question requires live data. -If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer -from the static context below. -If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", -"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"): - 1. Recognize this requires live data. - 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.). - 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool]."). -For general knowledge questions not about the home: Answer truthfully from internal knowledge. -""" +DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just name and domain. " + "When controlling an area, prefer passing just area name and domain." +) + +DYNAMIC_CONTEXT_PROMPT = ( + "You ARE equipped to answer questions about the" + " current state of\n" + "the home using the `GetLiveContext` tool." + " This is a primary function." + " Do not state you lack the\n" + "functionality if the question requires live data.\n" + "If the user asks about device existence/type" + ' (e.g., "Do I have lights in the bedroom?"):' + " Answer\n" + "from the static context below.\n" + "If the user asks about the CURRENT state, value," + ' or mode (e.g., "Is the lock locked?",\n' + '"Is the fan on?",' + ' "What mode is the thermostat in?",' + ' "What is the temperature outside?"):\n' + " 1. Recognize this requires live data.\n" + " 2. You MUST call `GetLiveContext`." + " This tool will provide the needed real-time" + " information (like temperature from the local" + " weather, lock status, etc.).\n" + " 3. Use the tool's response** to answer the" + " user accurately" + ' (e.g., "The temperature outside is' + ' [value from tool].").\n' + "For general knowledge questions not about the" + " home: Answer truthfully from internal" + " knowledge.\n" +) @callback @@ -481,27 +503,33 @@ class AssistAPI(API): ) -> str: if not exposed_entities or not exposed_entities["entities"]: return NO_ENTITIES_PROMPT - return "\n".join( - [ - *self._async_get_preable(llm_context), - *self._async_get_exposed_entities_prompt(llm_context, exposed_entities), - ] - ) + + # Collect all parts, filtering out any None values + prompt_parts = [ + DEVICE_CONTROL_TOOL_USAGE_PROMPT, + DYNAMIC_CONTEXT_PROMPT, + *self._async_get_exposed_entities_prompt(exposed_entities), + self._async_get_voice_satellite_area_prompt(llm_context), + self._async_get_no_timer_prompt(llm_context), + ] + + # Filter out None and empty strings before joining + return "\n".join([part for part in prompt_parts if part]) @callback - def _async_get_preable(self, llm_context: LLMContext) -> list[str]: - """Return the prompt for the API.""" + def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None: + if not llm_context.device_id or not async_device_supports_timers( + self.hass, llm_context.device_id + ): + return "This device is not able to start timers." + return None - prompt = [ - ( - "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." - ) - ] - area: ar.AreaEntry | None = None + @callback + def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str: + """Return the area prompt for the voice satellite.""" floor: fr.FloorEntry | None = None + area: ar.AreaEntry | None = None + extra = "" if llm_context.device_id: device_reg = dr.async_get(self.hass) device = device_reg.async_get(llm_context.device_id) @@ -513,37 +541,33 @@ class AssistAPI(API): if area.floor_id: floor = floor_reg.async_get_floor(area.floor_id) - extra = "and all generic commands like 'turn on the lights' should target this area." - - if floor and area: - prompt.append(f"You are in area {area.name} (floor {floor.name}) {extra}") - elif area: - prompt.append(f"You are in area {area.name} {extra}") - else: - prompt.append( - "When a user asks to turn on all devices of a specific type, " - "ask user to specify an area, unless there is only one device of that type." + extra = ( + "and all generic commands like" + " 'turn on the lights' should target" + " this area." ) - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - prompt.append("This device is not able to start timers.") - - prompt.append(DYNAMIC_CONTEXT_PROMPT) - - return prompt + if floor and area: + return f"You are in area {area.name} (floor {floor.name}) {extra}".strip() + if area: + return f"You are in area {area.name} {extra}".strip() + return ( + "When a user asks to turn on all devices of a specific type, " + "ask the user to specify an area, unless there" + " is only one device of that type." + ) @callback def _async_get_exposed_entities_prompt( - self, llm_context: LLMContext, exposed_entities: dict | None + self, exposed_entities: dict | None ) -> list[str]: """Return the prompt for the API for exposed entities.""" prompt = [] if exposed_entities and exposed_entities["entities"]: prompt.append( - "Static Context: An overview of the areas and the devices in this smart home:" + "Static Context: An overview of the areas" + " and the devices in this smart home:" ) prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) @@ -1107,7 +1131,9 @@ class TodoGetItemsTool(Tool): name = "todo_get_items" description = ( "Query a to-do list to find out what items are on it. " - "Use this to answer questions like 'What's on my task list?' or 'Read my grocery list'. " + "Use this to answer questions like " + "'What's on my task list?' or " + "'Read my grocery list'. " "Filters items by status (needs_action, completed, all)." ) @@ -1118,7 +1144,11 @@ class TodoGetItemsTool(Tool): vol.Required("todo_list"): vol.In(todo_lists), vol.Optional( "status", - description="Filter returned items by status, by default returns incomplete items", + description=( + "Filter returned items by status," + " by default returns incomplete" + " items" + ), default="needs_action", ): vol.In(["needs_action", "completed", "all"]), } @@ -1160,6 +1190,26 @@ class TodoGetItemsTool(Tool): return {"success": True, "result": items} +def _live_context_match_error( + match_result: intent.MatchTargetsResult, + name_filter: str | None, + area_filter: str | None, + domain_filter: list[str] | None, +) -> str: + """Build an actionable error message for a failed GetLiveContext match.""" + reason = match_result.no_match_reason + if reason is intent.MatchFailedReason.INVALID_AREA: + return f"Area '{match_result.no_match_name}' does not exist" + if reason is intent.MatchFailedReason.NAME: + return f"No exposed entities matched name '{name_filter}'" + if reason is intent.MatchFailedReason.AREA: + return f"No exposed entities found in area '{area_filter}'" + if reason is intent.MatchFailedReason.DOMAIN: + domains = ", ".join(domain_filter) if domain_filter else "" + return f"No exposed entities found in domain(s): {domains}" + return "No entities matched the provided filter" + + class GetLiveContextTool(Tool): """Tool for getting the current state of exposed entities. @@ -1170,10 +1220,41 @@ class GetLiveContextTool(Tool): name = "GetLiveContext" description = ( - "Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. " + "Provides real-time information about the" + " CURRENT state, value, or mode of devices," + " sensors, entities, or areas. " "Use this tool for: " - "1. Answering questions about current conditions (e.g., 'Is the light on?'). " - "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)." + "1. Answering questions about current" + " conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions" + " (e.g., 'If the weather is rainy, turn off" + " sprinklers' requires checking the weather" + " first). " + "You may filter for devices by name, domain," + " and area, including combining those" + " filters. " + "Prefer filtering by domain when searching" + " for multiple devices of the same type." + ) + parameters = vol.Schema( + { + vol.Optional( + "name", + description="Filter entities by name or alias (case-insensitive).", + ): cv.string, + vol.Optional( + "domain", + description=( + "Filter entities by domain" + " (e.g. 'light', 'sensor')." + " Accepts a single domain or a list." + ), + ): vol.Any(cv.string, [cv.string]), + vol.Optional( + "area", + description="Filter entities by area name or alias (case-insensitive).", + ): cv.string, + } ) async def async_call( @@ -1188,12 +1269,63 @@ class GetLiveContextTool(Tool): # exposed if no assistant is configured. return {"success": False, "error": "No assistant configured"} + args = self.parameters(tool_input.tool_args) exposed_entities = _get_exposed_entities(hass, llm_context.assistant) + if not exposed_entities["entities"]: return {"success": False, "error": NO_ENTITIES_PROMPT} + + name_filter = args.get("name") + area_filter = args.get("area") + domain_filter = args.get("domain") + + if isinstance(domain_filter, str): + domain_filter = [domain_filter] + + if domain_filter is not None: + domain_filter = [ + normalized_domain + for domain in domain_filter + if (normalized_domain := domain.strip().lower()) + ] + + if name_filter or area_filter or domain_filter: + exposed_states = [ + state + for entity_id in exposed_entities["entities"] + if (state := hass.states.get(entity_id)) is not None + ] + match_result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=name_filter, + area_name=area_filter, + domains=domain_filter, + ), + states=exposed_states, + ) + + if not match_result.is_match: + return { + "success": False, + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ), + } + + matched_ids = {state.entity_id for state in match_result.states} + entities = [ + info + for entity_id, info in exposed_entities["entities"].items() + if entity_id in matched_ids + ] + else: + entities = list(exposed_entities["entities"].values()) + prompt = [ - "Live Context: An overview of the areas and the devices in this smart home:", - yaml_util.dump(list(exposed_entities["entities"].values())), + "Live Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(entities), ] return { "success": True, diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index c8b812b73ed..42c251e72d4 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -1,7 +1,5 @@ """Location helpers for Home Assistant.""" -from __future__ import annotations - from collections.abc import Iterable import logging diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 6f4aadaf786..832ed319b67 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -1,10 +1,9 @@ """Network helpers.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from ipaddress import ip_address +import logging from aiohttp import hdrs from hass_nabucasa import remote @@ -12,12 +11,13 @@ import yarl from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass from homeassistant.util.network import is_ip_address, is_loopback, normalize_url from . import http from .hassio import is_hassio +_LOGGER = logging.getLogger(__name__) + TYPE_URL_INTERNAL = "internal_url" TYPE_URL_EXTERNAL = "external_url" SUPERVISOR_NETWORK_HOST = "homeassistant" @@ -27,7 +27,6 @@ class NoURLAvailableError(HomeAssistantError): """An URL to the Home Assistant instance is not available.""" -@bind_hass def is_internal_request(hass: HomeAssistant) -> bool: """Test if the current request is internal.""" try: @@ -39,7 +38,6 @@ def is_internal_request(hass: HomeAssistant) -> bool: return True -@bind_hass def get_supervisor_network_url( hass: HomeAssistant, *, allow_ssl: bool = False ) -> str | None: @@ -114,7 +112,6 @@ def is_hass_url(hass: HomeAssistant, url: str) -> bool: return False -@bind_hass def get_url( hass: HomeAssistant, *, @@ -186,12 +183,21 @@ def get_url( known_hostnames = ["localhost"] if is_hassio(hass): # Local import to avoid circular dependencies - from homeassistant.components.hassio import get_host_info # noqa: PLC0415 + from homeassistant.components.hassio import ( # noqa: PLC0415 + HassioNotReadyError, + get_host_info, + ) - if host_info := get_host_info(hass): + try: + host_info = get_host_info(hass) known_hostnames.extend( [host_info["hostname"], f"{host_info['hostname']}.local"] ) + except HassioNotReadyError: + _LOGGER.debug( + "Could not retrieve Supervisor host information," + " list of known URLs will be incomplete" + ) if ( ( @@ -229,7 +235,6 @@ def _get_request_host() -> str | None: return host -@bind_hass def _get_internal_url( hass: HomeAssistant, *, @@ -267,7 +272,6 @@ def _get_internal_url( raise NoURLAvailableError -@bind_hass def _get_external_url( hass: HomeAssistant, *, @@ -312,7 +316,6 @@ def _get_external_url( raise NoURLAvailableError -@bind_hass def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) -> str: """Get external Home Assistant Cloud URL of this instance.""" if "cloud" in hass.config.components: diff --git a/homeassistant/helpers/normalized_name_base_registry.py b/homeassistant/helpers/normalized_name_base_registry.py index 983d9e55340..c651adbf9b9 100644 --- a/homeassistant/helpers/normalized_name_base_registry.py +++ b/homeassistant/helpers/normalized_name_base_registry.py @@ -52,7 +52,8 @@ class NormalizedNameBaseRegistryItems[_VT: NormalizedNameBaseRegistryEntry]( and normalized_name in self._normalized_names ): raise ValueError( - f"The name {replacement_entry.name} ({normalized_name}) is already in use" + f"The name {replacement_entry.name}" + f" ({normalized_name}) is already in use" ) del self._normalized_names[old_entry.normalized_name] diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index c9b1f21cba7..716a97f7414 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -1,7 +1,5 @@ """Ratelimit helper.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Hashable import logging diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 1698646d6b5..1338fb0f600 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -1,7 +1,5 @@ """Helpers to check recorder.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Generator from contextlib import contextmanager diff --git a/homeassistant/helpers/redact.py b/homeassistant/helpers/redact.py index cc4f53ae70e..617bc399593 100644 --- a/homeassistant/helpers/redact.py +++ b/homeassistant/helpers/redact.py @@ -1,7 +1,5 @@ """Helpers to redact sensitive data.""" -from __future__ import annotations - from collections.abc import Callable, Iterable, Mapping from typing import Any, cast, overload diff --git a/homeassistant/helpers/registry.py b/homeassistant/helpers/registry.py index 1fee41d3293..bc434a02fa3 100644 --- a/homeassistant/helpers/registry.py +++ b/homeassistant/helpers/registry.py @@ -1,7 +1,5 @@ """Provide a base implementation for registries.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections import UserDict, defaultdict from collections.abc import Mapping, Sequence, ValuesView diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 0e33fedb28e..b2a2cc78f6c 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -1,14 +1,12 @@ """Class to reload platforms.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging from typing import Any, Literal, overload from homeassistant import config as conf_util -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration @@ -26,7 +24,9 @@ PLATFORM_RESET_LOCK = "lock_async_reset_platform_{}" async def async_reload_integration_platforms( - hass: HomeAssistant, integration_domain: str, platform_domains: Iterable[str] + hass: HomeAssistant, + integration_domain: str, + platform_domains: Iterable[Platform], ) -> None: """Reload an integration's platforms. @@ -54,7 +54,7 @@ async def async_reload_integration_platforms( async def _resetup_platform( hass: HomeAssistant, integration_domain: str, - platform_domain: str, + platform_domain: Platform, unprocessed_config: ConfigType, ) -> None: """Resetup a platform.""" @@ -109,7 +109,7 @@ async def _resetup_platform( async def _async_setup_platform( hass: HomeAssistant, integration_domain: str, - platform_domain: str, + platform_domain: Platform, platform_configs: list[dict[str, Any]], ) -> None: """Platform for the first time when new configuration is added.""" @@ -175,7 +175,7 @@ async def async_integration_yaml_config( @callback def async_get_platform_without_config_entry( - hass: HomeAssistant, integration_name: str, integration_platform_name: str + hass: HomeAssistant, integration_name: str, integration_platform_name: Platform ) -> EntityPlatform | None: """Find an existing platform that is not a config entry.""" for integration_platform in async_get_platforms(hass, integration_name): @@ -189,7 +189,7 @@ def async_get_platform_without_config_entry( async def async_setup_reload_service( - hass: HomeAssistant, domain: str, platforms: Iterable[str] + hass: HomeAssistant, domain: str, platforms: Iterable[Platform] ) -> None: """Create the reload service for the domain.""" if hass.services.has_service(domain, SERVICE_RELOAD): @@ -204,7 +204,7 @@ async def async_setup_reload_service( def setup_reload_service( - hass: HomeAssistant, domain: str, platforms: Iterable[str] + hass: HomeAssistant, domain: str, platforms: Iterable[Platform] ) -> None: """Sync version of async_setup_reload_service.""" asyncio.run_coroutine_threadsafe( diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 81e9d7ed68e..cca956858c3 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -1,7 +1,5 @@ """Support for restoring entity states on startup.""" -from __future__ import annotations - from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 69cfc8f8450..dea2b44869c 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -1,7 +1,5 @@ """Helpers for creating schema based data entry flows.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable, Container, Coroutine, Mapping import copy @@ -183,25 +181,6 @@ class SchemaCommonFlowHandler: """Handle a form step.""" form_step: SchemaFlowFormStep = cast(SchemaFlowFormStep, self._flow[step_id]) - if ( - user_input is not None - and (data_schema := await self._get_schema(form_step)) - and data_schema.schema - and not self._handler.show_advanced_options - ): - # Add advanced field default if not set - for key in data_schema.schema: - if isinstance(key, (vol.Optional, vol.Required)): - if ( - key.description - and key.description.get("advanced") - and key.default is not vol.UNDEFINED - and key not in self._options - ): - user_input[str(key.schema)] = cast( - Callable[[], Any], key.default - )() - if user_input is not None and form_step.validate_user_input is not None: # Do extra validation of user input try: @@ -212,7 +191,7 @@ class SchemaCommonFlowHandler: if user_input is not None: # User input was validated successfully, update options self._update_and_remove_omitted_optional_keys( - self._options, user_input, data_schema + self._options, user_input, await self._get_schema(form_step) ) if user_input is not None or form_step.schema is None: @@ -232,12 +211,6 @@ class SchemaCommonFlowHandler: if ( isinstance(key, vol.Optional) and key not in user_input - and not ( - # don't remove advanced keys, if they are hidden - key.description - and key.description.get("advanced") - and not self._handler.show_advanced_options - ) and not ( # don't remove read_only keys isinstance(data_schema.schema[key], selector.Selector) @@ -477,7 +450,7 @@ class SchemaOptionsFlowHandler(OptionsFlow): ) if async_setup_preview: - setattr(self, "async_setup_preview", async_setup_preview) + setattr(self, "async_setup_preview", async_setup_preview) # noqa: B010 @property def options(self) -> dict[str, Any]: diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 98f23ecd47e..fe8a721e138 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,7 +1,5 @@ """Helpers to execute scripts.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import asynccontextmanager @@ -92,7 +90,7 @@ from . import ( template, trigger as trigger_helper, ) -from .condition import ConditionCheckerTypeOptional, trace_condition_function +from .condition import ConditionChecker, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template from .script_variables import ScriptRunVariables, ScriptVariables @@ -137,7 +135,7 @@ DEFAULT_MAX_EXCEEDED = "WARNING" ATTR_CUR = "current" ATTR_MAX = "max" -DATA_SCRIPTS: HassKey[list[ScriptData]] = HassKey("helpers.script") +DATA_SCRIPTS: HassKey[dict[int, ScriptData]] = HassKey("helpers.script") DATA_SCRIPT_BREAKPOINTS: HassKey[dict[str, dict[str, set[str]]]] = HassKey( "helpers.script_breakpoints" ) @@ -460,7 +458,7 @@ class _ScriptRun: try: self._log("Running %s", self._script.running_description) - for self._step, self._action in enumerate(self._script.sequence): + for self._step, self._action in enumerate(self._script.sequence): # noqa: B020 if self._stop.done(): script_execution_set("cancelled") break @@ -514,6 +512,7 @@ class _ScriptRun: enabled = enabled.async_render(limited=True) except exceptions.TemplateError as ex: self._handle_exception( + trace_element, ex, continue_on_error, self._log_exceptions or log_exceptions, @@ -531,7 +530,10 @@ class _ScriptRun: await getattr(self, handler)() except Exception as ex: # noqa: BLE001 self._handle_exception( - ex, continue_on_error, self._log_exceptions or log_exceptions + trace_element, + ex, + continue_on_error, + self._log_exceptions or log_exceptions, ) finally: trace_element.update_variables(self._variables.non_parallel_scope) @@ -554,7 +556,11 @@ class _ScriptRun: await self._stopped.wait() def _handle_exception( - self, exception: Exception, continue_on_error: bool, log_exceptions: bool + self, + trace_element: TraceElement, + exception: Exception, + continue_on_error: bool, + log_exceptions: bool, ) -> None: if not isinstance(exception, _HaltScript) and log_exceptions: self._log_exception(exception) @@ -585,6 +591,9 @@ class _ScriptRun: if not isinstance(exception, exceptions.HomeAssistantError): raise exception + # Mark the step as having an error, but continue running the script. + trace_element.set_error(exception) + def _log_exception(self, exception: Exception) -> None: action_type = cv.determine_script_action(self._action) @@ -682,14 +691,12 @@ class _ScriptRun: ### Condition actions ### - async def _async_get_condition( - self, config: ConfigType - ) -> ConditionCheckerTypeOptional: + async def _async_get_condition(self, config: ConfigType) -> ConditionChecker: return await self._script._async_get_condition(config) # noqa: SLF001 def _test_conditions( self, - conditions: list[ConditionCheckerTypeOptional], + conditions: list[ConditionChecker], name: str, condition_path: str | None = None, ) -> bool | None: @@ -704,7 +711,7 @@ class _ScriptRun: with trace_path(condition_path): for idx, cond in enumerate(conditions): with trace_path(str(idx)): - if cond(hass, variables) is False: + if cond.async_check(variables=variables) is False: return False except exceptions.ConditionError as ex: self._log( @@ -755,7 +762,7 @@ class _ScriptRun: trace_element = trace_stack_top(trace_stack_cv) if trace_element: trace_element.reuse_by_child = True - check = cond(self._hass, self._variables) + check = cond.async_check(variables=self._variables) except exceptions.ConditionError as ex: self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING) check = False @@ -882,7 +889,8 @@ class _ScriptRun: if iteration > REPEAT_TERMINATE_ITERATIONS: self._log( - "While condition %s terminated because it looped %s times", + "While condition %s terminated because" + " it looped %s times", repeat[CONF_WHILE], REPEAT_TERMINATE_ITERATIONS, level=logging.CRITICAL, @@ -1008,7 +1016,8 @@ class _ScriptRun: if supports_response == SupportsResponse.NONE and return_response: raise vol.Invalid( f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " - f"'{params[CONF_DOMAIN]}.{params[CONF_SERVICE]}' which does not support response data." + f"'{params[CONF_DOMAIN]}.{params[CONF_SERVICE]}'" + " which does not support response data." ) running_script = ( @@ -1358,7 +1367,9 @@ async def _async_stop_scripts_after_shutdown( """Stop running Script objects started after shutdown.""" hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None running_scripts = [ - script for script in hass.data[DATA_SCRIPTS] if script["instance"].is_running + script + for script in hass.data[DATA_SCRIPTS].values() + if script["instance"].is_running ] if running_scripts: names = ", ".join([script["instance"].name for script in running_scripts]) @@ -1377,7 +1388,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> running_scripts = [ script - for script in hass.data[DATA_SCRIPTS] + for script in hass.data[DATA_SCRIPTS].values() if script["instance"].is_running and script["started_before_shutdown"] ] if running_scripts: @@ -1413,12 +1424,12 @@ def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: class _ChooseData(TypedDict): - choices: list[tuple[list[ConditionCheckerTypeOptional], Script]] + choices: list[tuple[list[ConditionChecker], Script]] default: Script | None class _IfData(TypedDict): - if_conditions: list[ConditionCheckerTypeOptional] + if_conditions: list[ConditionChecker] if_then: Script if_else: Script | None @@ -1458,16 +1469,17 @@ class Script: enabled attribute is only used for non-top-level scripts. """ - if not (all_scripts := hass.data.get(DATA_SCRIPTS)): - all_scripts = hass.data[DATA_SCRIPTS] = [] + if (all_scripts := hass.data.get(DATA_SCRIPTS)) is None: + all_scripts = hass.data[DATA_SCRIPTS] = {} hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, partial(_async_stop_scripts_at_shutdown, hass) ) self.top_level = top_level if top_level: - all_scripts.append( - {"instance": self, "started_before_shutdown": not hass.is_stopping} - ) + all_scripts[id(self)] = { + "instance": self, + "started_before_shutdown": not hass.is_stopping, + } if DATA_SCRIPT_BREAKPOINTS not in hass.data: hass.data[DATA_SCRIPT_BREAKPOINTS] = {} @@ -1495,16 +1507,24 @@ class Script: self._max_exceeded = max_exceeded if script_mode == SCRIPT_MODE_QUEUED: self._queue_lck = asyncio.Lock() - self._config_cache: dict[ - frozenset[tuple[str, str]], ConditionCheckerTypeOptional - ] = {} + self._condition_cache: dict[frozenset[tuple[str, str]], ConditionChecker] = {} self._repeat_script: dict[int, Script] = {} self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} + self._unloaded = False self.variables = variables + def __del__(self) -> None: + """Clean up when the script is deleted.""" + if self._unloaded: + return + try: + self._async_unload() + except Exception: + _LOGGER.exception("Error while unloading script") + @property def change_listener(self) -> Callable[..., Any] | None: """Return the change_listener.""" @@ -1769,17 +1789,23 @@ class Script: started_action: Callable[..., Any] | None = None, ) -> ScriptRunResult | None: """Run script.""" + if self._unloaded: + raise RuntimeError( + f"Cannot run script '{self.name}' after it has been unloaded" + ) + if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data: + self._log("Home Assistant is shutting down, starting script blocked") + return None + # The fences above rely on there being no await between these checks + # and the _runs.append below, so that setting either flag is + # sufficient to block new runs from being added. + if context is None: self._log( "Running script requires passing in a context", level=logging.WARNING ) context = Context() - # Prevent spawning new script runs when Home Assistant is shutting down - if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data: - self._log("Home Assistant is shutting down, starting script blocked") - return None - # Prevent spawning new script runs if not allowed by script mode if self.is_running: if self.script_mode == SCRIPT_MODE_SINGLE: @@ -1813,7 +1839,8 @@ class Script: variables = ScriptRunVariables.create_top_level(run_variables) variables["context"] = context else: - # This is not the top level script, run_variables is an instance of ScriptRunVariables + # This is not the top level script, run_variables + # is an instance of ScriptRunVariables variables = cast(ScriptRunVariables, run_variables) # Prevent non-allowed recursive calls which will cause deadlocks when we try to @@ -1889,13 +1916,73 @@ class Script: return await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) - async def _async_get_condition( - self, config: ConfigType - ) -> ConditionCheckerTypeOptional: + async def async_unload(self) -> None: + """Unload the script, stopping any in-flight runs first. + + Blocks new runs immediately, stops any in-flight runs, then cleans + up all resources. + """ + if self._unloaded: + return + # Set the flag before stopping so async_run rejects new runs. + self._unloaded = True + await self.async_stop() + self._async_unload() + + def _async_unload(self) -> None: + """Unload the script, cleaning up all resources. + + Unloads cached conditions, and recursively unloads sub-scripts. + The script must not be running when this is called; sub-scripts + are guaranteed to not be running if the parent is not running. + """ + if self._runs: + raise RuntimeError( + f"Cannot unload script '{self.name}' while it is running" + ) + self._unloaded = True + + # Remove from global script registry + if self.top_level: + del self._hass.data[DATA_SCRIPTS][id(self)] + + for cond in self._condition_cache.values(): + cond.async_unload() + self._condition_cache.clear() + + for sub_script in self._repeat_script.values(): + sub_script._async_unload() # noqa: SLF001 + self._repeat_script.clear() + + # Conditions in _choose_data and _if_data are the same objects as in + # _condition_cache, so they're already unloaded above. Only unload scripts. + for choose_data in self._choose_data.values(): + for _conditions, sub_script in choose_data["choices"]: + sub_script._async_unload() # noqa: SLF001 + if choose_data["default"] is not None: + choose_data["default"]._async_unload() # noqa: SLF001 + self._choose_data.clear() + + for if_data in self._if_data.values(): + if_data["if_then"]._async_unload() # noqa: SLF001 + if if_data["if_else"] is not None: + if_data["if_else"]._async_unload() # noqa: SLF001 + self._if_data.clear() + + for scripts in self._parallel_scripts.values(): + for sub_script in scripts: + sub_script._async_unload() # noqa: SLF001 + self._parallel_scripts.clear() + + for sub_script in self._sequence_scripts.values(): + sub_script._async_unload() # noqa: SLF001 + self._sequence_scripts.clear() + + async def _async_get_condition(self, config: ConfigType) -> ConditionChecker: config_cache_key = frozenset((k, str(v)) for k, v in config.items()) - if not (cond := self._config_cache.get(config_cache_key)): + if not (cond := self._condition_cache.get(config_cache_key)): cond = await condition.async_from_config(self._hass, config) - self._config_cache[config_cache_key] = cond + self._condition_cache[config_cache_key] = cond return cond def _prep_repeat_script(self, step: int) -> Script: diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 54200e094e6..88f1809b8fa 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -1,7 +1,5 @@ """Script variables.""" -from __future__ import annotations - from collections import ChainMap, UserDict from collections.abc import Mapping from dataclasses import dataclass, field @@ -93,15 +91,19 @@ class ScriptVariables: class _ParallelData: """Data used in each parallel sequence.""" - # `protected` is for variables that need special protection in parallel sequences. - # What this means is that such a variable defined in one parallel sequence will not be - # clobbered by the variable with the same name assigned in another parallel sequence. - # It also means that such a variable will not be visible in the outer scope. + # `protected` is for variables that need special protection + # in parallel sequences. What this means is that such a + # variable defined in one parallel sequence will not be + # clobbered by the variable with the same name assigned in + # another parallel sequence. It also means that such a + # variable will not be visible in the outer scope. # Currently the only such variable is `wait`. protected: dict[str, Any] = field(default_factory=dict) - # `outer_scope_writes` is for variables that are written to the outer scope from - # a parallel sequence. This is used for generating correct traces of changed variables - # for each of the parallel sequences, isolating them from one another. + # `outer_scope_writes` is for variables that are written + # to the outer scope from a parallel sequence. This is used + # for generating correct traces of changed variables for + # each of the parallel sequences, isolating them from one + # another. outer_scope_writes: dict[str, Any] = field(default_factory=dict) @@ -109,11 +111,14 @@ class _ParallelData: class ScriptRunVariables(UserDict[str, Any]): """Class to hold script run variables. - The purpose of this class is to provide proper variable scoping semantics for scripts. - Each instance institutes a new local scope, in which variables can be defined. - Each instance has a reference to the previous instance, except for the top-level instance. - The instances therefore form a chain, in which variable lookup and assignment is performed. - The variables defined lower in the chain naturally override those defined higher up. + The purpose of this class is to provide proper variable + scoping semantics for scripts. Each instance institutes a + new local scope, in which variables can be defined. Each + instance has a reference to the previous instance, except + for the top-level instance. The instances therefore form a + chain, in which variable lookup and assignment is performed. + The variables defined lower in the chain naturally override + those defined higher up. """ # _previous is the previous ScriptRunVariables in the chain @@ -126,7 +131,8 @@ class ScriptRunVariables(UserDict[str, Any]): # _parallel_data is used for each parallel sequence _parallel_data: _ParallelData | None = None - # _non_parallel_scope includes all scopes all the way to the most recent parallel split + # _non_parallel_scope includes all scopes all the way to + # the most recent parallel split _non_parallel_scope: ChainMap[str, Any] # _full_scope includes all scopes (all the way to the top-level) _full_scope: ChainMap[str, Any] @@ -204,10 +210,12 @@ class ScriptRunVariables(UserDict[str, Any]): def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None: """Assign value to a variable. - Value is always assigned to the variable in the nearest scope, in which it is defined. - If the variable is not defined at all, it is created in the top-level scope. + Value is always assigned to the variable in the nearest + scope, in which it is defined. If the variable is not + defined at all, it is created in the top-level scope. - :param parallel_protected: Whether variable is to be protected in parallel sequences. + :param parallel_protected: Whether variable is to be + protected in parallel sequences. """ if self._local_data is not None and key in self._local_data: self._local_data[key] = value diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 3194de03dc5..6587447e31e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1,7 +1,5 @@ """Selectors for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable, Mapping, Sequence from copy import deepcopy from enum import StrEnum @@ -57,9 +55,12 @@ class Selector[_T: Mapping[str, Any]]: CONFIG_SCHEMA: Callable config: _T selector_type: str - # Context keys that are allowed to be used in the selector, with list of allowed selector types. - # Selectors can use the value of other fields in the same schema as context for filtering for example. - # The selector defines which context keys it supports and what selector types are allowed for each key. + # Context keys that are allowed to be used in the + # selector, with list of allowed selector types. Selectors + # can use the value of other fields in the same schema as + # context for filtering for example. The selector defines + # which context keys it supports and what selector types + # are allowed for each key. allowed_context_keys: dict[str, set[str]] = {} def __init__(self, config: Mapping[str, Any] | None = None) -> None: @@ -300,7 +301,11 @@ AddonSelectorConfig = AppSelectorConfig @SELECTORS.register("addon") class AddonSelector(Selector[AddonSelectorConfig]): - """Selector of an add-on, kept for backward compatibility after add-ons -> apps rename.""" + """Selector of an add-on. + + Kept for backward compatibility after add-ons -> apps + rename. + """ selector_type = "addon" @@ -422,6 +427,69 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute +class AutomationBehavior(StrEnum): + """Possible behaviors for an automation behavior selector.""" + + ALL = "all" + FIRST = "first" + EACH = "each" + ANY = "any" + + +class AutomationBehaviorSelectorMode(StrEnum): + """Possible modes for an automation behavior selector.""" + + TRIGGER = "trigger" + CONDITION = "condition" + + +_AUTOMATION_BEHAVIOR_MODES: dict[AutomationBehaviorSelectorMode, list[str]] = { + AutomationBehaviorSelectorMode.TRIGGER: [ + AutomationBehavior.FIRST, + AutomationBehavior.ALL, + AutomationBehavior.EACH, + ], + AutomationBehaviorSelectorMode.CONDITION: [ + AutomationBehavior.ALL, + AutomationBehavior.ANY, + ], +} + + +class AutomationBehaviorConfig(BaseSelectorConfig, total=False): + """Class to represent an automation behavior selector config.""" + + mode: Required[AutomationBehaviorSelectorMode] + translation_key: str + + +@SELECTORS.register("automation_behavior") +class AutomationBehaviorSelector(Selector[AutomationBehaviorConfig]): + """Selector of an automation behavior.""" + + selector_type = "automation_behavior" + + CONFIG_SCHEMA = make_selector_config_schema( + { + vol.Required("mode"): vol.All( + vol.Coerce(AutomationBehaviorSelectorMode), lambda val: val.value + ), + vol.Optional("translation_key"): cv.string, + }, + ) + + def __init__(self, config: AutomationBehaviorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + if not isinstance(data, str): + raise vol.Invalid("Value should be a string") + mode = AutomationBehaviorSelectorMode(self.config["mode"]) + return vol.In(_AUTOMATION_BEHAVIOR_MODES[mode])(data) + + class BackupLocationSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a backup location selector config.""" @@ -1637,7 +1705,11 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): if field_data.get("required") and field not in _config: raise vol.Invalid(f"Field {field} is required") if field in _config: - selector(field_data["selector"])(_config[field]) # type: ignore[operator] + field_selector = field_data["selector"] + if isinstance(field_selector, Selector): + field_selector(_config[field]) # type: ignore[operator] + else: + selector(field_selector)(_config[field]) # type: ignore[operator] for key in _config: if key not in self.config["fields"]: @@ -1771,6 +1843,34 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class SerialPortSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a serial port selector config.""" + + extra_recommended_domains: list[str] + + +@SELECTORS.register("serial_port") +class SerialPortSelector(Selector[SerialPortSelectorConfig]): + """Selector for a serial port.""" + + selector_type = "serial_port" + + CONFIG_SCHEMA = make_selector_config_schema( + { + vol.Optional("extra_recommended_domains"): [str], + } + ) + + def __init__(self, config: SerialPortSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + serial: str = vol.Schema(str)(data) + return serial + + class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" @@ -1856,6 +1956,7 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] + primary_entities_only: bool @SELECTORS.register("target") @@ -1877,6 +1978,7 @@ class TargetSelector(Selector[TargetSelectorConfig]): cv.ensure_list, [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), + vol.Optional("primary_entities_only"): cv.boolean, } ) diff --git a/homeassistant/helpers/sensor.py b/homeassistant/helpers/sensor.py index 3cccfb661ee..72da4880afe 100644 --- a/homeassistant/helpers/sensor.py +++ b/homeassistant/helpers/sensor.py @@ -1,7 +1,5 @@ """Common functions related to sensor device management.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant import const diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index d7484f214fb..4cc7a96a19c 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,9 +1,7 @@ """Service calling related helpers.""" -from __future__ import annotations - import asyncio -from collections.abc import Callable, Coroutine, Iterable, Mapping +from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence import dataclasses from enum import Enum from functools import cache, partial @@ -48,7 +46,7 @@ from homeassistant.exceptions import ( Unauthorized, UnknownUser, ) -from homeassistant.loader import Integration, async_get_integrations, bind_hass +from homeassistant.loader import Integration, async_get_integrations from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict @@ -252,7 +250,6 @@ class SelectedEntities(target_helpers.SelectedEntities): super().log_missing(missing_entities, logger or _LOGGER) -@bind_hass def call_from_config( hass: HomeAssistant, config: ConfigType, @@ -267,7 +264,6 @@ def call_from_config( ).result() -@bind_hass async def async_call_from_config( hass: HomeAssistant, config: ConfigType, @@ -290,7 +286,6 @@ async def async_call_from_config( @callback -@bind_hass def async_prepare_call_from_config( hass: HomeAssistant, config: ConfigType, @@ -452,7 +447,6 @@ async def async_extract_entity_ids( "homeassistant.helpers.target.async_extract_referenced_entity_ids", breaks_in_ha_version="2026.8", ) -@bind_hass def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: @@ -532,7 +526,6 @@ def async_get_cached_service_description( return hass.data.get(SERVICE_DESCRIPTION_CACHE, {}).get((domain, service)) -@bind_hass async def async_get_all_descriptions( hass: HomeAssistant, ) -> dict[str, dict[str, Any]]: @@ -647,7 +640,6 @@ def remove_entity_service_fields(call: ServiceCall) -> dict[Any, Any]: @callback -@bind_hass def async_set_service_schema( hass: HomeAssistant, domain: str, service: str, schema: dict[str, Any] ) -> None: @@ -679,7 +671,7 @@ def async_set_service_schema( def _get_permissible_entity_candidates( call: ServiceCall, - entities: dict[str, Entity], + entities: Mapping[str, Entity], entity_perms: Callable[[str, str], bool] | None, target_all_entities: bool, all_referenced: set[str] | None, @@ -724,22 +716,15 @@ def _get_permissible_entity_candidates( return [entities[entity_id] for entity_id in all_referenced.intersection(entities)] -@bind_hass -async def entity_service_call( +async def _resolve_entity_service_call_entities( hass: HomeAssistant, - registered_entities: dict[str, Entity] | Callable[[], dict[str, Entity]], - func: str | HassJob, + registered_entities: Mapping[str, Entity] | Callable[[], Mapping[str, Entity]], call: ServiceCall, required_features: Iterable[int] | None = None, - *, entity_device_classes: Iterable[str | None] | None = None, -) -> EntityServiceResponse | None: - """Handle an entity service call. - - Calls all platforms simultaneously. - """ +) -> list[Entity] | None: + """Resolve and filter entities for an entity service call.""" entity_perms: Callable[[str, str], bool] | None = None - return_response = call.return_response if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) @@ -761,13 +746,6 @@ async def entity_service_call( ) all_referenced = referenced.referenced | referenced.indirectly_referenced - # If the service function is a string, we'll pass it the service call data - if isinstance(func, str): - data: dict | ServiceCall = remove_entity_service_fields(call) - # If the service function is not a string, we pass the service call - else: - data = call - if callable(registered_entities): _registered_entities = registered_entities() else: @@ -822,73 +800,96 @@ async def entity_service_call( entities.append(entity) if not entities: - if return_response: + if call.return_response: raise HomeAssistantError( "Service call requested response data but did not match any entities" ) return None - if len(entities) == 1: - # Single entity case avoids creating task - entity = entities[0] - single_response = await _handle_entity_call( - hass, entity, func, data, call.context - ) - if entity.should_poll: - # Context expires if the turn on commands took a long time. - # Set context again so it's there when we update - entity.async_set_context(call.context) - await entity.async_update_ha_state(True) - return {entity.entity_id: single_response} if return_response else None + return entities - # Use asyncio.gather here to ensure the returned results - # are in the same order as the entities list + +async def _async_handle_entity_calls( + entity_calls: list[tuple[Entity, Coroutine[Any, Any, ServiceResponse]]], + *, + context: Context, +) -> EntityServiceResponse: + """Handle calls for entities.""" + + async def _with_context( + entity: Entity, coro: Coroutine[Any, Any, ServiceResponse] + ) -> ServiceResponse: + entity.async_set_context(context) + return await coro + + if len(entity_calls) == 1: + # Single entity case avoids creating task + entity, coro = entity_calls[0] + single_result = await entity.async_request_call(_with_context(entity, coro)) + if entity.should_poll: + # Context can expire, so set it again before we update + entity.async_set_context(context) + await entity.async_update_ha_state(True) + return {entity.entity_id: single_result} + + entities = [entity for entity, _ in entity_calls] results: list[ServiceResponse | BaseException] = await asyncio.gather( *[ - entity.async_request_call( - _handle_entity_call(hass, entity, func, data, call.context) - ) - for entity in entities + entity.async_request_call(_with_context(entity, coro)) + for entity, coro in entity_calls ], return_exceptions=True, ) response_data: EntityServiceResponse = {} - for entity, result in zip(entities, results, strict=False): + for entity, result in zip(entities, results, strict=True): if isinstance(result, BaseException): raise result from None response_data[entity.entity_id] = result tasks: list[asyncio.Task[None]] = [] - for entity in entities: if not entity.should_poll: continue - - # Context expires if the turn on commands took a long time. - # Set context again so it's there when we update - entity.async_set_context(call.context) + # Context can expire, so set it again before we update + entity.async_set_context(context) tasks.append(create_eager_task(entity.async_update_ha_state(True))) if tasks: done, pending = await asyncio.wait(tasks) assert not pending for future in done: - future.result() # pop exception if have + future.result() - return response_data if return_response and response_data else None + return response_data -async def _handle_entity_call( +async def async_handle_entity_calls( + func: str, + entity_data: Sequence[tuple[Entity, dict[str, Any]]], + *, + context: Context, +) -> EntityServiceResponse: + """Handle calls for multiple entities.""" + return await _async_handle_entity_calls( + [ + ( + entity, + getattr(entity, func)(**data), + ) + for entity, data in entity_data + ], + context=context, + ) + + +async def _handle_single_entity_call( hass: HomeAssistant, entity: Entity, func: str | HassJob, data: dict | ServiceCall, - context: Context, ) -> ServiceResponse: """Handle calling service method.""" - entity.async_set_context(context) - task: asyncio.Future[ServiceResponse] | None if isinstance(func, str): job = HassJob( @@ -919,6 +920,80 @@ async def _handle_entity_call( return result +async def entity_service_call( + hass: HomeAssistant, + registered_entities: Mapping[str, Entity] | Callable[[], Mapping[str, Entity]], + func: str | HassJob, + call: ServiceCall, + required_features: Iterable[int] | None = None, + *, + entity_device_classes: Iterable[str | None] | None = None, +) -> EntityServiceResponse | None: + """Handle an entity service call. + + Calls all platforms simultaneously. + """ + entities = await _resolve_entity_service_call_entities( + hass, registered_entities, call, required_features, entity_device_classes + ) + if entities is None: + return None + + # If the service function is a string, we'll pass it the service call data + if isinstance(func, str): + data: dict | ServiceCall = remove_entity_service_fields(call) + # If the service function is not a string, we pass the service call + else: + data = call + + response_data = await _async_handle_entity_calls( + [ + (entity, _handle_single_entity_call(hass, entity, func, data)) + for entity in entities + ], + context=call.context, + ) + + return response_data if call.return_response else None + + +async def batched_entity_service_call( + hass: HomeAssistant, + registered_entities: Mapping[str, Entity] | Callable[[], Mapping[str, Entity]], + func: Callable[ + [list[Entity], ServiceCall], + Coroutine[Any, Any, EntityServiceResponse | None], + ], + call: ServiceCall, + required_features: Iterable[int] | None = None, +) -> EntityServiceResponse | None: + """Handle a batched entity service call. + + Calls the service function once with all matching entities as a list, + instead of once per entity. + """ + entities = await _resolve_entity_service_call_entities( + hass, registered_entities, call, required_features + ) + if entities is None: + return None + + return_response = call.return_response + + # Create a new ServiceCall without entity service fields. + call = ServiceCall( + hass, + call.domain, + call.service, + remove_entity_service_fields(call), + context=call.context, + return_response=return_response, + ) + result = await func(entities, call) + + return result if return_response else None + + async def _async_admin_handler( hass: HomeAssistant, service_job: HassJob[ @@ -944,7 +1019,6 @@ async def _async_admin_handler( return None -@bind_hass @callback def async_register_admin_service( hass: HomeAssistant, @@ -1110,7 +1184,8 @@ def _validate_entity_service_schema( return cv.make_entity_service_schema(schema) if not cv.is_entity_service_schema(schema): raise HomeAssistantError( - f"The {service} service registers an entity service with a non entity service schema" + f"The {service} service registers an entity service" + " with a non entity service schema" ) return schema @@ -1123,7 +1198,7 @@ def async_register_entity_service( *, description_placeholders: Mapping[str, str] | None = None, entity_device_classes: Iterable[str | None] | None = None, - entities: dict[str, Entity], + entities: Mapping[str, Entity], func: str | Callable[..., Any], job_type: HassJobType | None, required_features: Iterable[int] | None = None, @@ -1159,6 +1234,65 @@ def async_register_entity_service( ) +@callback +def async_register_batched_entity_service[_EntityT: Entity]( + hass: HomeAssistant, + domain: str, + name: str, + *, + description_placeholders: Mapping[str, str] | None = None, + entities: dict[str, _EntityT], + func: Callable[ + [list[_EntityT], ServiceCall], + Coroutine[Any, Any, EntityServiceResponse | None], + ], + required_features: Iterable[int] | None = None, + schema: VolDictType | VolSchemaType | None, + supports_response: SupportsResponse = SupportsResponse.NONE, +) -> None: + """Help registering a batched entity service. + + This is called by EntityComponent.async_register_batched_entity_service + and should not be called directly by integrations. + + A batched entity service calls the service function once with all + matching entities as a list, instead of once per entity. + """ + schema = _validate_entity_service_schema(schema, f"{domain}.{name}") + + hass.services.async_register( + domain, + name, + partial( + batched_entity_service_call, + hass, + entities, + func, # type: ignore[arg-type] + required_features=required_features, + ), + schema, + supports_response, + job_type=HassJobType.Coroutinefunction, + description_placeholders=description_placeholders, + ) + + +def _get_platform_entities( + hass: HomeAssistant, + entity_domain: str, + service_domain: str, +) -> dict[str, Entity]: + """Get platform entities for a service domain.""" + from .entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES # noqa: PLC0415 + + entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get( + (entity_domain, service_domain) + ) + if entities is None: + return {} + return entities + + @callback def async_register_platform_entity_service( hass: HomeAssistant, @@ -1174,28 +1308,18 @@ def async_register_platform_entity_service( supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Help registering a platform entity service.""" - from .entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES # noqa: PLC0415 - schema = _validate_entity_service_schema(schema, f"{service_domain}.{service_name}") service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) - def get_entities() -> dict[str, Entity]: - entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get( - (entity_domain, service_domain) - ) - if entities is None: - return {} - return entities - hass.services.async_register( service_domain, service_name, partial( entity_service_call, hass, - get_entities, + partial(_get_platform_entities, hass, entity_domain, service_domain), service_func, entity_device_classes=entity_device_classes, required_features=required_features, @@ -1207,6 +1331,46 @@ def async_register_platform_entity_service( ) +@callback +def async_register_batched_platform_entity_service[_EntityT: Entity]( + hass: HomeAssistant, + service_domain: str, + service_name: str, + *, + description_placeholders: Mapping[str, str] | None = None, + entity_domain: str, + func: Callable[ + [list[_EntityT], ServiceCall], + Coroutine[Any, Any, EntityServiceResponse | None], + ], + required_features: Iterable[int] | None = None, + schema: VolDictType | VolSchemaType | None, + supports_response: SupportsResponse = SupportsResponse.NONE, +) -> None: + """Help registering a batched platform entity service. + + A batched entity service calls the service function once with all + matching entities as a list, instead of once per entity. + """ + schema = _validate_entity_service_schema(schema, f"{service_domain}.{service_name}") + + hass.services.async_register( + service_domain, + service_name, + partial( + batched_entity_service_call, + hass, + partial(_get_platform_entities, hass, entity_domain, service_domain), + func, # type: ignore[arg-type] + required_features=required_features, + ), + schema, + supports_response, + job_type=HassJobType.Coroutinefunction, + description_placeholders=description_placeholders, + ) + + @callback def async_get_config_entry( hass: HomeAssistant, domain: str, entry_id: str diff --git a/homeassistant/helpers/service_info/esphome.py b/homeassistant/helpers/service_info/esphome.py index 5a9d50baaec..9544090cd8d 100644 --- a/homeassistant/helpers/service_info/esphome.py +++ b/homeassistant/helpers/service_info/esphome.py @@ -22,5 +22,5 @@ class ESPHomeServiceInfo(BaseServiceInfo): """Return the socket path to connect to the ESPHome device.""" url = URL.build(scheme="esphome", host=self.ip_address, port=self.port) if self.noise_psk: - url = url.with_user(self.noise_psk) + url = url.with_query({"key": self.noise_psk}) return str(url) diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 4a4b9bead47..6fd2a384c0e 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -6,7 +6,6 @@ import signal from homeassistant.const import RESTART_EXIT_CODE from homeassistant.core import HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) @@ -15,7 +14,6 @@ KEY_HA_STOP: HassKey[asyncio.Task[None]] = HassKey("homeassistant_stop") @callback -@bind_hass def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 632b42c735b..a0c63bb089c 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -27,8 +27,6 @@ The following cases will never be passed to your function: - state adding/removing """ -from __future__ import annotations - from collections.abc import Callable, Mapping import math from types import MappingProxyType diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index dac2e5832f6..64db32e15e7 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -1,7 +1,5 @@ """Helper to help coordinating calls.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import functools @@ -9,7 +7,6 @@ import inspect from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey type _FuncType[_T] = Callable[[HomeAssistant], _T] @@ -51,7 +48,6 @@ def singleton[_S, _T, _U]( if not inspect.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) - @bind_hass @functools.wraps(func) def wrapped(hass: HomeAssistant) -> _U: if data_key not in hass.data: @@ -60,7 +56,6 @@ def singleton[_S, _T, _U]( return wrapped - @bind_hass @functools.wraps(func) async def async_wrapped(hass: HomeAssistant) -> _T: if data_key not in hass.data: diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index 099060e49ca..a80dd48a76e 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -1,7 +1,5 @@ """Helpers to help during startup.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from typing import Any diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 70f64d5296a..ba2bcacade3 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -1,7 +1,5 @@ """Helpers that help with state related things.""" -from __future__ import annotations - import asyncio from collections import defaultdict from collections.abc import Iterable @@ -21,12 +19,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, State -from homeassistant.loader import IntegrationNotFound, async_get_integration, bind_hass +from homeassistant.loader import IntegrationNotFound, async_get_integration _LOGGER = logging.getLogger(__name__) -@bind_hass async def async_reproduce_state( hass: HomeAssistant, states: State | Iterable[State], diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index d651f6c36c4..d57a06b7409 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -1,7 +1,5 @@ """Helper to help store data.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Iterable, Mapping, Sequence from contextlib import suppress @@ -29,7 +27,6 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError -from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file_atomic from homeassistant.util.hass_dict import HassKey @@ -49,7 +46,6 @@ STORAGE_MANAGER: HassKey[_StoreManager] = HassKey("storage_manager") MANAGER_CLEANUP_DELAY = 60 -@bind_hass async def async_migrator[_T: Mapping[str, Any] | Sequence[Any]]( hass: HomeAssistant, old_path: str, @@ -226,7 +222,6 @@ class _StoreManager: self._files = set(os.listdir(self._storage_path)) -@bind_hass class Store[_T: Mapping[str, Any] | Sequence[Any]]: """Class to help storing data.""" @@ -374,7 +369,8 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]: except HomeAssistantError as err: if isinstance(err.__cause__, JSONDecodeError): # If we have a JSONDecodeError, it means the file is corrupt. - # We can't recover from this, so we'll log an error, rename the file and + # We can't recover from this, so we'll log + # an error, rename the file and # return None so that we can start with a clean slate which will # allow startup to continue so they can restore from a backup. isotime = dt_util.utcnow().isoformat() diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 1c35f45d713..00dc53900d5 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -1,14 +1,11 @@ """Helpers for sun events.""" -from __future__ import annotations - from collections.abc import Callable import datetime from typing import TYPE_CHECKING, Any, cast from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant, callback -from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey @@ -26,7 +23,6 @@ type _AstralSunEventCallable = Callable[..., datetime.datetime] @callback -@bind_hass def get_astral_location( hass: HomeAssistant, ) -> tuple[astral.location.Location, astral.Elevation]: @@ -51,7 +47,6 @@ def get_astral_location( @callback -@bind_hass def get_astral_event_next( hass: HomeAssistant, event: str, @@ -109,7 +104,6 @@ def get_location_astral_event_next( @callback -@bind_hass def get_astral_event_date( hass: HomeAssistant, event: str, @@ -136,7 +130,6 @@ def get_astral_event_date( @callback -@bind_hass def is_up( hass: HomeAssistant, utc_point_in_time: datetime.datetime | None = None ) -> bool: diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 20da2ec6d65..4d161ae921f 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,7 +1,5 @@ """Helper to gather system info.""" -from __future__ import annotations - from functools import cache from getpass import getuser import logging @@ -10,7 +8,6 @@ from typing import Any from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from homeassistant.util.package import is_docker_env, is_virtual_env from homeassistant.util.system_info import is_official_image @@ -50,23 +47,22 @@ async def async_get_container_arch(hass: HomeAssistant) -> str: cached_get_user = cache(getuser) -@bind_hass async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: """Return info about the system.""" is_hassio_ = is_hassio(hass) info_object = { - "installation_type": "Unknown", - "version": current_version, - "dev": "dev" in current_version, - "hassio": is_hassio_, - "virtualenv": is_virtual_env(), - "python_version": platform.python_version(), - "docker": False, "arch": platform.machine(), - "timezone": str(hass.config.time_zone), + "dev": "dev" in current_version, + "docker": False, + "hassio": is_hassio_, + "installation_type": "Unknown", "os_name": platform.system(), "os_version": platform.release(), + "python_version": platform.python_version(), + "timezone": str(hass.config.time_zone), + "version": current_version, + "virtualenv": is_virtual_env(), } try: @@ -98,11 +94,17 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # Local import to avoid circular dependencies from homeassistant.components import hassio # noqa: PLC0415 - if not (info := hassio.get_info(hass)): + try: + info = hassio.get_info(hass) + except hassio.HassioNotReadyError: _LOGGER.warning("No Home Assistant Supervisor info available") info = {} - host = hassio.get_host_info(hass) or {} + try: + host = hassio.get_host_info(hass) + except hassio.HassioNotReadyError: + _LOGGER.warning("No Home Assistant Supervisor host info available") + host = {} info_object["supervisor"] = info.get("supervisor") info_object["host_os"] = host.get("operating_system") info_object["docker_version"] = info.get("docker") diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 334b7147e01..1d716a281a1 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -1,7 +1,5 @@ """Helpers for dealing with entity targets.""" -from __future__ import annotations - import abc from collections.abc import Callable import dataclasses @@ -147,9 +145,22 @@ class SelectedEntities: def async_extract_referenced_entity_ids( - hass: HomeAssistant, target_selection: TargetSelection, expand_group: bool = True + hass: HomeAssistant, + target_selection: TargetSelection, + expand_group: bool = True, + *, + primary_entities_only: bool = True, ) -> SelectedEntities: - """Extract referenced entity IDs from a target selection.""" + """Extract referenced entity IDs from a target selection. + + When `primary_entities_only` is True (the default), entities with an + `entity_category` (i.e. config or diagnostic entities) are excluded from + indirect expansion via device, area, and floor. When False, those entities + are included. Direct label-to-entity expansion is unaffected by this flag. + Label targeting via labeled devices or areas follows the same filtering + rules as other indirect device/area expansion paths: filtered when + `primary_entities_only` is True, and included when it is False. + """ selected = SelectedEntities() if not target_selection.has_any_target: @@ -217,14 +228,18 @@ def async_extract_referenced_entity_ids( if not selected.referenced_areas and not selected.referenced_devices: return selected + def _include_entry(entry: er.RegistryEntry) -> bool: + """Return True if the entry should be included in indirect expansion.""" + if entry.hidden_by is not None: + return False + return not primary_entities_only or entry.entity_category is None + # Add indirectly referenced by device selected.indirectly_referenced.update( entry.entity_id for device_id in selected.referenced_devices for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if (entry.entity_category is None and entry.hidden_by is None) + if _include_entry(entry) ) # Find devices for targeted areas @@ -243,27 +258,16 @@ def async_extract_referenced_entity_ids( for area_id in selected.referenced_areas # The entity's area matches a targeted area for entry in entities.get_entries_for_area_id(area_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if entry.entity_category is None and entry.hidden_by is None + if _include_entry(entry) ) # Add indirectly referenced by area through device selected.indirectly_referenced.update( entry.entity_id for device_id in referenced_devices_by_area for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if ( - entry.entity_category is None - and entry.hidden_by is None - and ( - # The entity's device matches a device referenced - # by an area and the entity - # has no explicitly set area - not entry.area_id - ) - ) + # The entity's device matches a device referenced by an area and the + # entity has no explicitly set area. + if _include_entry(entry) and not entry.area_id ) return selected @@ -277,11 +281,14 @@ class TargetEntityChangeTracker(abc.ABC): hass: HomeAssistant, target_selection: TargetSelection, entity_filter: Callable[[set[str]], set[str]], + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" self._hass = hass self._target_selection = target_selection self._entity_filter = entity_filter + self._primary_entities_only = primary_entities_only self._registry_unsubs: list[CALLBACK_TYPE] = [] @@ -294,13 +301,16 @@ class TargetEntityChangeTracker(abc.ABC): @abc.abstractmethod @callback def _handle_entities_update(self, tracked_entities: set[str]) -> None: - """Called when there's an update to the list of entities of the tracked targets.""" + """Called when there's an update to tracked target entities.""" @callback def _handle_target_update(self, event: Event[Any] | None = None) -> None: """Handle updates in the tracked targets.""" selected = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False + self._hass, + self._target_selection, + expand_group=False, + primary_entities_only=self._primary_entities_only, ) filtered_entities = self._entity_filter( selected.referenced | selected.indirectly_referenced @@ -311,7 +321,8 @@ class TargetEntityChangeTracker(abc.ABC): """Set up listeners for registry changes that require resubscription.""" # Subscribe to registry updates that can change the entities to track: - # - Entity registry: entity added/removed; entity labels changed; entity area changed. + # - Entity registry: entity added/removed; + # entity labels changed; entity area changed. # - Device registry: device labels changed; device area changed. # - Area registry: area floor changed. # @@ -345,14 +356,32 @@ class TargetStateChangeTracker(TargetEntityChangeTracker): target_selection: TargetSelection, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], + on_entities_update: Callable[[set[str], set[str]], None] | None = None, + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" - super().__init__(hass, target_selection, entity_filter) + super().__init__( + hass, + target_selection, + entity_filter, + primary_entities_only=primary_entities_only, + ) self._action = action + self._on_entities_update = on_entities_update self._state_change_unsub: CALLBACK_TYPE | None = None + self._tracked_entities: set[str] = set() def _handle_entities_update(self, tracked_entities: set[str]) -> None: """Handle the tracked entities.""" + previous_entities = self._tracked_entities + self._tracked_entities = tracked_entities + + if self._on_entities_update is not None: + added = tracked_entities - previous_entities + removed = previous_entities - tracked_entities + if added or removed: + self._on_entities_update(added, removed) @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: @@ -380,12 +409,30 @@ def async_track_target_selector_state_change_event( target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]] = lambda x: x, + on_entities_update: Callable[[set[str], set[str]], None] | None = None, + *, + primary_entities_only: bool = True, ) -> CALLBACK_TYPE: - """Track state changes for entities referenced directly or indirectly in a target selector.""" + """Track state changes for entities in a target selector. + + Tracks entities referenced directly or indirectly. + When `primary_entities_only` is True, indirect target + expansion (via device, area, and floor) skips entities + with an `entity_category` (config or diagnostic entities). + """ target_selection = TargetSelection(target_selector_config) if not target_selection.has_any_target: raise HomeAssistantError( - f"Target selector {target_selector_config} does not have any selectors defined" + "Target selector" + f" {target_selector_config}" + " does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, target_selection, action, entity_filter) + tracker = TargetStateChangeTracker( + hass, + target_selection, + action, + entity_filter, + on_entities_update, + primary_entities_only=primary_entities_only, + ) return tracker.async_setup() diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index 0311486fdd2..1f5ca3b6668 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -1,7 +1,5 @@ """Temperature helpers for Home Assistant.""" -from __future__ import annotations - from numbers import Number from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 728f11bc365..720506e94bc 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1,65 +1,38 @@ """Template helper methods for rendering strings with Home Assistant data.""" -from __future__ import annotations - from ast import literal_eval import asyncio import collections.abc -from collections.abc import Callable, Generator, Iterable -from datetime import datetime, timedelta -from enum import Enum -from functools import cache, lru_cache, partial, wraps +from collections.abc import Callable +import contextlib +from datetime import timedelta +from functools import lru_cache, partial import logging -import math import pathlib import re import sys from types import CodeType -from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload +from typing import TYPE_CHECKING, Any, Literal, Self, overload import weakref -from awesomeversion import AwesomeVersion import jinja2 -from jinja2 import pass_context, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from lru import LRU -from propcache.api import under_cached_property -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, - ATTR_PERSONS, - ATTR_UNIT_OF_MEASUREMENT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfLength, -) -from homeassistant.core import ( - Context, - HomeAssistant, - State, - callback, - valid_domain, - valid_entity_id, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity_registry as er, location as loc_helper from homeassistant.helpers.singleton import singleton -from homeassistant.helpers.translation import ( - async_translate_state, - async_translate_state_attr, +from homeassistant.helpers.trace import ( + suppress_template_error_logging_cv, + trace_stack_cv, + trace_stack_top, ) from homeassistant.helpers.typing import TemplateVarsType -from homeassistant.util import convert, location as location_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads -from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from .context import ( @@ -68,8 +41,40 @@ from .context import ( template_context_manager, template_cv, ) -from .helpers import raise_no_default, result_as_boolean as result_as_boolean +from .extensions import ( + AreaExtension, + Base64Extension, + CollectionExtension, + ConfigEntryExtension, + CryptoExtension, + DateTimeExtension, + DeviceExtension, + EntityExtension, + FloorExtension, + FunctionalExtension, + IssuesExtension, + LabelExtension, + MathExtension, + RegexExtension, + SerializationExtension, + StateExtension, + StringExtension, + TypeCastExtension, + VersionExtension, +) +from .helpers import result_as_boolean as result_as_boolean from .render_info import RenderInfo, render_info_cv +from .states import ( + CACHED_TEMPLATE_LRU, + CACHED_TEMPLATE_NO_COLLECT_LRU, + ENTITY_COUNT_GROWTH_FACTOR, + AllStates, + DomainStates, + StateAttrTranslated, + StateTranslated, + TemplateState as TemplateState, + TemplateStateFromEntityId as TemplateStateFromEntityId, +) if TYPE_CHECKING: from _typeshed import OptExcInfo @@ -92,68 +97,11 @@ _HASS_LOADER = "template.hass_loader" # Match "simple" ints and floats. -1.0, 1, +5, 5.0 _IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") -_RESERVED_NAMES = { - "contextfunction", - "evalcontextfunction", - "environmentfunction", - "jinja_pass_arg", -} - -_COLLECTABLE_STATE_ATTRIBUTES = { - "state", - "attributes", - "last_changed", - "last_updated", - "context", - "domain", - "object_id", - "name", -} - - -# -# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities -# on a typical system. It is used as the initial size of the LRU cache -# for TemplateState objects. -# -# If the cache is too small we will end up creating and destroying -# TemplateState objects too often which will cause a lot of GC activity -# and slow down the system. For systems with a lot of entities and -# templates, this can reach 100000s of object creations and destructions -# per minute. -# -# Since entity counts may grow over time, we will increase -# the size if the number of entities grows via _async_adjust_lru_sizes -# at the start of the system and every 10 minutes if needed. -# -CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 MAX_TEMPLATE_OUTPUT = 256 * 1024 # 256KiB -CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -ENTITY_COUNT_GROWTH_FACTOR = 1.2 - - -def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: - """Return a TemplateState for a state without collecting.""" - if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): - return template_state - template_state = _create_template_state_no_collect(hass, state) - CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state - return template_state - - -def _template_state(hass: HomeAssistant, state: State) -> TemplateState: - """Return a TemplateState for a state that collects.""" - if template_state := CACHED_TEMPLATE_LRU.get(state): - return template_state - template_state = TemplateState(hass, state) - CACHED_TEMPLATE_LRU[state] = template_state - return template_state - def async_setup(hass: HomeAssistant) -> bool: """Set up tracking the template LRUs.""" @@ -161,8 +109,8 @@ def async_setup(hass: HomeAssistant) -> bool: @callback def _async_adjust_lru_sizes(_: Any) -> None: """Adjust the lru cache sizes.""" - new_size = int( - round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + new_size = round( + hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR ) for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): # There is no typing for LRU @@ -328,27 +276,11 @@ class Template: "template", ) - def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: - """Instantiate a template. - - Note: A valid hass instance should always be passed in. The hass parameter - will be non optional in Home Assistant Core 2025.10. - """ - from homeassistant.helpers.frame import ( # noqa: PLC0415 - ReportBehavior, - report_usage, - ) - + def __init__(self, template: str, hass: HomeAssistant) -> None: + """Instantiate a template.""" if not isinstance(template, str): raise TypeError("Expected template to be a string") - if not hass: - report_usage( - "creates a template object without passing hass", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.10", - ) - self.template: str = template.strip() self._compiled_code: CodeType | None = None self._compiled: jinja2.Template | None = None @@ -363,8 +295,6 @@ class Template: @property def _env(self) -> TemplateEnvironment: - if self.hass is None: - return _NO_HASS_ENV # Bypass cache if a custom log function is specified if self._log_fn is not None: return TemplateEnvironment( @@ -456,7 +386,8 @@ class Template: if len(render_result) > MAX_TEMPLATE_OUTPUT: raise TemplateError( - f"Template output exceeded maximum size of {MAX_TEMPLATE_OUTPUT} characters" + "Template output exceeded maximum size of" + f" {MAX_TEMPLATE_OUTPUT} characters" ) render_result = render_result.strip() @@ -528,7 +459,7 @@ class Template: if self._exc_info: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) except TimeoutError: - if template_render_thread.is_alive(): + with contextlib.suppress(ValueError): template_render_thread.raise_exc(TimeoutError) return True finally: @@ -692,775 +623,6 @@ class Template: return f"Template" -@cache -def _domain_states(hass: HomeAssistant, name: str) -> DomainStates: - return DomainStates(hass, name) - - -def _readonly(*args: Any, **kwargs: Any) -> Any: - """Raise an exception when a states object is modified.""" - raise RuntimeError(f"Cannot modify template States object: {args} {kwargs}") - - -class AllStates: - """Class to expose all HA states as attributes.""" - - __setitem__ = _readonly - __delitem__ = _readonly - __slots__ = ("_hass",) - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize all states.""" - self._hass = hass - - def __getattr__(self, name): - """Return the domain state.""" - if "." in name: - return _get_state_if_valid(self._hass, name) - - if name in _RESERVED_NAMES: - return None - - if not valid_domain(name): - raise TemplateError(f"Invalid domain name '{name}'") - - return _domain_states(self._hass, name) - - # Jinja will try __getitem__ first and it avoids the need - # to call is_safe_attribute - __getitem__ = __getattr__ - - def _collect_all(self) -> None: - if (render_info := render_info_cv.get()) is not None: - render_info.all_states = True - - def _collect_all_lifecycle(self) -> None: - if (render_info := render_info_cv.get()) is not None: - render_info.all_states_lifecycle = True - - def __iter__(self) -> Generator[TemplateState]: - """Return all states.""" - self._collect_all() - return _state_generator(self._hass, None) - - def __len__(self) -> int: - """Return number of states.""" - self._collect_all_lifecycle() - return self._hass.states.async_entity_ids_count() - - def __call__( - self, - entity_id: str, - rounded: bool | object = _SENTINEL, - with_unit: bool = False, - ) -> str: - """Return the states.""" - state = _get_state(self._hass, entity_id) - if state is None: - return STATE_UNKNOWN - if rounded is _SENTINEL: - rounded = with_unit - if rounded or with_unit: - return state.format_state(rounded, with_unit) # type: ignore[arg-type] - return state.state - - def __repr__(self) -> str: - """Representation of All States.""" - return "